blob: f9542c0aed82bde187bad81d2498c4c0f95dac4f [file] [log] [blame]
//! Access common paths and manipulate the filesystem
use filetime::FileTime;
use itertools::Itertools;
use walkdir::WalkDir;
use std::cell::RefCell;
use std::fs;
use std::io::{self, ErrorKind};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::compare::assert_e2e;
use crate::compare::match_contains;
static CARGO_INTEGRATION_TEST_DIR: &str = "cit";
static GLOBAL_ROOT: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
fn set_global_root(tmp_dir: &'static str) {
let mut lock = GLOBAL_ROOT
.get_or_init(|| Default::default())
.lock()
.unwrap();
if lock.is_none() {
let mut root = PathBuf::from(tmp_dir);
root.push(CARGO_INTEGRATION_TEST_DIR);
*lock = Some(root);
}
}
/// Path to the parent directory of all test [`root`]s
///
/// ex: `$CARGO_TARGET_TMPDIR/cit`
pub fn global_root() -> PathBuf {
let lock = GLOBAL_ROOT
.get_or_init(|| Default::default())
.lock()
.unwrap();
match lock.as_ref() {
Some(p) => p.clone(),
None => unreachable!("GLOBAL_ROOT not set yet"),
}
}
// We need to give each test a unique id. The test name serve this
// purpose. We are able to get the test name by having the `cargo-test-macro`
// crate automatically insert an init function for each test that sets the
// test name in a thread local variable.
thread_local! {
static TEST_ID: RefCell<Option<usize>> = const { RefCell::new(None) };
static TEST_DIR: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}
/// See [`init_root`]
pub struct TestIdGuard {
_private: (),
}
/// For test harnesses like [`crate::cargo_test`]
pub fn init_root(tmp_dir: &'static str, test_dir: PathBuf) -> TestIdGuard {
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
TEST_ID.with(|n| *n.borrow_mut() = Some(id));
if cfg!(windows) {
// Due to path-length limits, Windows doesn't use the full test name.
TEST_DIR.with(|n| *n.borrow_mut() = Some(PathBuf::from(format!("t{id}"))));
} else {
TEST_DIR.with(|n| *n.borrow_mut() = Some(test_dir));
}
let guard = TestIdGuard { _private: () };
set_global_root(tmp_dir);
let r = root();
r.rm_rf();
r.mkdir_p();
#[cfg(not(windows))]
if id == 0 {
// Create a symlink from `t0` to the first test to make it easier to
// find and reuse when running a single test.
use crate::SymlinkBuilder;
let mut alias = global_root();
alias.push("t0");
alias.rm_rf();
SymlinkBuilder::new_dir(r, alias).mk();
}
guard
}
impl Drop for TestIdGuard {
fn drop(&mut self) {
TEST_ID.with(|n| *n.borrow_mut() = None);
TEST_DIR.with(|n| *n.borrow_mut() = None);
}
}
/// Path to the test's filesystem scratchpad
///
/// ex: `$CARGO_TARGET_TMPDIR/cit/<integration test>/<module>/<fn name>/`
/// or `$CARGO_TARGET_TMPDIR/cit/t0` on Windows
pub fn root() -> PathBuf {
let test_dir = TEST_DIR.with(|n| {
n.borrow().clone().expect(
"Tests must use the `#[cargo_test]` attribute in \
order to be able to use the crate root.",
)
});
let mut root = global_root();
root.push(&test_dir);
root
}
/// Path to the current test's `$HOME`
///
/// ex: `$CARGO_TARGET_TMPDIR/cit/t0/home`
pub fn home() -> PathBuf {
let mut path = root();
path.push("home");
path.mkdir_p();
path
}
/// Path to the current test's `$CARGO_HOME`
///
/// ex: `$CARGO_TARGET_TMPDIR/cit/t0/home/.cargo`
pub fn cargo_home() -> PathBuf {
home().join(".cargo")
}
/// Path to the current test's `$CARGO_LOG`
///
/// ex: `$CARGO_TARGET_TMPDIR/cit/t0/home/.cargo/log`
pub fn log_dir() -> PathBuf {
cargo_home().join("log")
}
/// Path to the current test's `$CARGO_LOG` file
///
/// ex: `$CARGO_TARGET_TMPDIR/cit/t0/home/.cargo/log/<id>.jsonl`
///
/// This also asserts the number of log files is exactly the same as `idx + 1`.
pub fn log_file(idx: usize) -> PathBuf {
let log_dir = log_dir();
let entries = std::fs::read_dir(&log_dir).unwrap();
let mut log_files: Vec<_> = entries
.filter_map(Result::ok)
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("jsonl"))
.collect();
// Sort them to get chronological order
log_files.sort_unstable_by(|a, b| a.file_name().to_str().cmp(&b.file_name().to_str()));
assert_eq!(
idx + 1,
log_files.len(),
"unexpected number of log files: {}, expected {}",
log_files.len(),
idx + 1
);
log_files[idx].path()
}
/// Common path and file operations
pub trait CargoPathExt {
fn to_url(&self) -> url::Url;
fn rm_rf(&self);
fn mkdir_p(&self);
/// Returns a list of all files and directories underneath the given
/// directory, recursively, including the starting path.
fn ls_r(&self) -> Vec<PathBuf>;
fn move_into_the_past(&self) {
self.move_in_time(|sec, nsec| (sec - 3600, nsec))
}
fn move_into_the_future(&self) {
self.move_in_time(|sec, nsec| (sec + 3600, nsec))
}
fn move_in_time<F>(&self, travel_amount: F)
where
F: Fn(i64, u32) -> (i64, u32);
fn assert_build_dir_layout(&self, expected: impl snapbox::IntoData);
fn assert_dir_layout(&self, expected: impl snapbox::IntoData, ignored_path_patterns: &[String]);
}
impl CargoPathExt for Path {
fn to_url(&self) -> url::Url {
url::Url::from_file_path(self).ok().unwrap()
}
fn rm_rf(&self) {
let meta = match self.symlink_metadata() {
Ok(meta) => meta,
Err(e) => {
if e.kind() == ErrorKind::NotFound {
return;
}
panic!("failed to remove {:?}, could not read: {:?}", self, e);
}
};
// There is a race condition between fetching the metadata and
// actually performing the removal, but we don't care all that much
// for our tests.
if meta.is_dir() {
if let Err(e) = fs::remove_dir_all(self) {
panic!("failed to remove {:?}: {:?}", self, e)
}
} else if let Err(e) = fs::remove_file(self) {
panic!("failed to remove {:?}: {:?}", self, e)
}
}
fn mkdir_p(&self) {
fs::create_dir_all(self)
.unwrap_or_else(|e| panic!("failed to mkdir_p {}: {}", self.display(), e))
}
fn ls_r(&self) -> Vec<PathBuf> {
walkdir::WalkDir::new(self)
.sort_by_file_name()
.into_iter()
.filter_map(|e| e.map(|e| e.path().to_owned()).ok())
.collect()
}
fn move_in_time<F>(&self, travel_amount: F)
where
F: Fn(i64, u32) -> (i64, u32),
{
if self.is_file() {
time_travel(self, &travel_amount);
} else {
recurse(self, &self.join("target"), &travel_amount);
}
fn recurse<F>(p: &Path, bad: &Path, travel_amount: &F)
where
F: Fn(i64, u32) -> (i64, u32),
{
if p.is_file() {
time_travel(p, travel_amount)
} else if !p.starts_with(bad) {
for f in t!(fs::read_dir(p)) {
let f = t!(f).path();
recurse(&f, bad, travel_amount);
}
}
}
fn time_travel<F>(path: &Path, travel_amount: &F)
where
F: Fn(i64, u32) -> (i64, u32),
{
let stat = t!(path.symlink_metadata());
let mtime = FileTime::from_last_modification_time(&stat);
let (sec, nsec) = travel_amount(mtime.unix_seconds(), mtime.nanoseconds());
let newtime = FileTime::from_unix_time(sec, nsec);
// Sadly change_file_times has a failure mode where a readonly file
// cannot have its times changed on windows.
do_op(path, "set file times", |path| {
filetime::set_file_times(path, newtime, newtime)
});
}
}
#[track_caller]
fn assert_build_dir_layout(&self, expected: impl snapbox::IntoData) {
// We call `unordered()` here to because the build-dir has some scenarios that make
// consistent ordering not possible.
// Notably:
// 1. Binaries with `.exe` on Windows causing the ordering to change with the dep-info `.d`
// file.
// 2. Directories with hashes are often reordered differently by platform.
self.assert_dir_layout(expected.unordered(), &build_dir_ignored_path_patterns());
}
#[track_caller]
fn assert_dir_layout(
&self,
expected: impl snapbox::IntoData,
ignored_path_patterns: &[String],
) {
let assert = assert_e2e();
let actual = WalkDir::new(self)
.sort_by_file_name()
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.map(|e| e.path().to_string_lossy().into_owned())
.filter(|file| {
for ignored in ignored_path_patterns {
if match_contains(&ignored, file, &assert.redactions()).is_ok() {
return false;
}
}
return true;
})
.join("\n");
assert.eq(format!("{actual}\n"), expected);
}
}
impl CargoPathExt for PathBuf {
fn to_url(&self) -> url::Url {
self.as_path().to_url()
}
fn rm_rf(&self) {
self.as_path().rm_rf()
}
fn mkdir_p(&self) {
self.as_path().mkdir_p()
}
fn ls_r(&self) -> Vec<PathBuf> {
self.as_path().ls_r()
}
fn move_in_time<F>(&self, travel_amount: F)
where
F: Fn(i64, u32) -> (i64, u32),
{
self.as_path().move_in_time(travel_amount)
}
#[track_caller]
fn assert_build_dir_layout(&self, expected: impl snapbox::IntoData) {
self.as_path().assert_build_dir_layout(expected);
}
#[track_caller]
fn assert_dir_layout(
&self,
expected: impl snapbox::IntoData,
ignored_path_patterns: &[String],
) {
self.as_path()
.assert_dir_layout(expected, ignored_path_patterns);
}
}
fn do_op<F>(path: &Path, desc: &str, mut f: F)
where
F: FnMut(&Path) -> io::Result<()>,
{
match f(path) {
Ok(()) => {}
Err(ref e) if e.kind() == ErrorKind::PermissionDenied => {
let mut p = t!(path.metadata()).permissions();
p.set_readonly(false);
t!(fs::set_permissions(path, p));
// Unix also requires the parent to not be readonly for example when
// removing files
let parent = path.parent().unwrap();
let mut p = t!(parent.metadata()).permissions();
p.set_readonly(false);
t!(fs::set_permissions(parent, p));
f(path).unwrap_or_else(|e| {
panic!("failed to {} {}: {}", desc, path.display(), e);
})
}
Err(e) => {
panic!("failed to {} {}: {}", desc, path.display(), e);
}
}
}
/// The paths to ignore when [`CargoPathExt::assert_build_dir_layout`] is called
fn build_dir_ignored_path_patterns() -> Vec<String> {
vec![
// Ignore MacOS debug symbols as there are many files/directories that would clutter up
// tests few not a lot of benefit.
"[..].dSYM/[..]",
// Ignore Windows debug symbols files (.pdb)
"[..].pdb",
]
.into_iter()
.map(ToString::to_string)
.collect()
}
/// Get the filename for a library.
///
/// `kind` should be one of:
/// - `lib`
/// - `rlib`
/// - `staticlib`
/// - `dylib`
/// - `proc-macro`
///
/// # Examples
/// ```
/// # use cargo_test_support::paths::get_lib_filename;
/// get_lib_filename("foo", "dylib");
/// ```
/// would return:
/// - macOS: `"libfoo.dylib"`
/// - Windows: `"foo.dll"`
/// - Unix: `"libfoo.so"`
pub fn get_lib_filename(name: &str, kind: &str) -> String {
let prefix = get_lib_prefix(kind);
let extension = get_lib_extension(kind);
format!("{}{}.{}", prefix, name, extension)
}
/// See [`get_lib_filename`] for more details
pub fn get_lib_prefix(kind: &str) -> &str {
match kind {
"lib" | "rlib" => "lib",
"staticlib" | "dylib" | "proc-macro" => {
if cfg!(windows) {
""
} else {
"lib"
}
}
_ => unreachable!(),
}
}
/// See [`get_lib_filename`] for more details
pub fn get_lib_extension(kind: &str) -> &str {
match kind {
"lib" | "rlib" => "rlib",
"staticlib" => {
if cfg!(windows) {
"lib"
} else {
"a"
}
}
"dylib" | "proc-macro" => {
if cfg!(windows) {
"dll"
} else if cfg!(target_os = "macos") {
"dylib"
} else {
"so"
}
}
_ => unreachable!(),
}
}
/// Path to `rustc`s sysroot
pub fn sysroot() -> String {
let output = Command::new("rustc")
.arg("--print=sysroot")
.output()
.expect("rustc to run");
assert!(output.status.success());
let sysroot = String::from_utf8(output.stdout).unwrap();
sysroot.trim().to_string()
}
/// Returns true if names such as aux.* are allowed.
///
/// Traditionally, Windows did not allow a set of file names (see `is_windows_reserved`
/// for a list). More recent versions of Windows have relaxed this restriction. This test
/// determines whether we are running in a mode that allows Windows reserved names.
#[cfg(windows)]
pub fn windows_reserved_names_are_allowed() -> bool {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::ptr;
use windows_sys::Win32::Storage::FileSystem::GetFullPathNameW;
let test_file_name: Vec<_> = OsStr::new("aux.rs").encode_wide().chain([0]).collect();
let buffer_length =
unsafe { GetFullPathNameW(test_file_name.as_ptr(), 0, ptr::null_mut(), ptr::null_mut()) };
if buffer_length == 0 {
// This means the call failed, so we'll conservatively assume reserved names are not allowed.
return false;
}
let mut buffer = vec![0u16; buffer_length as usize];
let result = unsafe {
GetFullPathNameW(
test_file_name.as_ptr(),
buffer_length,
buffer.as_mut_ptr(),
ptr::null_mut(),
)
};
if result == 0 {
// Once again, conservatively assume reserved names are not allowed if the
// GetFullPathNameW call failed.
return false;
}
// Under the old rules, a file name like aux.rs would get converted into \\.\aux, so
// we detect this case by checking if the string starts with \\.\
//
// Otherwise, the filename will be something like C:\Users\Foo\Documents\aux.rs
let prefix: Vec<_> = OsStr::new("\\\\.\\").encode_wide().collect();
if buffer.starts_with(&prefix) {
false
} else {
true
}
}
/// This takes the test location (std::file!() should be passed) and the test name
/// and outputs the location the test should be places in, inside of `target/tmp/cit`
///
/// `path: tests/testsuite/workspaces.rs`
/// `name: `workspace_in_git
/// `output: "testsuite/workspaces/workspace_in_git`
pub fn test_dir(path: &str, name: &str) -> std::path::PathBuf {
let test_dir: std::path::PathBuf = std::path::PathBuf::from(path)
.components()
// Trim .rs from any files
.map(|c| c.as_os_str().to_str().unwrap().trim_end_matches(".rs"))
// We only want to take once we have reached `tests` or `src`. This helps when in a
// workspace: `workspace/more/src/...` would result in `src/...`
.skip_while(|c| c != &"tests" && c != &"src")
// We want to skip "tests" since it is taken in `skip_while`.
// "src" is fine since you could have test in "src" named the same as one in "tests"
// Skip "mod" since `snapbox` tests have a folder per test not a file and the files
// are named "mod.rs"
.filter(|c| c != &"tests" && c != &"mod")
.collect();
test_dir.join(name)
}