Skip to content

Commit 7d36300

Browse files
committed
new lint: expr_metavar_in_unsafe
1 parent 0e5dc8e commit 7d36300

File tree

6 files changed

+633
-0
lines changed

6 files changed

+633
-0
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -5133,6 +5133,7 @@ Released 2018-09-13
51335133
[`explicit_into_iter_loop`]: https://rust-lang.github.io/rust-clippy/master/index.html#explicit_into_iter_loop
51345134
[`explicit_iter_loop`]: https://rust-lang.github.io/rust-clippy/master/index.html#explicit_iter_loop
51355135
[`explicit_write`]: https://rust-lang.github.io/rust-clippy/master/index.html#explicit_write
5136+
[`expr_metavars_in_unsafe`]: https://rust-lang.github.io/rust-clippy/master/index.html#expr_metavars_in_unsafe
51365137
[`extend_from_slice`]: https://rust-lang.github.io/rust-clippy/master/index.html#extend_from_slice
51375138
[`extend_with_drain`]: https://rust-lang.github.io/rust-clippy/master/index.html#extend_with_drain
51385139
[`extra_unused_lifetimes`]: https://rust-lang.github.io/rust-clippy/master/index.html#extra_unused_lifetimes

clippy_lints/src/declared_lints.rs

+1
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ pub(crate) static LINTS: &[&crate::LintInfo] = &[
169169
crate::exhaustive_items::EXHAUSTIVE_STRUCTS_INFO,
170170
crate::exit::EXIT_INFO,
171171
crate::explicit_write::EXPLICIT_WRITE_INFO,
172+
crate::expr_metavars_in_unsafe::EXPR_METAVARS_IN_UNSAFE_INFO,
172173
crate::extra_unused_type_parameters::EXTRA_UNUSED_TYPE_PARAMETERS_INFO,
173174
crate::fallible_impl_from::FALLIBLE_IMPL_FROM_INFO,
174175
crate::float_literal::EXCESSIVE_PRECISION_INFO,
+241
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
use std::collections::btree_map::Entry;
2+
use std::collections::BTreeMap;
3+
4+
use clippy_utils::diagnostics::span_lint_hir_and_then;
5+
use clippy_utils::is_lint_allowed;
6+
use itertools::Itertools;
7+
use rustc_hir::intravisit::{walk_block, walk_expr, walk_stmt, Visitor};
8+
use rustc_hir::{BlockCheckMode, Expr, ExprKind, HirId, Stmt, UnsafeSource};
9+
use rustc_lint::{LateContext, LateLintPass};
10+
use rustc_session::impl_lint_pass;
11+
use rustc_span::def_id::DefId;
12+
use rustc_span::{sym, Span, SyntaxContext};
13+
14+
declare_clippy_lint! {
15+
/// ### What it does
16+
/// Looks for macros that expand metavariables in an unsafe block.
17+
///
18+
/// ### Why is this bad?
19+
/// This is unsound: it allows the user of the macro to write unsafe code outside of an
20+
/// unsafe block at callsite, potentially invoking undefined behavior in safe code.
21+
///
22+
/// ### Known limitations
23+
/// Due to how macros are represented in the compiler at the time Clippy runs its lints,
24+
/// it's not possible to look for metavariables in macro definitions directly.
25+
///
26+
/// Instead, this lint looks at expansions of macros defined in the same crate.
27+
/// This leads to false negatives when a macro is never actually invoked.
28+
///
29+
/// ### Example
30+
/// ```no_run
31+
/// /// Gets the first element of a slice
32+
/// macro_rules! first {
33+
/// ($slice:expr) => {
34+
/// unsafe {
35+
/// let slice = $slice; // ⚠️ expansion inside of `unsafe {}`
36+
///
37+
/// assert!(!slice.is_empty());
38+
/// // SAFETY: slice is checked to have at least one element
39+
/// slice.first().unwrap_unchecked()
40+
/// }
41+
/// }
42+
/// }
43+
///
44+
/// assert_eq!(*first!(&[1i32]), 1);
45+
///
46+
/// // This will compile as a consequence (note the lack of `unsafe {}`)
47+
/// assert_eq!(*first!(std::hint::unreachable_unchecked() as &[i32]), 1);
48+
/// ```
49+
/// Use instead:
50+
/// ```compile_fail
51+
/// macro_rules! first {
52+
/// ($slice:expr) => {{
53+
/// let slice = $slice; // ✅ outside of `unsafe {}`
54+
/// unsafe {
55+
/// assert!(!slice.is_empty());
56+
/// // SAFETY: slice is checked to have at least one element
57+
/// slice.first().unwrap_unchecked()
58+
/// }
59+
/// }}
60+
/// }
61+
///
62+
/// assert_eq!(*first!(&[1]), 1);
63+
///
64+
/// // This won't compile:
65+
/// assert_eq!(*first!(std::hint::unreachable_unchecked() as &[i32]), 1);
66+
/// ```
67+
#[clippy::version = "1.77.0"]
68+
pub EXPR_METAVARS_IN_UNSAFE,
69+
nursery,
70+
"expanding expr metavariables in an unsafe block"
71+
}
72+
73+
#[derive(Clone, Debug)]
74+
enum MetavarState {
75+
ReferencedInUnsafe { unsafe_blocks: Vec<HirId> },
76+
ReferencedInSafe,
77+
}
78+
79+
#[derive(Default)]
80+
pub struct ExprMetavarsInUnsafe {
81+
/// A metavariable can be expanded more than once, potentially across multiple bodies, so it
82+
/// requires some state kept across HIR nodes to make it possible to delay a warning
83+
/// and later undo:
84+
///
85+
/// ```ignore
86+
/// macro_rules! x {
87+
/// ($v:expr) => {
88+
/// unsafe { $v; } // unsafe context, it might be possible to emit a warning here, so add it to the map
89+
///
90+
/// $v; // `$v` expanded another time but in a safe context, set to ReferencedInSafe to suppress
91+
/// }
92+
/// }
93+
/// ```
94+
metavar_expns: BTreeMap<Span, MetavarState>,
95+
}
96+
impl_lint_pass!(ExprMetavarsInUnsafe => [EXPR_METAVARS_IN_UNSAFE]);
97+
98+
struct BodyVisitor<'a> {
99+
/// The top item always represents the last seen unsafe block
100+
macro_unsafe_blocks: Vec<HirId>,
101+
/// When this is >0, it means that the node in the visitor currently being visited is "within" a
102+
/// macro definition. This helps reduce the number of spans we need to insert into the map,
103+
/// since only spans from macros are relevant.
104+
expn_depth: u32,
105+
metavar_map: &'a mut BTreeMap<Span, MetavarState>,
106+
}
107+
108+
impl<'a, 'tcx> Visitor<'tcx> for BodyVisitor<'a> {
109+
fn visit_stmt(&mut self, s: &'tcx Stmt<'tcx>) {
110+
let from_expn = s.span.from_expansion();
111+
if from_expn {
112+
self.expn_depth += 1;
113+
}
114+
walk_stmt(self, s);
115+
if from_expn {
116+
self.expn_depth -= 1;
117+
}
118+
}
119+
120+
fn visit_expr(&mut self, e: &'tcx Expr<'tcx>) {
121+
let ctxt = e.span.ctxt();
122+
123+
if let ExprKind::Block(block, _) = e.kind
124+
&& let BlockCheckMode::UnsafeBlock(UnsafeSource::UserProvided) = block.rules
125+
&& !ctxt.is_root()
126+
&& ctxt.outer_expn_data().macro_def_id.is_some_and(DefId::is_local)
127+
{
128+
self.macro_unsafe_blocks.push(block.hir_id);
129+
walk_block(self, block);
130+
self.macro_unsafe_blocks.pop();
131+
} else if ctxt.is_root() && self.expn_depth > 0 {
132+
let unsafe_block = self.macro_unsafe_blocks.last().copied();
133+
134+
match (self.metavar_map.entry(e.span), unsafe_block) {
135+
(Entry::Vacant(e), None) => {
136+
e.insert(MetavarState::ReferencedInSafe);
137+
},
138+
(Entry::Vacant(e), Some(unsafe_block)) => {
139+
e.insert(MetavarState::ReferencedInUnsafe {
140+
unsafe_blocks: vec![unsafe_block],
141+
});
142+
},
143+
(Entry::Occupied(mut e), None) => {
144+
if let MetavarState::ReferencedInUnsafe { .. } = *e.get() {
145+
e.insert(MetavarState::ReferencedInSafe);
146+
}
147+
},
148+
(Entry::Occupied(mut e), Some(unsafe_block)) => {
149+
if let MetavarState::ReferencedInUnsafe { unsafe_blocks } = e.get_mut()
150+
&& !unsafe_blocks.contains(&unsafe_block)
151+
{
152+
unsafe_blocks.push(unsafe_block);
153+
}
154+
},
155+
}
156+
157+
// NB: No need to visit descendant nodes. They're guaranteed to represent the same
158+
// metavariable
159+
} else {
160+
walk_expr(self, e);
161+
}
162+
}
163+
}
164+
165+
impl<'tcx> LateLintPass<'tcx> for ExprMetavarsInUnsafe {
166+
fn check_body(&mut self, cx: &LateContext<'tcx>, body: &'tcx rustc_hir::Body<'tcx>) {
167+
if is_lint_allowed(cx, EXPR_METAVARS_IN_UNSAFE, body.value.hir_id) {
168+
return;
169+
}
170+
171+
// This BodyVisitor is separate and not part of the lint pass because there is no
172+
// `check_stmt_post` on `(Late)LintPass`, which we'd need to detect when we're leaving a macro span
173+
174+
let mut vis = BodyVisitor {
175+
#[expect(clippy::bool_to_int_with_if)] // obfuscates the meaning
176+
expn_depth: if body.value.span.from_expansion() { 1 } else { 0 },
177+
macro_unsafe_blocks: Vec::new(),
178+
metavar_map: &mut self.metavar_expns,
179+
};
180+
vis.visit_body(body);
181+
}
182+
183+
fn check_crate_post(&mut self, cx: &LateContext<'tcx>) {
184+
// Aggregate all unsafe blocks from all spans:
185+
// ```
186+
// macro_rules! x {
187+
// ($w:expr, $x:expr, $y:expr) => { $w; unsafe { $w; $x; }; unsafe { $x; $y; }; }
188+
// }
189+
// $w: [] (unsafe#0 is never added because it was referenced in a safe context)
190+
// $x: [unsafe#0, unsafe#1]
191+
// $y: [unsafe#1]
192+
// ```
193+
// We want to lint unsafe blocks #0 and #1
194+
let bad_unsafe_blocks = self
195+
.metavar_expns
196+
.iter()
197+
.filter_map(|(_, state)| match state {
198+
MetavarState::ReferencedInUnsafe { unsafe_blocks } => Some(unsafe_blocks.as_slice()),
199+
MetavarState::ReferencedInSafe => None,
200+
})
201+
.flatten()
202+
.copied()
203+
.map(|id| {
204+
// Remove the syntax context to hide "in this macro invocation" in the diagnostic.
205+
// The invocation doesn't matter. Also we want to dedupe by the unsafe block and not by anything
206+
// related to the callsite.
207+
let span = cx.tcx.hir().span(id);
208+
let macro_def_id = span.ctxt().outer_expn_data().macro_def_id.and_then(DefId::as_local);
209+
(
210+
id,
211+
Span::new(span.lo(), span.hi(), SyntaxContext::root(), None),
212+
macro_def_id,
213+
)
214+
})
215+
.dedup_by(|(_, a, _), (_, b, _)| a == b);
216+
217+
for (id, span, def_id) in bad_unsafe_blocks {
218+
if let Some(def_id) = def_id
219+
&& (cx.effective_visibilities.is_exported(def_id) || cx.tcx.has_attr(def_id, sym::macro_export))
220+
&& !cx.tcx.is_doc_hidden(def_id)
221+
{
222+
span_lint_hir_and_then(
223+
cx,
224+
EXPR_METAVARS_IN_UNSAFE,
225+
id,
226+
span,
227+
"this unsafe block in a macro expands `expr` metavariables",
228+
|diag| {
229+
diag.note("this allows the user of the macro to write unsafe code outside of an unsafe block");
230+
diag.help(
231+
"consider expanding any metavariables outside of this block, e.g. by storing them in a variable",
232+
);
233+
diag.help(
234+
"... or also expand referenced metavariables in a safe context to require an unsafe block at callsite",
235+
);
236+
},
237+
);
238+
}
239+
}
240+
}
241+
}

clippy_lints/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ mod excessive_nesting;
128128
mod exhaustive_items;
129129
mod exit;
130130
mod explicit_write;
131+
mod expr_metavars_in_unsafe;
131132
mod extra_unused_type_parameters;
132133
mod fallible_impl_from;
133134
mod float_literal;
@@ -1092,6 +1093,7 @@ pub fn register_lints(store: &mut rustc_lint::LintStore, conf: &'static Conf) {
10921093
store.register_late_pass(move |_| {
10931094
Box::new(thread_local_initializer_can_be_made_const::ThreadLocalInitializerCanBeMadeConst::new(msrv()))
10941095
});
1096+
store.register_late_pass(|_| Box::<expr_metavars_in_unsafe::ExprMetavarsInUnsafe>::default());
10951097
// add lints here, do not remove this comment, it's used in `new_lint`
10961098
}
10971099

0 commit comments

Comments
 (0)