Skip to main content

wowlab_tidy/languages/rust/rules/safety/
build_rs_external_tool.rs

1#[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"];
75/// An unprefixed `env::var` call is exactly two path segments.
76const 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// The default test rel would defeat the build.rs gate, so examples run through the
230// rel-aware helper instead of `example_tests!`.
231#[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}