#[cfg(test)]
use googletest::prelude::*;
use ra_ap_syntax::{
AstNode,
ast::{self, HasArgList, LiteralKind},
};
use super::support::path_segments;
use crate::{AstCtx, Example, Violation};
#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
Example {
label: "external tool invocation",
code: "fn main() { let _ = std::process::Command::new(\"cmake\").status(); }",
pass: false,
},
Example {
label: "compiler driver is allowed",
code: "fn main() { let _ = std::process::Command::new(\"cc\").status(); }",
pass: true,
},
Example {
label: "required env var via unwrap",
code: "fn main() { let _ = std::env::var(\"FOO_LIB_DIR\").unwrap(); }",
pass: false,
},
Example {
label: "required env var via expect",
code: "fn main() { let _ = std::env::var(\"FOO_LIB_DIR\").expect(\"FOO_LIB_DIR must be set\"); }",
pass: false,
},
Example {
label: "required env var via question mark",
code: "fn main() -> Result<(), std::env::VarError> {\n let _ = std::env::var(\"FOO_LIB_DIR\")?;\n Ok(())\n}",
pass: false,
},
Example {
label: "defaulted env var is fine",
code: "fn main() { let _ = std::env::var(\"PROFILE\").unwrap_or_default(); }",
pass: true,
},
Example {
label: "optional env var is fine",
code: "fn main() { if let Ok(dir) = std::env::var(\"FOO_DIR\") { let _ = dir; } }",
pass: true,
},
Example {
label: "build-time bindgen",
code: "fn main() { let _ = bindgen::Builder::default(); }",
pass: false,
},
Example {
label: "build-time pkg_config probe",
code: "fn main() { let _ = pkg_config::probe_library(\"foo\"); }",
pass: false,
},
Example {
label: "plain rerun directive",
code: "fn main() { println!(\"cargo:rerun-if-changed=src/schema.json\"); }",
pass: true,
},
];
crate::ast_rule!(
build_rs_external_tool,
"Flag build.rs usage of external tools, hard-required env vars, and build-time binding generation.",
"Published crates should build with Cargo and rustc alone. Check generated artifacts in, use a Rust build dependency, or make optional integration explicit; do not make every downstream build depend on an undeclared executable or environment variable.",
Medium,
);
const ALLOWED_TOOLS: &[&str] = &["rustc", "cargo", "cc", "c++", "clang", "gcc"];
const SYS_BUILD_TOOLS: &[&str] = &["make", "cmake", "sh", "bash", "ninja"];
const BINDING_GENERATORS: &[&str] = &["bindgen", "pkg_config"];
const MODULE_CALL_SEGMENTS: usize = 2;
fn check_build_rs_external_tool(ctx: &AstCtx<'_>) -> Vec<Violation> {
if !ctx.file.rel.ends_with("build.rs") {
return Vec::new();
}
let is_sys = ctx
.file
.rel
.rsplit('/')
.nth(1)
.is_some_and(|dir| dir.ends_with("-sys") || dir.ends_with("_sys"));
let mut violations = Vec::new();
for call in ctx
.nodes::<ast::CallExpr>()
.filter(|call| !ctx.is_in_test(call))
{
check_command_new(ctx, &call, is_sys, &mut violations);
check_binding_generator_path(ctx, &call, &mut violations);
}
violations.extend({
let unsafe_calls = ctx
.nodes::<ast::MethodCallExpr>()
.filter(|call| !ctx.is_in_test(call))
.filter(|call| {
call.name_ref()
.is_some_and(|name| matches!(name.text().as_str(), "unwrap" | "expect"))
&& call.receiver().is_some_and(|expr| is_env_var_call(&expr))
});
unsafe_calls.map(|call| env_var_violation(ctx, &call))
});
violations.extend({
let unsafe_tries = ctx
.nodes::<ast::TryExpr>()
.filter(|expr| !ctx.is_in_test(expr))
.filter(|expr| expr.expr().is_some_and(|expr| is_env_var_call(&expr)));
unsafe_tries.map(|expr| env_var_violation(ctx, &expr))
});
violations
}
fn check_command_new(
ctx: &AstCtx<'_>,
expr: &ast::CallExpr,
is_sys: bool,
violations: &mut Vec<Violation>,
) {
let Some(segments) = call_path_segments(expr) else {
return;
};
let [.., ty, method] = segments.as_slice() else {
return;
};
if ty != "Command" || method != "new" {
return;
}
let Some(tool) = first_arg_str_literal(expr) else {
return;
};
if ALLOWED_TOOLS.contains(&tool.as_str()) {
return;
}
let message = if is_sys && SYS_BUILD_TOOLS.contains(&tool.as_str()) {
format!(
"external build system `{tool}` in a -sys crate build.rs — govern the native \
build with the `cc` crate instead"
)
} else {
format!("external tool `{tool}` invoked from build.rs breaks out-of-the-box builds")
};
violations.push(ctx.violation(expr, message));
}
fn check_binding_generator_path(
ctx: &AstCtx<'_>,
expr: &ast::CallExpr,
violations: &mut Vec<Violation>,
) {
let Some(root) = call_path_segments(expr).and_then(|segments| segments.into_iter().next())
else {
return;
};
if BINDING_GENERATORS.contains(&root.as_str()) {
violations.push(ctx.violation(
expr,
format!(
"`{root}` runs at build time — pre-generate the bindings and ship them \
in the crate"
),
));
}
}
fn env_var_violation<N>(ctx: &AstCtx<'_>, node: &N) -> Violation
where
N: AstNode,
{
ctx.violation(
node,
"build.rs fails when this env var is absent — default it with `unwrap_or`/`ok()` \
or make the step optional",
)
}
fn call_path_segments(expr: &ast::CallExpr) -> Option<Vec<String>> {
let ast::Expr::PathExpr(path_expr) = expr.expr()? else {
return None;
};
path_expr.path().map(|path| path_segments(&path))
}
fn first_arg_str_literal(expr: &ast::CallExpr) -> Option<String> {
let ast::Expr::Literal(literal) = expr.arg_list()?.args().next()? else {
return None;
};
let LiteralKind::String(text) = literal.kind() else {
return None;
};
text.value().ok().map(std::borrow::Cow::into_owned)
}
fn is_env_var_call(expr: &ast::Expr) -> bool {
let ast::Expr::CallExpr(call) = expr else {
return false;
};
let Some(segments) = call_path_segments(call) else {
return false;
};
let [.., module, name] = segments.as_slice() else {
return false;
};
let rooted = segments.len() == MODULE_CALL_SEGMENTS
|| segments.first().is_some_and(|root| root == "std");
rooted && module == "env" && name == "var"
}
#[cfg(test)]
mod tests {
use super::*;
const BUILD_RS_REL: &str = "crates/mylib/build.rs";
fn run_at(rel: &str, source: &str) -> Vec<Violation> {
crate::test_support::check_source_ast_at(rel, source, check_build_rs_external_tool)
}
#[gtest]
fn examples() -> Result<()> {
for ex in EXAMPLES {
let violations = run_at(BUILD_RS_REL, ex.code);
verify_eq!(violations.is_empty(), ex.pass)?;
}
Ok(())
}
#[gtest]
fn non_build_rs_files_are_exempt() -> Result<()> {
for ex in EXAMPLES {
let violations = run_at("crates/mylib/src/lib.rs", ex.code);
verify_true!(violations.is_empty())?;
}
Ok(())
}
#[gtest]
fn sys_crate_build_system_gets_sys_message() -> Result<()> {
let violations = run_at(
"crates/foo-sys/build.rs",
"fn main() { let _ = std::process::Command::new(\"make\").status(); }",
);
verify_eq!(violations.len(), 1)?;
verify_true!(violations[0].message.contains("external build system"))?;
Ok(())
}
}