Skip to content

Commit a4a1a73

Browse files
committed
Auto merge of #12107 - y21:expr_metavars_in_unsafe, r=xFrednet
new lint: `macro_metavars_in_unsafe` This implements a lint that I've been meaning to write for a while: a macro with an `expr` metavariable that is then expanded in an unsafe context. It's bad because it lets the user write unsafe code without an unsafe block. Note: this has gone through some major rewrites, so any comment before #12107 (comment) is outdated and was based on an older version that has since been completely rewritten. changelog: new lint: [`macro_metavars_in_unsafe`]
2 parents 7cfb9a0 + 9747c80 commit a4a1a73

File tree

12 files changed

+764
-0
lines changed

12 files changed

+764
-0
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -5448,6 +5448,7 @@ Released 2018-09-13
54485448
[`little_endian_bytes`]: https://rust-lang.github.io/rust-clippy/master/index.html#little_endian_bytes
54495449
[`logic_bug`]: https://rust-lang.github.io/rust-clippy/master/index.html#logic_bug
54505450
[`lossy_float_literal`]: https://rust-lang.github.io/rust-clippy/master/index.html#lossy_float_literal
5451+
[`macro_metavars_in_unsafe`]: https://rust-lang.github.io/rust-clippy/master/index.html#macro_metavars_in_unsafe
54515452
[`macro_use_imports`]: https://rust-lang.github.io/rust-clippy/master/index.html#macro_use_imports
54525453
[`main_recursion`]: https://rust-lang.github.io/rust-clippy/master/index.html#main_recursion
54535454
[`manual_assert`]: https://rust-lang.github.io/rust-clippy/master/index.html#manual_assert
@@ -6005,4 +6006,5 @@ Released 2018-09-13
60056006
[`vec-box-size-threshold`]: https://doc.rust-lang.org/clippy/lint_configuration.html#vec-box-size-threshold
60066007
[`verbose-bit-mask-threshold`]: https://doc.rust-lang.org/clippy/lint_configuration.html#verbose-bit-mask-threshold
60076008
[`warn-on-all-wildcard-imports`]: https://doc.rust-lang.org/clippy/lint_configuration.html#warn-on-all-wildcard-imports
6009+
[`warn-unsafe-macro-metavars-in-private-macros`]: https://doc.rust-lang.org/clippy/lint_configuration.html#warn-unsafe-macro-metavars-in-private-macros
60086010
<!-- end autogenerated links to configuration documentation -->

book/src/lint_configuration.md

+10
Original file line numberDiff line numberDiff line change
@@ -922,3 +922,13 @@ Whether to allow certain wildcard imports (prelude, super in tests).
922922
* [`wildcard_imports`](https://rust-lang.github.io/rust-clippy/master/index.html#wildcard_imports)
923923

924924

925+
## `warn-unsafe-macro-metavars-in-private-macros`
926+
Whether to also emit warnings for unsafe blocks with metavariable expansions in **private** macros.
927+
928+
**Default Value:** `false`
929+
930+
---
931+
**Affected lints:**
932+
* [`macro_metavars_in_unsafe`](https://rust-lang.github.io/rust-clippy/master/index.html#macro_metavars_in_unsafe)
933+
934+

clippy_config/src/conf.rs

+4
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,10 @@ define_Conf! {
632632
/// default configuration of Clippy. By default, any configuration will replace the default value.
633633
(allow_renamed_params_for: Vec<String> =
634634
DEFAULT_ALLOWED_TRAITS_WITH_RENAMED_PARAMS.iter().map(ToString::to_string).collect()),
635+
/// Lint: MACRO_METAVARS_IN_UNSAFE.
636+
///
637+
/// Whether to also emit warnings for unsafe blocks with metavariable expansions in **private** macros.
638+
(warn_unsafe_macro_metavars_in_private_macros: bool = false),
635639
}
636640

637641
/// Search for the configuration file.

clippy_lints/src/declared_lints.rs

+1
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ pub(crate) static LINTS: &[&crate::LintInfo] = &[
296296
crate::loops::WHILE_IMMUTABLE_CONDITION_INFO,
297297
crate::loops::WHILE_LET_LOOP_INFO,
298298
crate::loops::WHILE_LET_ON_ITERATOR_INFO,
299+
crate::macro_metavars_in_unsafe::MACRO_METAVARS_IN_UNSAFE_INFO,
299300
crate::macro_use::MACRO_USE_IMPORTS_INFO,
300301
crate::main_recursion::MAIN_RECURSION_INFO,
301302
crate::manual_assert::MANUAL_ASSERT_INFO,

clippy_lints/src/lib.rs

+8
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ mod lifetimes;
193193
mod lines_filter_map_ok;
194194
mod literal_representation;
195195
mod loops;
196+
mod macro_metavars_in_unsafe;
196197
mod macro_use;
197198
mod main_recursion;
198199
mod manual_assert;
@@ -599,6 +600,7 @@ pub fn register_lints(store: &mut rustc_lint::LintStore, conf: &'static Conf) {
599600

600601
blacklisted_names: _,
601602
cyclomatic_complexity_threshold: _,
603+
warn_unsafe_macro_metavars_in_private_macros,
602604
} = *conf;
603605
let msrv = || msrv.clone();
604606

@@ -1155,6 +1157,12 @@ pub fn register_lints(store: &mut rustc_lint::LintStore, conf: &'static Conf) {
11551157
store.register_late_pass(|_| Box::new(zero_repeat_side_effects::ZeroRepeatSideEffects));
11561158
store.register_late_pass(|_| Box::new(manual_unwrap_or_default::ManualUnwrapOrDefault));
11571159
store.register_late_pass(|_| Box::new(integer_division_remainder_used::IntegerDivisionRemainderUsed));
1160+
store.register_late_pass(move |_| {
1161+
Box::new(macro_metavars_in_unsafe::ExprMetavarsInUnsafe {
1162+
warn_unsafe_macro_metavars_in_private_macros,
1163+
..Default::default()
1164+
})
1165+
});
11581166
// add lints here, do not remove this comment, it's used in `new_lint`
11591167
}
11601168

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

0 commit comments

Comments
 (0)