mod utils;
use assert_cmd::prelude::*;
use httpmock::Method::GET;
use httpmock::MockServer;
use predicates::prelude::*;
use std::thread::sleep;
use std::time::Duration;
use std::{process::Command, time};
use utils::{setup_tmp_directory, teardown_tmp_directory};
#[test]
fn scanner_single_request_scan() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains("14")),
);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
fn scanner_recursive_request_scan() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let urls = [
"js".to_string(),
"prod".to_string(),
"dev".to_string(),
"file.js".to_string(),
];
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
let js_mock = srv.mock(|when, then| {
when.method(GET).path("/js");
then.status(301).header("Location", srv.url("/js/"));
});
let js_prod_mock = srv.mock(|when, then| {
when.method(GET).path("/js/prod");
then.status(301).header("Location", srv.url("/js/prod/"));
});
let js_dev_mock = srv.mock(|when, then| {
when.method(GET).path("/js/dev");
then.status(301).header("Location", srv.url("/js/dev/"));
});
let js_dev_file_mock = srv.mock(|when, then| {
when.method(GET).path("/js/dev/file.js");
then.status(200)
.body("this is a test and is more bytes than other ones");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.arg("-t")
.arg("1")
.unwrap();
cmd.assert().success().stdout(
predicate::str::is_match("301.*js")
.unwrap()
.and(predicate::str::is_match("301.*js/prod").unwrap())
.and(predicate::str::is_match("301.*js/dev").unwrap())
.and(predicate::str::is_match("200.*js/dev/file.js").unwrap()),
);
assert_eq!(js_mock.hits(), 2);
assert_eq!(js_prod_mock.hits(), 2);
assert_eq!(js_dev_mock.hits(), 2);
assert_eq!(js_dev_file_mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
fn scanner_recursive_request_scan_using_only_success_responses(
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let urls = [
"js/".to_string(),
"prod/".to_string(),
"dev/".to_string(),
"file.js".to_string(),
];
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
let js_mock = srv.mock(|when, then| {
when.method(GET).path("/js/");
then.status(200).header("Location", srv.url("/js/"));
});
let js_prod_mock = srv.mock(|when, then| {
when.method(GET).path("/js/prod/");
then.status(200).header("Location", srv.url("/js/prod/"));
});
let js_dev_mock = srv.mock(|when, then| {
when.method(GET).path("/js/dev/");
then.status(200).header("Location", srv.url("/js/dev/"));
});
let js_dev_file_mock = srv.mock(|when, then| {
when.method(GET).path("/js/dev/file.js");
then.status(200)
.body("this is a test and is more bytes than other ones");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.arg("-t")
.arg("1")
.arg("--redirects")
.unwrap();
cmd.assert().success().stdout(
predicate::str::is_match("200.*js")
.unwrap()
.and(predicate::str::is_match("200.*js/prod").unwrap())
.and(predicate::str::is_match("200.*js/dev").unwrap())
.and(predicate::str::is_match("200.*js/dev/file.js").unwrap()),
);
assert_eq!(js_mock.hits(), 3);
assert_eq!(js_prod_mock.hits(), 3);
assert_eq!(js_dev_mock.hits(), 3);
assert_eq!(js_dev_file_mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
fn scanner_single_request_scan_with_file_output() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let outfile = tmp_dir.path().join("output");
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.arg("-o")
.arg(outfile.as_os_str())
.unwrap();
let contents = std::fs::read_to_string(outfile)?;
assert!(contents.contains("/LICENSE"));
assert!(contents.contains("200"));
assert!(contents.contains("14"));
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
fn scanner_single_request_scan_with_file_output_and_tack_q(
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let outfile = tmp_dir.path().join("output");
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.arg("-q")
.arg("-o")
.arg(outfile.as_os_str())
.unwrap();
let contents = std::fs::read_to_string(outfile)?;
let url = srv.url("/LICENSE");
assert!(contents.contains(&url));
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
fn scanner_single_request_scan_with_invalid_file_output() -> Result<(), Box<dyn std::error::Error>>
{
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let outfile = tmp_dir.path();
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.arg("-q")
.arg("-o")
.arg(outfile.as_os_str())
.unwrap();
let contents = std::fs::read_to_string(outfile);
assert!(contents.is_err());
assert_eq!(mock.hits(), 0);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
fn scanner_single_request_quiet_scan() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-x")
.arg("js,html")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains(srv.url("/LICENSE"))
.and(predicate::str::contains("200"))
.not()
.and(predicate::str::contains("14"))
.not(),
);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
fn scanner_single_request_returns_301_without_location_header(
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(301).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--timeout")
.arg("5")
.arg("--user-agent")
.arg("some-user-agent-string")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains(srv.url("/LICENSE"))
.and(predicate::str::contains("301"))
.and(predicate::str::contains("14")),
);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
fn scanner_single_request_replayed_to_proxy() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let proxy = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let mock_two = proxy.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--replay-proxy")
.arg(format!("http://{}", proxy.address()))
.arg("--replay-codes")
.arg("200")
.unwrap();
cmd.assert()
.success()
.stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains("14c")),
)
.stderr(predicate::str::contains("Replay Proxy Codes"));
assert_eq!(mock.hits(), 1);
assert_eq!(mock_two.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
fn scanner_single_request_scan_with_filtered_result() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "ignored".to_string()], "wordlist")?;
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a not a test");
});
let filtered_mock = srv.mock(|when, then| {
when.method(GET).path("/ignored");
then.status(200).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-n")
.arg("-S")
.arg("14")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains("20"))
.and(predicate::str::contains("ignored"))
.not()
.and(predicate::str::contains(" 14 "))
.not(),
);
assert_eq!(mock.hits(), 1);
assert_eq!(filtered_mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
#[should_panic] fn scanner_single_request_scan_with_debug_logging() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let outfile = tmp_dir.path().join("debug.log");
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.arg("--debug-log")
.arg(outfile.as_os_str())
.unwrap();
let contents = std::fs::read_to_string(outfile).unwrap();
println!("{contents}");
assert!(contents.starts_with("Configuration {"));
assert!(contents.contains("TRC"));
assert!(contents.contains("DBG"));
assert!(contents.contains("INF"));
assert!(contents.contains("feroxbuster All scans complete!"));
assert!(contents.contains("feroxbuster::event_handlers::inputs exit: start_enter_handler"));
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
}
#[test]
#[should_panic] fn scanner_single_request_scan_with_debug_logging_as_json() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let outfile = tmp_dir.path().join("debug.log");
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.arg("--debug-log")
.arg(outfile.as_os_str())
.arg("--json")
.unwrap();
let contents = std::fs::read_to_string(outfile).unwrap();
println!("{contents}");
assert!(contents.starts_with("{\"type\":\"configuration\""));
assert!(contents.contains("\"level\":\"TRACE\""));
assert!(contents.contains("\"level\":\"DEBUG\""));
assert!(contents.contains("\"level\":\"INFO\""));
assert!(contents.contains("time_offset"));
assert!(contents.contains("exit: main"));
assert!(contents.contains(&srv.url("/LICENSE")));
assert!(contents.contains("\"module\":\"feroxbuster::response\""));
assert!(contents.contains("\"module\":\"feroxbuster::url\""));
assert!(contents.contains("\"module\":\"feroxbuster::event_handlers::inputs\""));
assert!(contents.contains("exit: start_enter_handler"));
assert!(contents.contains("All scans complete!"));
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
}
#[test]
fn scanner_single_request_scan_with_regex_filtered_result() {
let srv = MockServer::start();
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "ignored".to_string()], "wordlist").unwrap();
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let filtered_mock = srv.mock(|when, then| {
when.method(GET).path("/ignored");
then.status(200)
.body("this is a test\nThat rug really tied the room together");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--filter-regex")
.arg("'That rug.*together$'")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains("20"))
.and(predicate::str::contains("ignored"))
.not()
.and(predicate::str::contains(" 14 "))
.not(),
);
assert_eq!(mock.hits(), 1);
assert_eq!(filtered_mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
}
#[test]
fn scanner_recursion_works_with_403_directories() {
let srv = MockServer::start();
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "ignored/".to_string()], "wordlist").unwrap();
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let forbidden_dir = srv.mock(|when, then| {
when.method(GET).path("/ignored/");
then.status(403);
});
let found_anyway = srv.mock(|when, then| {
when.method(GET).path("/ignored/LICENSE");
then.status(200)
.body("this is a test\nThat rug really tied the room together");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.count(2)
.and(predicate::str::contains("200"))
.and(predicate::str::contains("404"))
.and(predicate::str::contains("53c Auto-filtering"))
.and(predicate::str::contains(
"Auto-filtering found 404-like response and created new filter;",
))
.and(predicate::str::contains("14c"))
.and(predicate::str::contains("0c"))
.and(predicate::str::contains("ignored").count(2))
.and(predicate::str::contains("/ignored/LICENSE")),
);
assert_eq!(mock.hits(), 1);
assert_eq!(found_anyway.hits(), 1);
assert_eq!(forbidden_dir.hits(), 3);
teardown_tmp_directory(tmp_dir);
}
#[test]
fn rate_limit_enforced_when_specified() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(
&[
"css".to_string(),
"stuff".to_string(),
"css1".to_string(),
"css2".to_string(),
"css3".to_string(),
"css4".to_string(),
],
"wordlist",
)
.unwrap();
let now = time::Instant::now();
let lower_bound = time::Duration::new(5, 0);
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--rate-limit")
.arg("1")
.assert()
.success();
assert!(now.elapsed() > lower_bound);
teardown_tmp_directory(tmp_dir);
}
#[test]
fn add_discovered_extension_updates_bars_and_stats() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(
&["LICENSE".to_string(), "stuff.php".to_string()],
"wordlist",
)
.unwrap();
let mock = srv.mock(|when, then| {
when.method(GET).path("/stuff.php");
then.status(200).body("cool... coolcoolcool");
});
let file_path = tmp_dir.path().join("debug-file.txt");
assert!(!file_path.exists());
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--extract-links")
.arg("--collect-extensions")
.arg("-vvvv")
.arg("--debug-log")
.arg(file_path.as_os_str())
.unwrap()
.assert()
.success();
mock.assert_hits(1);
let contents = std::fs::read_to_string(file_path).unwrap();
println!("{contents}");
assert!(contents.contains("discovered new extension: php"));
assert!(contents.contains("expected_per_scan: 6"));
}
#[test]
fn collect_backups_makes_appropriate_requests() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE.txt".to_string()], "wordlist").unwrap();
let valid_paths = [
"/LICENSE.txt",
"/LICENSE.txt~",
"/LICENSE.txt.bak",
"/LICENSE.txt.bak2",
"/LICENSE.txt.old",
"/LICENSE.txt.1",
"/LICENSE.bak",
"/.LICENSE.txt.swp",
];
let valid_mocks: Vec<_> = valid_paths
.iter()
.map(|&p| {
srv.mock(|when, then| {
when.method(GET).path(p);
then.status(200).body("this is a valid test");
})
})
.collect();
let invalid_paths: Vec<_> = vec![
"/LICENSE.txt~~",
"/LICENSE.txt.bak.bak",
"/LICENSE.txt.bak2.bak2",
"/LICENSE.txt.old.old",
"/LICENSE.txt.1.1",
"/..LICENSE.txt.swp.swp",
];
let invalid_mocks: Vec<_> = invalid_paths
.iter()
.map(|&p| {
srv.mock(|when, then| {
when.method(GET).path(p);
then.status(200).body("this is an invalid test");
})
})
.collect();
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--collect-backups")
.arg("--wordlist")
.arg(file.as_os_str())
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE.txt")
.and(predicate::str::contains("/LICENSE.txt~"))
.and(predicate::str::contains("/LICENSE.txt.bak"))
.and(predicate::str::contains("/LICENSE.txt.bak2"))
.and(predicate::str::contains("/LICENSE.txt.old"))
.and(predicate::str::contains("/LICENSE.txt.1"))
.and(predicate::str::contains("/LICENSE.bak"))
.and(predicate::str::contains("/.LICENSE.txt.swp")),
);
for valid_mock in valid_mocks {
assert_eq!(valid_mock.hits(), 1);
}
for invalid_mock in invalid_mocks {
assert_eq!(invalid_mock.hits(), 0);
}
teardown_tmp_directory(tmp_dir);
}
#[test]
fn collect_words_makes_appropriate_requests() {
let srv = MockServer::start();
let wordlist: Vec<_> = [
"doc1", "doc2", "doc3", "doc4", "blah", "blah2", "blah3", "blah4",
]
.iter()
.map(|w| w.to_string())
.collect();
let (tmp_dir, file) = setup_tmp_directory(&wordlist, "wordlist").unwrap();
srv.mock(|when, then| {
when.method(GET).path("/doc1");
then.status(200)
.body("Air quality in the sunny island improved gradually throughout Wednesday.");
});
srv.mock(|when, then| {
when.method(GET).path("/doc2");
then.status(200).body(
"Air quality in Singapore on Wednesday continued to get worse as haze hit the island.",
);
});
srv.mock(|when, then| {
when.method(GET).path("/doc3");
then.status(200).body("The air quality in Singapore is monitored through a network of air monitoring stations located in different parts of the island");
});
srv.mock(|when, then| {
when.method(GET).path("/doc4");
then.status(200)
.body("The air quality in Singapore got worse on Wednesday.");
});
let valid_paths = vec![
"/gradually",
"/network",
"/hit",
"/located",
"/continued",
"/island",
"/worse",
"/monitored",
"/monitoring",
"/haze",
"/different",
"/stations",
"/sunny",
"/singapore",
"/improved",
"/parts",
"/wednesday",
];
let valid_mocks: Vec<_> = valid_paths
.iter()
.map(|&p| {
srv.mock(|when, then| {
when.method(GET).path(p);
then.status(200);
})
})
.collect();
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("-vv")
.arg("--collect-words")
.arg("-t")
.arg("1")
.arg("--wordlist")
.arg(file.as_os_str())
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/doc1")
.and(predicate::str::contains("/doc2"))
.and(predicate::str::contains("/doc3"))
.and(predicate::str::contains("/doc4")),
);
sleep(Duration::new(2, 0));
for valid_mock in valid_mocks {
assert_eq!(valid_mock.hits(), 1);
}
teardown_tmp_directory(tmp_dir);
}
#[test]
fn scanner_forced_recursion_ignores_normal_redirect_logic() -> Result<(), Box<dyn std::error::Error>>
{
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock1 = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(301)
.body("this is a test")
.header("Location", srv.url("/LICENSE"));
});
let mock2 = srv.mock(|when, then| {
when.method(GET).path("/LICENSE/LICENSE");
then.status(404);
});
let mock3 = srv.mock(|when, then| {
when.method(GET).path("/LICENSE/LICENSE/LICENSE");
then.status(404);
});
let mock4 = srv.mock(|when, then| {
when.method(GET).path("/LICENSE/LICENSE/LICENSE/LICENSE");
then.status(404);
});
let outfile = tmp_dir.path().join("output");
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--force-recursion")
.arg("--dont-filter")
.arg("--status-codes")
.arg("301")
.arg("200")
.arg("-o")
.arg(outfile.as_os_str())
.unwrap();
let contents = std::fs::read_to_string(outfile)?;
println!("{contents}");
assert!(contents.contains("/LICENSE"));
assert!(contents.contains("301"));
assert!(contents.contains("14"));
assert_eq!(mock1.hits(), 2);
assert_eq!(mock2.hits(), 1);
assert_eq!(mock3.hits(), 0);
assert_eq!(mock4.hits(), 0);
teardown_tmp_directory(tmp_dir);
Ok(())
}