use std::path::{Path, PathBuf};
use tempfile::TempDir;
use crate::test_helpers::env::env_mutex;
const ARG_SEP: char = '\u{1f}';
const REC_SEP: char = '\u{1e}';
pub struct FakeToolDir {
dir: TempDir,
}
impl FakeToolDir {
pub fn new() -> Self {
let dir = TempDir::new().expect("fake_tool: create temp dir");
Self { dir }
}
pub fn bin_dir(&self) -> &Path {
self.dir.path()
}
pub fn tool_path(&self, name: &str) -> PathBuf {
self.dir.path().join(stub_file_name(name))
}
pub fn tool<'a>(&'a self, name: &str) -> ToolSpec<'a> {
ToolSpec {
dir: self,
name: name.to_string(),
stdout: String::new(),
stderr: String::new(),
exit: 0,
script: None,
creates: Vec::new(),
}
}
pub fn activate(&self) -> PathGuard {
let lock = env_mutex().lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var_os("PATH");
let mut entries: Vec<PathBuf> = vec![self.dir.path().to_path_buf()];
if let Some(ref p) = prior {
entries.extend(std::env::split_paths(p));
}
let joined = std::env::join_paths(entries).expect("fake_tool: join PATH");
unsafe { std::env::set_var("PATH", &joined) };
PathGuard { prior, _lock: lock }
}
pub fn calls(&self, name: &str) -> Vec<Vec<String>> {
let log = self.calls_path(name);
let Ok(raw) = std::fs::read_to_string(&log) else {
return Vec::new();
};
let mut records: Vec<&str> = raw.split(REC_SEP).collect();
if records.last() == Some(&"") {
records.pop();
}
records
.iter()
.map(|rec| {
rec.split(ARG_SEP)
.filter(|a| !a.is_empty())
.map(|a| a.to_string())
.collect()
})
.collect()
}
pub fn was_called(&self, name: &str) -> bool {
self.calls_path(name).exists()
}
pub fn call_count(&self, name: &str) -> usize {
self.calls(name).len()
}
fn calls_path(&self, name: &str) -> PathBuf {
self.dir.path().join(format!(".calls.{name}"))
}
}
impl Default for FakeToolDir {
fn default() -> Self {
Self::new()
}
}
#[must_use = "call `.install()` to write the stub"]
pub struct ToolSpec<'a> {
dir: &'a FakeToolDir,
name: String,
stdout: String,
stderr: String,
exit: i32,
script: Option<String>,
creates: Vec<(String, String)>,
}
impl ToolSpec<'_> {
pub fn stdout(mut self, s: impl Into<String>) -> Self {
self.stdout = s.into();
self
}
pub fn stderr(mut self, s: impl Into<String>) -> Self {
self.stderr = s.into();
self
}
pub fn exit(mut self, code: i32) -> Self {
self.exit = code;
self
}
pub fn creates(mut self, rel_path: impl Into<String>, contents: impl Into<String>) -> Self {
self.creates.push((rel_path.into(), contents.into()));
self
}
pub fn script(mut self, body: impl Into<String>) -> Self {
self.script = Some(body.into());
self
}
pub fn install(self) {
let path = self.dir.tool_path(&self.name);
let calls = self.dir.calls_path(&self.name);
let body = self.render_script(&calls);
std::fs::write(&path, body).expect("fake_tool: write stub");
make_executable(&path);
}
#[cfg(unix)]
fn render_script(&self, calls: &Path) -> String {
let mut s = String::from("#!/bin/sh\n");
s.push_str(&format!(
"__r=''\nfor __a in \"$@\"; do __r=\"${{__r}}${{__a}}{arg}\"; done\n\
printf '%s{rec}' \"$__r\" >> {log}\n",
arg = ARG_SEP,
rec = REC_SEP,
log = sh_quote(&calls.to_string_lossy()),
));
for (rel, contents) in &self.creates {
s.push_str(&format!(
"mkdir -p \"$(dirname {p})\" 2>/dev/null || true\n",
p = sh_quote(rel)
));
s.push_str(&format!(
"printf '%s' {c} > {p}\n",
c = sh_quote(contents),
p = sh_quote(rel),
));
}
if let Some(custom) = &self.script {
s.push_str(custom);
if !custom.ends_with('\n') {
s.push('\n');
}
} else {
s.push_str("cat >/dev/null 2>&1\n");
if !self.stdout.is_empty() {
s.push_str(&format!("printf '%s' {}\n", sh_quote(&self.stdout)));
}
if !self.stderr.is_empty() {
s.push_str(&format!("printf '%s' {} 1>&2\n", sh_quote(&self.stderr)));
}
s.push_str(&format!("exit {}\n", self.exit));
}
s
}
#[cfg(not(unix))]
fn render_script(&self, calls: &Path) -> String {
assert!(
self.script.is_none() && self.creates.is_empty(),
"fake_tool: .script()/.creates() are unix-only"
);
let mut s = String::from("@echo off\r\n");
s.push_str(&format!(">>\"{}\" echo %*\r\n", calls.display()));
if !self.stdout.is_empty() {
s.push_str(&format!(
"echo|set /p=\"{}\"\r\n",
self.stdout.replace('\n', " ")
));
}
if !self.stderr.is_empty() {
s.push_str(&format!(
"echo|set /p=\"{}\" 1>&2\r\n",
self.stderr.replace('\n', " ")
));
}
s.push_str(&format!("exit /b {}\r\n", self.exit));
s
}
}
pub struct PathGuard {
prior: Option<std::ffi::OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl Drop for PathGuard {
fn drop(&mut self) {
unsafe {
match &self.prior {
Some(p) => std::env::set_var("PATH", p),
None => std::env::remove_var("PATH"),
}
}
}
}
#[cfg(unix)]
fn stub_file_name(name: &str) -> String {
name.to_string()
}
#[cfg(not(unix))]
fn stub_file_name(name: &str) -> String {
format!("{name}.cmd")
}
#[cfg(unix)]
fn make_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)
.expect("fake_tool: stat stub")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).expect("fake_tool: chmod stub");
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) {}
#[cfg(unix)]
fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
#[cfg(unix)]
pub fn output_retrying_etxtbsy(cmd: &mut std::process::Command) -> std::process::Output {
for _ in 0..50 {
match cmd.output() {
Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => {
std::thread::sleep(std::time::Duration::from_millis(10));
}
other => return other.expect("spawn fake tool"),
}
}
panic!("fake tool stayed ETXTBSY after 50 retries");
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::process::Command;
#[test]
fn records_argv_across_invocations() {
let tools = FakeToolDir::new();
tools.tool("widget").stdout("ok\n").install();
let bin = tools.tool_path("widget");
let out = output_retrying_etxtbsy(Command::new(&bin).args(["build", "--fast"]));
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout), "ok\n");
output_retrying_etxtbsy(Command::new(&bin).arg("clean"));
assert!(tools.was_called("widget"));
assert_eq!(tools.call_count("widget"), 2);
let calls = tools.calls("widget");
assert_eq!(calls[0], vec!["build", "--fast"]);
assert_eq!(calls[1], vec!["clean"]);
}
#[test]
fn honors_exit_code_and_stderr() {
let tools = FakeToolDir::new();
tools.tool("boom").stderr("fatal\n").exit(7).install();
let out = output_retrying_etxtbsy(&mut Command::new(tools.tool_path("boom")));
assert_eq!(out.status.code(), Some(7));
assert_eq!(String::from_utf8_lossy(&out.stderr), "fatal\n");
}
#[test]
fn creates_output_file() {
let tools = FakeToolDir::new();
tools
.tool("gen")
.creates("out/doc.json", "{\"k\":1}")
.install();
let work = TempDir::new().unwrap();
let out =
output_retrying_etxtbsy(Command::new(tools.tool_path("gen")).current_dir(work.path()));
assert!(out.status.success());
let body = std::fs::read_to_string(work.path().join("out/doc.json")).unwrap();
assert_eq!(body, "{\"k\":1}");
}
#[test]
fn custom_script_sees_argv() {
let tools = FakeToolDir::new();
tools
.tool("syft")
.script("for a in \"$@\"; do case \"$a\" in *=*) echo '{}' > \"${a#*=}\";; esac; done")
.install();
let work = TempDir::new().unwrap();
output_retrying_etxtbsy(
Command::new(tools.tool_path("syft"))
.current_dir(work.path())
.args(["scan", "-o", "spdx-json=bom.json"]),
);
assert_eq!(
std::fs::read_to_string(work.path().join("bom.json")).unwrap(),
"{}\n"
);
}
#[test]
#[serial_test::serial]
fn activate_prepends_path_and_restores() {
let before = std::env::var_os("PATH");
let tools = FakeToolDir::new();
tools.tool("findme").install();
{
let _g = tools.activate();
let resolved = which_on_path("findme");
assert_eq!(
resolved.as_deref(),
Some(tools.tool_path("findme").as_path())
);
}
assert_eq!(std::env::var_os("PATH"), before);
}
fn which_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|d| d.join(name))
.find(|p| p.exists())
}
}