Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7449,6 +7449,7 @@ Released 2018-09-13
[`unnecessary_operation`]: https://rust-lang.github.io/rust-clippy/master/index.html#unnecessary_operation
[`unnecessary_option_map_or_else`]: https://rust-lang.github.io/rust-clippy/master/index.html#unnecessary_option_map_or_else
[`unnecessary_owned_empty_strings`]: https://rust-lang.github.io/rust-clippy/master/index.html#unnecessary_owned_empty_strings
[`unnecessary_path_exists`]: https://rust-lang.github.io/rust-clippy/master/index.html#unnecessary_path_exists
[`unnecessary_result_map_or_else`]: https://rust-lang.github.io/rust-clippy/master/index.html#unnecessary_result_map_or_else
[`unnecessary_safety_comment`]: https://rust-lang.github.io/rust-clippy/master/index.html#unnecessary_safety_comment
[`unnecessary_safety_doc`]: https://rust-lang.github.io/rust-clippy/master/index.html#unnecessary_safety_doc
Expand Down
1 change: 1 addition & 0 deletions clippy_lints/src/declared_lints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,7 @@ pub static LINTS: &[&::declare_clippy_lint::LintInfo] = &[
crate::unnecessary_map_on_constructor::UNNECESSARY_MAP_ON_CONSTRUCTOR_INFO,
crate::unnecessary_mut_passed::UNNECESSARY_MUT_PASSED_INFO,
crate::unnecessary_owned_empty_strings::UNNECESSARY_OWNED_EMPTY_STRINGS_INFO,
crate::unnecessary_path_exists::UNNECESSARY_PATH_EXISTS_INFO,
crate::unnecessary_self_imports::UNNECESSARY_SELF_IMPORTS_INFO,
crate::unnecessary_semicolon::UNNECESSARY_SEMICOLON_INFO,
crate::unnecessary_struct_initialization::UNNECESSARY_STRUCT_INITIALIZATION_INFO,
Expand Down
2 changes: 2 additions & 0 deletions clippy_lints/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ mod unnecessary_literal_bound;
mod unnecessary_map_on_constructor;
mod unnecessary_mut_passed;
mod unnecessary_owned_empty_strings;
mod unnecessary_path_exists;
mod unnecessary_self_imports;
mod unnecessary_semicolon;
mod unnecessary_struct_initialization;
Expand Down Expand Up @@ -862,6 +863,7 @@ rustc_lint::late_lint_methods!(
ManualAssertEq: manual_assert_eq::ManualAssertEq = manual_assert_eq::ManualAssertEq,
WithCapacityZero: with_capacity_zero::WithCapacityZero = with_capacity_zero::WithCapacityZero,
RefPatterns: ref_patterns::RefPatterns = ref_patterns::RefPatterns,
UnnecessaryPathExists: unnecessary_path_exists::UnnecessaryPathExists = unnecessary_path_exists::UnnecessaryPathExists,
// add late passes here, used by `cargo dev new_lint`
]]
);
239 changes: 239 additions & 0 deletions clippy_lints/src/unnecessary_path_exists.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
use clippy_utils::diagnostics::span_lint_and_then;
use clippy_utils::res::MaybeDef;
use clippy_utils::{SpanlessEq, higher, path_to_local_with_projections, sym};
use rustc_hir::{BinOpKind, Block, Expr, ExprKind, MatchSource, PatKind, Stmt, StmtKind};
use rustc_lint::{LateContext, LateLintPass};
use rustc_session::declare_lint_pass;
use rustc_span::symbol::Symbol;
use rustc_span::{Span, SyntaxContext};

declare_clippy_lint! {
/// ### What it does
/// Checks for calls to `Path::exists` immediately before a filesystem
/// operation on the same path.
///
/// ### Why is this bad?
/// Calling `exists()` and then performing a filesystem operation on the same
/// path is a classic Time-Of-Check to Time-Of-Use (TOCTOU) race condition.
/// Between the two calls another process can add, remove, or replace the
/// file, making the result of `exists()` stale. The filesystem operation
/// itself will indicate whether the path exists via its return value, making
/// the prior `exists()` check both redundant and dangerous.
///
/// ### Example
/// ```rust,no_run
/// # use std::path::Path;
/// # fn example(path: &Path) {
/// if path.exists() {
/// let metadata = path.metadata().unwrap();
/// // use metadata ...
/// }
/// # }
/// ```
/// Use instead:
/// ```rust,no_run
/// # use std::path::Path;
/// # fn example(path: &Path) {
/// if let Ok(metadata) = path.metadata() {
/// // use metadata ...
/// }
/// # }
/// ```
///
/// ### Known problems
/// - Does not detect `std::fs` free functions used inside the block
/// (e.g. `fs::read(path)`, `fs::File::open(path)`), only method calls on
/// the path receiver itself.
/// - Does not detect `Path::try_exists()` (stabilized in Rust 1.63): the `?`
/// operator in the condition desugars to a `Match` node, so the condition
/// is not seen as a simple `.exists()` call.
/// - For the stored-bool variant (`let b = path.exists(); /* other stmts */;
/// if b { ... }`), only detects when the `if` immediately follows the `let`.
#[clippy::version = "1.98.0"]
pub UNNECESSARY_PATH_EXISTS,
nursery,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to choose a category, nursery is where lints go to die nowadays.

"calling `Path::exists` before a filesystem operation creates a TOCTOU race"
}

declare_lint_pass!(UnnecessaryPathExists => [UNNECESSARY_PATH_EXISTS]);

impl<'tcx> LateLintPass<'tcx> for UnnecessaryPathExists {
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
if expr.span.from_expansion() {
return;
}

if let Some(higher::If { cond, then, .. }) = higher::If::hir(expr)
&& let Some((path_recv, exists_span)) = extract_exists_receiver(cx, cond)
&& let Some(fs_call_span) = find_fs_call(cx, then, path_recv, expr.span.ctxt())
{
emit_lint(cx, exists_span, fs_call_span);
}
}

fn check_block(&mut self, cx: &LateContext<'tcx>, block: &'tcx Block<'tcx>) {
if block.span.from_expansion() {
return;
}

for (stmt, next_stmt) in block.stmts.iter().zip(block.stmts.iter().skip(1)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than checking each and every block, it probably would be more efficient to first check for a call to the .exists() method and then analyze the surrounding block. This lint should probably be moved into the methods directory.

if let StmtKind::Expr(next_expr) | StmtKind::Semi(next_expr) = next_stmt.kind {
check_stored_bool_pair(cx, stmt, next_expr);
}
}
if let Some(last_stmt) = block.stmts.last()
&& let Some(next_expr) = block.expr
{
check_stored_bool_pair(cx, last_stmt, next_expr);
}
}
}

fn check_stored_bool_pair<'tcx>(cx: &LateContext<'tcx>, let_stmt: &'tcx Stmt<'tcx>, next_expr: &'tcx Expr<'tcx>) {
let StmtKind::Let(local) = let_stmt.kind else {
return;
};
let Some(init) = local.init else { return };
let Some((path_recv, exists_span)) = extract_exists_receiver(cx, init) else {
return;
};
let PatKind::Binding(_, binding_id, _, _) = local.pat.kind else {
return;
};

if let Some(higher::If { cond, then, .. }) = higher::If::hir(next_expr)
&& path_to_local_with_projections(cond) == Some(binding_id)
&& let Some(fs_call_span) = find_fs_call(cx, then, path_recv, next_expr.span.ctxt())
{
emit_lint(cx, exists_span, fs_call_span);
}
}

fn emit_lint(cx: &LateContext<'_>, exists_span: Span, fs_call_span: Span) {
span_lint_and_then(
cx,
UNNECESSARY_PATH_EXISTS,
exists_span,
"unnecessary `Path::exists` before a filesystem operation on the same path",
|diag| {
diag.span_note(fs_call_span, "the filesystem operation is here");
diag.help(
"the `exists()` check is redundant and creates a TOCTOU race condition; \
consider removing it and handling the error from the filesystem operation directly",
);
},
);
}

/// Returns `true` if `expr` is a method call that resolves to a method defined
/// on `std::path::Path` (handles any type that derefs to `Path`, e.g. `PathBuf`).
fn is_path_method_call(cx: &LateContext<'_>, expr: &Expr<'_>) -> bool {
cx.typeck_results()
.type_dependent_def_id(expr.hir_id)
.is_some_and(|def_id| {
let parent = cx.tcx.parent(def_id);
cx.tcx
.type_of(parent)
.instantiate_identity()
.skip_norm_wip()
.is_diag_item(cx, sym::Path)
})
}

fn is_fs_method_name(name: Symbol) -> bool {
matches!(
name,
sym::canonicalize
| sym::is_dir
| sym::is_file
| sym::is_symlink
| sym::metadata
| sym::read_dir
| sym::read_link
| sym::symlink_metadata
)
}

/// Walks a condition expression to find a `Path::exists` call.
/// Returns the receiver expression and the span of the `.exists()` call.
/// Recurses through `&&` chains so compound conditions are handled.
fn extract_exists_receiver<'tcx>(cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) -> Option<(&'tcx Expr<'tcx>, Span)> {
match expr.kind {
ExprKind::MethodCall(seg, recv, [], _)
if seg.ident.name == sym::exists && !expr.span.from_expansion() && is_path_method_call(cx, expr) =>
{
Some((recv, expr.span))
},
ExprKind::Binary(op, lhs, rhs) if op.node == BinOpKind::And => {
extract_exists_receiver(cx, lhs).or_else(|| extract_exists_receiver(cx, rhs))
},
_ => None,
}
}

/// Searches the `then` block of the `if` for the first filesystem method call
/// on the same receiver as the `exists()` check.
fn find_fs_call<'tcx>(
cx: &LateContext<'tcx>,
then: &'tcx Expr<'tcx>,
path_recv: &'tcx Expr<'tcx>,
ctxt: SyntaxContext,
) -> Option<Span> {
let ExprKind::Block(block, _) = then.kind else {
return None;
};
for stmt in block.stmts {
if let Some(span) = find_fs_call_in_stmt(cx, stmt, path_recv, ctxt) {
return Some(span);
}
}
block.expr.and_then(|e| find_fs_call_in_expr(cx, e, path_recv, ctxt))
}

fn find_fs_call_in_stmt<'tcx>(
cx: &LateContext<'tcx>,
stmt: &'tcx Stmt<'tcx>,
path_recv: &'tcx Expr<'tcx>,
ctxt: SyntaxContext,
) -> Option<Span> {
match stmt.kind {
StmtKind::Expr(e) | StmtKind::Semi(e) => find_fs_call_in_expr(cx, e, path_recv, ctxt),
StmtKind::Let(local) => local
.init
.and_then(|init| find_fs_call_in_expr(cx, init, path_recv, ctxt)),
StmtKind::Item(_) => None,
}
}

/// Peels through method chains (e.g. `.metadata().unwrap()`) and the `?` operator
/// desugaring to find a filesystem method call on `path_recv`.
fn find_fs_call_in_expr<'tcx>(
cx: &LateContext<'tcx>,
expr: &'tcx Expr<'tcx>,
path_recv: &'tcx Expr<'tcx>,
ctxt: SyntaxContext,
) -> Option<Span> {
match expr.kind {
ExprKind::MethodCall(method_seg, recv, _, _) => {
if is_fs_method_name(method_seg.ident.name)
&& is_path_method_call(cx, expr)
&& SpanlessEq::new(cx).eq_expr(ctxt, recv, path_recv)
{
return Some(expr.span);
}
// Peel through chains like `.metadata().unwrap()` or `.metadata().ok()`
find_fs_call_in_expr(cx, recv, path_recv, ctxt)
},
// The `?` operator desugars to:
// Match(Call(TryTraitBranch, [inner_expr]), ..., TryDesugar)
// so we extract `inner_expr` and keep searching.
ExprKind::Match(scrutinee, _, MatchSource::TryDesugar(_)) => {
if let ExprKind::Call(_, [inner_expr]) = scrutinee.kind {
find_fs_call_in_expr(cx, inner_expr, path_recv, ctxt)
} else {
None
}
},
_ => None,
}
}
8 changes: 8 additions & 0 deletions clippy_utils/src/sym.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ generate! {
build_hasher,
by_ref,
bytes,
canonicalize,
capacity,
cargo_clippy: "cargo-clippy",
cast,
Expand Down Expand Up @@ -222,6 +223,7 @@ generate! {
eprint_macro,
eprintln_macro,
err,
exists,
exp,
expect_err,
expn_data,
Expand Down Expand Up @@ -375,6 +377,7 @@ generate! {
is_diag_item,
is_diagnostic_item,
is_digit,
is_dir,
is_empty,
is_err,
is_file,
Expand All @@ -385,6 +388,7 @@ generate! {
is_some,
is_some_and,
is_sorted_by_key,
is_symlink,
isize_legacy_const_max,
isize_legacy_const_min,
isize_legacy_fn_max_value,
Expand Down Expand Up @@ -426,6 +430,7 @@ generate! {
mem_replace,
mem_size_of,
mem_size_of_val,
metadata,
min,
min_by,
min_by_key,
Expand Down Expand Up @@ -496,8 +501,10 @@ generate! {
push_str,
range_step,
read,
read_dir,
read_exact,
read_line,
read_link,
read_to_end,
read_to_string,
read_unaligned,
Expand Down Expand Up @@ -592,6 +599,7 @@ generate! {
subsec_nanos,
sum,
symbol,
symlink_metadata,
take,
take_while,
tcx,
Expand Down
Loading