use std::path::{Path, PathBuf};
use std::process::Command;
fn foxguard_cmd() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_foxguard"));
cmd.args(["--config", "/dev/null"]);
cmd
}
fn django_chain_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("realistic")
.join("django_chain")
}
fn scan_with_list(
root: &Path,
list_contents: &str,
extra: &[&str],
) -> (bool, Vec<(String, String)>) {
let list_file = tempfile::NamedTempFile::new().expect("create temp list file");
std::fs::write(list_file.path(), list_contents).expect("write list file");
let output = foxguard_cmd()
.arg(root)
.arg("--changed-files-from")
.arg(list_file.path())
.args(extra)
.args(["-f", "json"])
.output()
.expect("run foxguard");
let report: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("valid JSON report");
let findings = report["findings"]
.as_array()
.expect("findings array")
.iter()
.map(|f| {
let rule = f["rule_id"].as_str().unwrap_or("").to_string();
let file = f["file"]
.as_str()
.unwrap_or("")
.rsplit('/')
.next()
.unwrap_or("")
.to_string();
(rule, file)
})
.collect();
(output.status.success(), findings)
}
#[test]
fn changed_files_from_preserves_cross_file_taint_among_listed_files() {
let (ok, findings) = scan_with_list(&django_chain_dir(), "views.py\nqueries.py\n", &[]);
assert!(ok || !findings.is_empty(), "scan should run");
assert!(
findings
.iter()
.any(|(rule, file)| rule == "py/taint-sql-injection" && file == "views.py"),
"cross-file taint flow should resolve across listed files; got {findings:?}"
);
}
#[test]
fn changed_files_from_returns_only_listed_files_findings() {
let (_ok, findings) = scan_with_list(&django_chain_dir(), "queries.py\n", &[]);
assert!(
findings.iter().any(|(_, file)| file == "queries.py"),
"listed file's own findings should be present; got {findings:?}"
);
assert!(
findings.iter().all(|(_, file)| file == "queries.py"),
"only listed files should appear; got {findings:?}"
);
assert!(
!findings
.iter()
.any(|(rule, _)| rule == "py/taint-sql-injection"),
"cross-file taint must NOT resolve when the source file is unlisted; got {findings:?}"
);
}
#[test]
fn changed_files_from_empty_list_scans_nothing() {
let (ok, findings) = scan_with_list(&django_chain_dir(), "", &[]);
assert!(ok, "empty list should exit 0");
assert!(
findings.is_empty(),
"empty list should yield no findings; got {findings:?}"
);
}
#[test]
fn changed_files_from_all_missing_paths_scans_nothing() {
let (ok, findings) = scan_with_list(
&django_chain_dir(),
"# a comment\n\ndoes/not/exist.py\nalso_missing.py\n",
&[],
);
assert!(ok, "all-missing list should exit 0");
assert!(
findings.is_empty(),
"all-missing list should yield no findings; got {findings:?}"
);
}
#[test]
fn changed_files_from_respects_exclude() {
let (_ok, baseline) = scan_with_list(&django_chain_dir(), "queries.py\n", &[]);
assert!(
baseline.iter().any(|(_, file)| file == "queries.py"),
"baseline should include queries.py finding; got {baseline:?}"
);
let (ok, excluded) = scan_with_list(
&django_chain_dir(),
"queries.py\n",
&["--exclude", "queries.py"],
);
assert!(ok, "excluded scan should exit 0");
assert!(
excluded.is_empty(),
"excluding queries.py should drop its finding; got {excluded:?}"
);
}