| //! 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) |
| } |