fold/tests/integration/test_rust_frontend.cpp

490 lines
18 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <map>
#include <nlohmann/json.hpp>
#include <optional>
#include <set>
#include <sstream>
#include <string>
namespace {
struct TempDir {
std::filesystem::path path;
TempDir()
: path(std::filesystem::temp_directory_path() /
("fold-rust-frontend-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()))) {
std::filesystem::create_directories(path);
}
~TempDir() { std::filesystem::remove_all(path); }
};
struct RustScanResult {
int exit_code = 0;
nlohmann::json report;
std::string log;
};
void writeFile(const std::filesystem::path& path, const std::string& content) {
std::ofstream(path) << content;
}
void writeExecutable(const std::filesystem::path& path, const std::string& content) {
writeFile(path, content);
std::filesystem::permissions(path,
std::filesystem::perms::owner_read |
std::filesystem::perms::owner_write |
std::filesystem::perms::owner_exec,
std::filesystem::perm_options::replace);
}
std::string readFile(const std::filesystem::path& path) {
std::ifstream in(path);
std::ostringstream out;
out << in.rdbuf();
return out.str();
}
std::string shellQuote(const std::filesystem::path& path) {
std::string out = "'";
for (char c : path.string()) {
if (c == '\'')
out += "'\\''";
else
out += c;
}
out += "'";
return out;
}
std::filesystem::path locateFoldBinary() {
if (const char* env = std::getenv("FOLD_BINARY")) {
if (*env && std::filesystem::exists(env))
return std::filesystem::canonical(env);
}
auto cwd = std::filesystem::current_path();
for (const auto& rel : {"../../src/cli/fold", "../../../src/cli/fold", "../src/cli/fold"}) {
auto candidate = cwd / rel;
if (std::filesystem::exists(candidate))
return std::filesystem::canonical(candidate);
}
return {};
}
std::filesystem::path locateFoldRustBinary() {
if (const char* env = std::getenv("FOLD_RUST_BIN")) {
if (*env && std::filesystem::exists(env))
return std::filesystem::canonical(env);
}
auto cwd = std::filesystem::current_path();
for (const auto& rel : {"../../tools/fold-rust/target/release/fold-rust",
"../../../tools/fold-rust/target/release/fold-rust",
"../tools/fold-rust/target/release/fold-rust"}) {
auto candidate = cwd / rel;
if (std::filesystem::exists(candidate))
return std::filesystem::canonical(candidate);
}
return {};
}
std::optional<RustScanResult>
runRustScanTarget(const std::filesystem::path& target, const std::string& detectors = {},
const std::map<std::string, std::string>& env = {}) {
const auto fold_bin = locateFoldBinary();
const auto fold_rust_bin = env.contains("FOLD_RUST_BIN")
? std::filesystem::path(env.at("FOLD_RUST_BIN"))
: locateFoldRustBinary();
if (fold_bin.empty() || fold_rust_bin.empty()) {
WARN("fold or fold-rust binary missing; skipping Rust frontend integration test");
return std::nullopt;
}
TempDir dir;
const auto output = dir.path / "report.json";
const auto log = dir.path / "scan.log";
std::string cmd;
cmd += "FOLD_RUST_BIN=" + shellQuote(fold_rust_bin) + " ";
for (const auto& [key, value] : env)
if (key != "FOLD_RUST_BIN")
cmd += key + "=" + shellQuote(value) + " ";
cmd += shellQuote(fold_bin) + " scan --mode=rust ";
if (!detectors.empty())
cmd += "--detectors " + detectors + " ";
cmd += shellQuote(target) + " --format sarif --output " + shellQuote(output) + " > " +
shellQuote(log) + " 2>&1";
RustScanResult result;
result.exit_code = std::system(cmd.c_str());
result.log = readFile(log);
if (std::filesystem::exists(output))
result.report = nlohmann::json::parse(std::ifstream(output));
return result;
}
std::optional<RustScanResult> runRustScan(const std::string& source_text,
const std::string& detectors = {}) {
TempDir dir;
const auto source = dir.path / "unsafe.rs";
writeFile(source, source_text);
return runRustScanTarget(source, detectors);
}
std::filesystem::path fixturesDir() {
if (const char* env = std::getenv("FOLD_FIXTURES_DIR")) {
if (*env && std::filesystem::exists(env))
return std::filesystem::canonical(env);
}
auto cwd = std::filesystem::current_path();
for (const auto& rel : {"../fixtures", "../../tests/fixtures", "tests/fixtures"}) {
auto candidate = cwd / rel;
if (std::filesystem::exists(candidate))
return std::filesystem::canonical(candidate);
}
return {};
}
const nlohmann::json& scanResults(const RustScanResult& scan) {
return scan.report.at("runs").at(0).at("results");
}
std::string correlationKind(const nlohmann::json& result) {
return result.at("properties").value("correlationKind", std::string{});
}
} // namespace
TEST_CASE("fold-rust reports its rustc-private toolchain contract", "[rust][frontend]") {
const auto fold_rust_bin = locateFoldRustBinary();
if (fold_rust_bin.empty()) {
WARN("fold-rust binary missing; skipping toolchain contract test");
return;
}
TempDir dir;
const auto output = dir.path / "toolchain.json";
const std::string cmd = shellQuote(fold_rust_bin) + " toolchain-info > " + shellQuote(output);
REQUIRE(std::system(cmd.c_str()) == 0);
const auto info = nlohmann::json::parse(std::ifstream(output));
REQUIRE(info.at("schema_version") == 1);
REQUIRE(info.at("release").get<std::string>().find("nightly") != std::string::npos);
REQUIRE(info.at("requires").at("minimum_release") == "1.98.0");
REQUIRE(info.at("requires").at("components") ==
nlohmann::json::array({"rustc-dev", "rustfmt", "llvm-tools-preview"}));
}
TEST_CASE("rust frontend respects detector selection", "[rust][frontend]") {
auto scan = runRustScan("pub unsafe fn read_raw(p: *const i32) -> i32 { *p }\n", "div-by-zero");
if (!scan)
return;
REQUIRE(scan->exit_code == 0); // no selected detector emitted findings
REQUIRE(scanResults(*scan).empty());
}
TEST_CASE("rust frontend runs the Rust IR safety pass for div-by-zero selection",
"[rust][frontend]") {
auto scan = runRustScan(
"pub fn maybe_div(y: i32, flag: bool) -> i32 { let x = if flag { 0 } else { y }; 1 / x }\n",
"div-by-zero");
if (!scan)
return;
REQUIRE(scan->log.find("rust shard: IR encoder processed ") != std::string::npos);
}
TEST_CASE("fold-rust cargo-check emits each source unit once", "[rust][frontend][cargo-check]") {
const auto fold_rust_bin = locateFoldRustBinary();
const auto fixtures = fixturesDir();
if (fold_rust_bin.empty() || fixtures.empty()) {
WARN("fold-rust binary or fixtures missing; skipping cargo-check integration test");
return;
}
TempDir dir;
const auto output = dir.path / "cargo-check.ndjson";
const auto crate = fixtures / "rust" / "security_crate";
const std::string cmd = shellQuote(fold_rust_bin) + " cargo-check --crate-dir " +
shellQuote(crate) + " > " + shellQuote(output);
REQUIRE(std::system(cmd.c_str()) == 0);
std::map<std::string, unsigned> functions;
std::istringstream lines(readFile(output));
std::string line;
while (std::getline(lines, line)) {
if (line.empty())
continue;
const auto record = nlohmann::json::parse(line);
if (record.value("kind", std::string{}) == "function")
++functions[record.at("data").at("qualified_name").get<std::string>()];
}
REQUIRE_FALSE(functions.empty());
for (const auto& [name, count] : functions)
CHECK(count == 1);
}
TEST_CASE("rust frontend emits Rust-specific unsafe API rule IDs", "[rust][frontend]") {
auto scan = runRustScan("pub unsafe fn cast_bits(x: u32) -> i32 { "
"std::mem::transmute::<u32, i32>(x) }\n",
"rust-unsafe-api");
if (!scan)
return;
REQUIRE(scan->exit_code != 0); // exit 1: actionable finding
const auto& results = scanResults(*scan);
REQUIRE(results.size() == 1);
REQUIRE(results.at(0).at("ruleId") == "fold.rust-unsafe-api");
REQUIRE(results.at(0).at("properties").value("backend", std::string{}) == "rust");
REQUIRE(correlationKind(results.at(0)) == "rust-transmute");
}
TEST_CASE("rust frontend flags unsafe memory API footguns", "[rust][frontend]") {
auto scan = runRustScan(
"pub unsafe fn cast_bits(x: u32) -> i32 { std::mem::transmute::<u32, i32>(x) }\n"
"pub unsafe fn raw_slice<'a>(p: *const u8, len: usize) -> &'a [u8] { "
"std::slice::from_raw_parts(p, len) }\n"
"pub unsafe fn trust_len(v: &mut Vec<u8>, len: usize) { v.set_len(len); }\n",
"rust-unsafe-api");
if (!scan)
return;
REQUIRE(scan->exit_code != 0);
const auto& results = scanResults(*scan);
bool saw_transmute = false;
bool saw_from_raw_parts = false;
bool saw_set_len = false;
for (const auto& result : results) {
REQUIRE(result.at("ruleId") == "fold.rust-unsafe-api");
const auto hazard = correlationKind(result);
saw_transmute = saw_transmute || hazard == "rust-transmute";
saw_from_raw_parts = saw_from_raw_parts || hazard == "rust-slice-from_raw_parts";
saw_set_len = saw_set_len || hazard == "rust-vec-set-len";
}
REQUIRE(results.size() == 3);
REQUIRE(saw_transmute);
REQUIRE(saw_from_raw_parts);
REQUIRE(saw_set_len);
}
TEST_CASE("rust frontend flags unchecked indexing APIs", "[rust][frontend]") {
auto scan = runRustScan(
"pub unsafe fn unchecked_read(xs: &[u8], i: usize) -> u8 { *xs.get_unchecked(i) }\n"
"pub unsafe fn unchecked_write(xs: &mut [u8], i: usize) -> *mut u8 { "
"xs.get_unchecked_mut(i) as *mut u8 }\n",
"rust-unchecked-index");
if (!scan)
return;
REQUIRE(scan->exit_code != 0);
const auto& results = scanResults(*scan);
bool saw_read = false;
bool saw_write = false;
for (const auto& result : results) {
REQUIRE(result.at("ruleId") == "fold.rust-unchecked-index");
const auto hazard = correlationKind(result);
saw_read = saw_read || hazard == "rust-get-unchecked";
saw_write = saw_write || hazard == "rust-get-unchecked-mut";
}
REQUIRE(results.size() == 2);
REQUIRE(saw_read);
REQUIRE(saw_write);
}
TEST_CASE("rust frontend flags path traversal sinks", "[rust][frontend]") {
auto scan =
runRustScan("use std::path::Path;\n"
"pub fn join_user(base: &Path, user: &str) { let _ = base.join(user); }\n"
"pub fn read_user(user: &str) { let _ = std::fs::read(user); }\n",
"rust-path-traversal");
if (!scan)
return;
REQUIRE(scan->exit_code != 0);
const auto& results = scanResults(*scan);
bool saw_join = false;
bool saw_read = false;
for (const auto& result : results) {
REQUIRE(result.at("ruleId") == "fold.rust-path-traversal");
const auto hazard = correlationKind(result);
saw_join = saw_join || hazard == "rust-path-join";
saw_read = saw_read || hazard == "rust-fs-read";
}
REQUIRE(results.size() == 2);
REQUIRE(saw_join);
REQUIRE(saw_read);
}
TEST_CASE("rust frontend flags command execution injection sinks", "[rust][frontend]") {
auto scan =
runRustScan("pub fn run_user_arg(user: &str) { "
"let mut cmd = std::process::Command::new(\"echo\"); cmd.arg(user); }\n",
"rust-command-injection");
if (!scan)
return;
REQUIRE(scan->exit_code != 0);
const auto& results = scanResults(*scan);
REQUIRE(results.size() == 1);
REQUIRE(results.at(0).at("ruleId") == "fold.rust-command-injection");
REQUIRE(correlationKind(results.at(0)) == "rust-command-arg");
}
TEST_CASE("rust frontend flags crypto misuse patterns", "[rust][frontend]") {
auto scan =
runRustScan("mod md5 { pub fn compute(_: &[u8]) -> [u8; 16] { [0; 16] } }\n"
"pub fn digest_password(password: &[u8]) { let _ = md5::compute(password); }\n",
"rust-crypto-misuse");
if (!scan)
return;
REQUIRE(scan->exit_code != 0);
const auto& results = scanResults(*scan);
REQUIRE(results.size() == 1);
REQUIRE(results.at(0).at("ruleId") == "fold.rust-crypto-misuse");
REQUIRE(correlationKind(results.at(0)) == "rust-weak-hash");
}
TEST_CASE("rust frontend scans a Cargo fixture crate", "[rust][frontend][fixture]") {
const auto fixtures = fixturesDir();
if (fixtures.empty()) {
WARN("fixtures directory missing; skipping Rust fixture-crate integration test");
return;
}
auto scan = runRustScanTarget(fixtures / "rust" / "security_crate", "rust-security");
if (!scan)
return;
REQUIRE(scan->exit_code != 0);
const auto& results = scanResults(*scan);
std::set<std::string> rule_ids;
std::set<std::string> hazards;
for (const auto& result : results) {
rule_ids.insert(result.at("ruleId").get<std::string>());
hazards.insert(correlationKind(result));
REQUIRE(result.at("properties").value("backend", std::string{}) == "rust");
}
REQUIRE(rule_ids.contains("fold.rust-unsafe-api"));
REQUIRE(rule_ids.contains("fold.rust-unchecked-index"));
REQUIRE(rule_ids.contains("fold.rust-path-traversal"));
REQUIRE(rule_ids.contains("fold.rust-command-injection"));
REQUIRE(rule_ids.contains("fold.rust-crypto-misuse"));
REQUIRE(hazards.contains("rust-transmute"));
REQUIRE(hazards.contains("rust-slice-from_raw_parts"));
REQUIRE(hazards.contains("rust-vec-set-len"));
REQUIRE(hazards.contains("rust-get-unchecked"));
REQUIRE(hazards.contains("rust-path-join"));
REQUIRE(hazards.contains("rust-command-arg"));
REQUIRE(hazards.contains("rust-weak-hash"));
}
TEST_CASE("rust frontend classifies raw pointer read/write and definite null hazards",
"[rust][frontend]") {
auto scan = runRustScan("pub unsafe fn read_raw(p: *const i32) -> i32 { *p }\n"
"pub unsafe fn write_raw(p: *mut i32) { *p = 7; }\n"
"pub unsafe fn null_read() -> i32 { *std::ptr::null::<i32>() }\n"
"pub fn safe_ref(r: &i32) -> i32 { *r }\n");
if (!scan)
return;
REQUIRE(scan->exit_code != 0); // exit 1: actionable findings
const auto& results = scanResults(*scan);
bool saw_read = false;
bool saw_write = false;
bool saw_null_read = false;
bool saw_z3_null = false;
for (const auto& result : results) {
REQUIRE(result.at("ruleId") == "fold.null-deref");
const auto hazard = correlationKind(result);
saw_read = saw_read || hazard == "rust-raw-pointer-read";
saw_write = saw_write || hazard == "rust-raw-pointer-write";
saw_null_read = saw_null_read || hazard == "rust-raw-pointer-null-read";
// Z3-encoded findings have no correlationKind (hazard) from HIR.
saw_z3_null = saw_z3_null || (result.at("properties").value("confidence", "") == "proved");
}
REQUIRE(results.size() >= 3); // at least 3 HIR + possibly Z3
REQUIRE(saw_read);
REQUIRE(saw_write);
REQUIRE(saw_null_read);
// Z3 encoder produces Proved findings when Z3 can prove null reachable.
// Acceptable if Z3 times out or returns UNKNOWN on the parameter case.
(void)saw_z3_null; // presence is best-effort with default budget
}
TEST_CASE("rust frontend fails closed on nonzero fake frontend exit", "[rust][frontend]") {
const auto fold_bin = locateFoldBinary();
if (fold_bin.empty()) {
WARN("fold binary missing; skipping Rust frontend integration test");
return;
}
TempDir dir;
const auto source = dir.path / "input.rs";
writeFile(source, "pub fn demo() {}\n");
const auto fake = dir.path / "fake-fold-rust.sh";
writeExecutable(fake, "#!/bin/sh\n"
"printf '%s\\n' '{\"kind\":\"error\",\"data\":{\"message\":\"synthetic "
"rust frontend failure\"}}'\n"
"printf '%s\\n' "
"'{\"kind\":\"function\",\"data\":{\"qualified_name\":\"demo\",\"source_"
"file\":\"input.rs\",\"start_line\":1,\"end_line\":1}}'\n"
"exit 17\n");
auto scan = runRustScanTarget(source, {}, {{"FOLD_RUST_BIN", fake.string()}});
if (!scan)
return;
REQUIRE(scan->exit_code != 0);
REQUIRE(scan->log.find("scan failed:") != std::string::npos);
REQUIRE(scan->log.find("synthetic rust frontend failure") != std::string::npos);
REQUIRE(scan->log.find("exit status 17") != std::string::npos);
}
TEST_CASE("rust frontend fails closed on timed out fake frontend", "[rust][frontend]") {
const auto fold_bin = locateFoldBinary();
if (fold_bin.empty()) {
WARN("fold binary missing; skipping Rust frontend integration test");
return;
}
TempDir dir;
const auto source = dir.path / "input.rs";
writeFile(source, "pub fn demo() {}\n");
const auto fake = dir.path / "fake-fold-rust-timeout.sh";
writeExecutable(
fake,
"#!/bin/sh\n"
"printf '%s\\n' '{\"kind\":\"error\",\"data\":{\"message\":\"synthetic rust timeout\"}}'\n"
"sleep 2\n");
// Leave enough startup headroom for loaded parallel test runners while
// keeping the subprocess well beyond the timeout boundary.
auto scan = runRustScanTarget(
source, {},
{{"FOLD_RUST_BIN", fake.string()}, {"FOLD_FRONTEND_SUBPROCESS_TIMEOUT_MS", "500"}});
if (!scan)
return;
REQUIRE(scan->exit_code != 0);
REQUIRE(scan->log.find("scan failed:") != std::string::npos);
REQUIRE(scan->log.find("timed out") != std::string::npos);
REQUIRE(scan->log.find("synthetic rust timeout") != std::string::npos);
}