wowlab_tidy/languages/rust/rules/safety/
build_rs_external_tool.rs1#[cfg(test)]
2use googletest::prelude::*;
3use ra_ap_syntax::{
4 AstNode,
5 ast::{self, HasArgList, LiteralKind},
6};
7
8use super::support::path_segments;
9use crate::{AstCtx, Example, Violation};
10
11#[rustfmt::skip]
12const EXAMPLES: &[Example] = &[
13 Example {
14 label: "external tool invocation",
15 code: "fn main() { let _ = std::process::Command::new(\"cmake\").status(); }",
16 pass: false,
17 },
18 Example {
19 label: "compiler driver is allowed",
20 code: "fn main() { let _ = std::process::Command::new(\"cc\").status(); }",
21 pass: true,
22 },
23 Example {
24 label: "required env var via unwrap",
25 code: "fn main() { let _ = std::env::var(\"FOO_LIB_DIR\").unwrap(); }",
26 pass: false,
27 },
28 Example {
29 label: "required env var via expect",
30 code: "fn main() { let _ = std::env::var(\"FOO_LIB_DIR\").expect(\"FOO_LIB_DIR must be set\"); }",
31 pass: false,
32 },
33 Example {
34 label: "required env var via question mark",
35 code: "fn main() -> Result<(), std::env::VarError> {\n let _ = std::env::var(\"FOO_LIB_DIR\")?;\n Ok(())\n}",
36 pass: false,
37 },
38 Example {
39 label: "defaulted env var is fine",
40 code: "fn main() { let _ = std::env::var(\"PROFILE\").unwrap_or_default(); }",
41 pass: true,
42 },
43 Example {
44 label: "optional env var is fine",
45 code: "fn main() { if let Ok(dir) = std::env::var(\"FOO_DIR\") { let _ = dir; } }",
46 pass: true,
47 },
48 Example {
49 label: "build-time bindgen",
50 code: "fn main() { let _ = bindgen::Builder::default(); }",
51 pass: false,
52 },
53 Example {
54 label: "build-time pkg_config probe",
55 code: "fn main() { let _ = pkg_config::probe_library(\"foo\"); }",
56 pass: false,
57 },
58 Example {
59 label: "plain rerun directive",
60 code: "fn main() { println!(\"cargo:rerun-if-changed=src/schema.json\"); }",
61 pass: true,
62 },
63];
64
65crate::ast_rule!(
66 build_rs_external_tool,
67 "Flag build.rs usage of external tools, hard-required env vars, and build-time binding generation.",
68 "Builds must work out of the box with cargo and rustc alone — external tools and required env vars break every downstream consumer (M-OOBE, M-SYS-CRATES).",
69 Medium,
70);
71
72const ALLOWED_TOOLS: &[&str] = &["rustc", "cargo", "cc", "c++", "clang", "gcc"];
73const SYS_BUILD_TOOLS: &[&str] = &["make", "cmake", "sh", "bash", "ninja"];
74const BINDING_GENERATORS: &[&str] = &["bindgen", "pkg_config"];
75const MODULE_CALL_SEGMENTS: usize = 2;
77
78fn check_build_rs_external_tool(ctx: &AstCtx<'_>) -> Vec<Violation> {
79 if !ctx.file.rel.ends_with("build.rs") {
80 return Vec::new();
81 }
82
83 let is_sys = ctx
84 .file
85 .rel
86 .rsplit('/')
87 .nth(1)
88 .is_some_and(|dir| dir.ends_with("-sys") || dir.ends_with("_sys"));
89 let mut violations = Vec::new();
90
91 for call in ctx
92 .nodes::<ast::CallExpr>()
93 .filter(|call| !ctx.is_in_test(call))
94 {
95 check_command_new(ctx, &call, is_sys, &mut violations);
96 check_binding_generator_path(ctx, &call, &mut violations);
97 }
98
99 violations.extend({
100 let unsafe_calls = ctx
101 .nodes::<ast::MethodCallExpr>()
102 .filter(|call| !ctx.is_in_test(call))
103 .filter(|call| {
104 call.name_ref()
105 .is_some_and(|name| matches!(name.text().as_str(), "unwrap" | "expect"))
106 && call.receiver().is_some_and(|expr| is_env_var_call(&expr))
107 });
108
109 unsafe_calls.map(|call| env_var_violation(ctx, &call))
110 });
111 violations.extend({
112 let unsafe_tries = ctx
113 .nodes::<ast::TryExpr>()
114 .filter(|expr| !ctx.is_in_test(expr))
115 .filter(|expr| expr.expr().is_some_and(|expr| is_env_var_call(&expr)));
116
117 unsafe_tries.map(|expr| env_var_violation(ctx, &expr))
118 });
119
120 violations
121}
122
123fn check_command_new(
124 ctx: &AstCtx<'_>,
125 expr: &ast::CallExpr,
126 is_sys: bool,
127 violations: &mut Vec<Violation>,
128) {
129 let Some(segments) = call_path_segments(expr) else {
130 return;
131 };
132 let [.., ty, method] = segments.as_slice() else {
133 return;
134 };
135
136 if ty != "Command" || method != "new" {
137 return;
138 }
139
140 let Some(tool) = first_arg_str_literal(expr) else {
141 return;
142 };
143
144 if ALLOWED_TOOLS.contains(&tool.as_str()) {
145 return;
146 }
147
148 let message = if is_sys && SYS_BUILD_TOOLS.contains(&tool.as_str()) {
149 format!(
150 "external build system `{tool}` in a -sys crate build.rs — govern the native \
151 build with the `cc` crate instead (M-SYS-CRATES)"
152 )
153 } else {
154 format!(
155 "external tool `{tool}` invoked from build.rs breaks out-of-the-box builds (M-OOBE)"
156 )
157 };
158
159 violations.push(ctx.violation(expr, message));
160}
161
162fn check_binding_generator_path(
163 ctx: &AstCtx<'_>,
164 expr: &ast::CallExpr,
165 violations: &mut Vec<Violation>,
166) {
167 let Some(root) = call_path_segments(expr).and_then(|segments| segments.into_iter().next())
168 else {
169 return;
170 };
171
172 if BINDING_GENERATORS.contains(&root.as_str()) {
173 violations.push(ctx.violation(
174 expr,
175 format!(
176 "`{root}` runs at build time — pre-generate the bindings and ship them \
177 in the crate (M-SYS-CRATES)"
178 ),
179 ));
180 }
181}
182
183fn env_var_violation<N>(ctx: &AstCtx<'_>, node: &N) -> Violation
184where
185 N: AstNode,
186{
187 ctx.violation(
188 node,
189 "build.rs fails when this env var is absent — default it with `unwrap_or`/`ok()` \
190 or make the step optional (M-OOBE)",
191 )
192}
193
194fn call_path_segments(expr: &ast::CallExpr) -> Option<Vec<String>> {
195 let ast::Expr::PathExpr(path_expr) = expr.expr()? else {
196 return None;
197 };
198
199 path_expr.path().map(|path| path_segments(&path))
200}
201
202fn first_arg_str_literal(expr: &ast::CallExpr) -> Option<String> {
203 let ast::Expr::Literal(literal) = expr.arg_list()?.args().next()? else {
204 return None;
205 };
206 let LiteralKind::String(text) = literal.kind() else {
207 return None;
208 };
209
210 text.value().ok().map(std::borrow::Cow::into_owned)
211}
212
213fn is_env_var_call(expr: &ast::Expr) -> bool {
214 let ast::Expr::CallExpr(call) = expr else {
215 return false;
216 };
217 let Some(segments) = call_path_segments(call) else {
218 return false;
219 };
220 let [.., module, name] = segments.as_slice() else {
221 return false;
222 };
223 let rooted = segments.len() == MODULE_CALL_SEGMENTS
224 || segments.first().is_some_and(|root| root == "std");
225
226 rooted && module == "env" && name == "var"
227}
228
229#[cfg(test)]
232mod tests {
233 use super::*;
234
235 const BUILD_RS_REL: &str = "crates/mylib/build.rs";
236
237 fn run_at(rel: &str, source: &str) -> Vec<Violation> {
238 crate::test_support::check_source_ast_at(rel, source, check_build_rs_external_tool)
239 }
240
241 #[gtest]
242 fn examples() -> Result<()> {
243 for ex in EXAMPLES {
244 let violations = run_at(BUILD_RS_REL, ex.code);
245
246 verify_eq!(violations.is_empty(), ex.pass)?;
247 }
248
249 Ok(())
250 }
251
252 #[gtest]
253 fn non_build_rs_files_are_exempt() -> Result<()> {
254 for ex in EXAMPLES {
255 let violations = run_at("crates/mylib/src/lib.rs", ex.code);
256
257 verify_true!(violations.is_empty())?;
258 }
259
260 Ok(())
261 }
262
263 #[gtest]
264 fn sys_crate_build_system_gets_sys_message() -> Result<()> {
265 let violations = run_at(
266 "crates/foo-sys/build.rs",
267 "fn main() { let _ = std::process::Command::new(\"make\").status(); }",
268 );
269
270 verify_eq!(violations.len(), 1)?;
271 verify_true!(violations[0].message.contains("M-SYS-CRATES"))?;
272
273 Ok(())
274 }
275}