|
| 1 | +use ruff_diagnostics::{Edit, Fix}; |
| 2 | +use ruff_macros::{ViolationMetadata, derive_message_formats}; |
| 3 | +use ruff_python_ast::name::QualifiedName; |
| 4 | +use ruff_python_ast::{self as ast, Expr, ExprCall}; |
| 5 | +use ruff_python_semantic::{SemanticModel, analyze}; |
| 6 | +use ruff_text_size::Ranged; |
| 7 | + |
| 8 | +use crate::checkers::ast::Checker; |
| 9 | +use crate::{FixAvailability, Violation}; |
| 10 | + |
| 11 | +/// ## What it does |
| 12 | +/// Checks for standard library functions which take a numeric `mode` argument |
| 13 | +/// where a non-octal integer literal is passed. |
| 14 | +/// |
| 15 | +/// ## Why is this bad? |
| 16 | +/// |
| 17 | +/// Numeric modes are made up of one to four octal digits. Converting a non-octal |
| 18 | +/// integer to octal may not be the mode the author intended. |
| 19 | +/// |
| 20 | +/// ## Example |
| 21 | +/// |
| 22 | +/// ```python |
| 23 | +/// os.chmod("foo", 644) |
| 24 | +/// ``` |
| 25 | +/// |
| 26 | +/// Use instead: |
| 27 | +/// |
| 28 | +/// ```python |
| 29 | +/// os.chmod("foo", 0o644) |
| 30 | +/// ``` |
| 31 | +/// |
| 32 | +/// ## Fix safety |
| 33 | +/// |
| 34 | +/// There are two categories of fix, the first of which is where it looks like |
| 35 | +/// the author intended to use an octal literal but the `0o` prefix is missing: |
| 36 | +/// |
| 37 | +/// ```python |
| 38 | +/// os.chmod("foo", 400) |
| 39 | +/// os.chmod("foo", 644) |
| 40 | +/// ``` |
| 41 | +/// |
| 42 | +/// This class of fix changes runtime behaviour. In the first case, `400` |
| 43 | +/// corresponds to `0o620` (`u=rw,g=w,o=`). As this mode is not deemed likely, |
| 44 | +/// it is changed to `0o400` (`u=r,go=`). Similarly, `644` corresponds to |
| 45 | +/// `0o1204` (`u=ws,g=,o=r`) and is changed to `0o644` (`u=rw,go=r`). |
| 46 | +/// |
| 47 | +/// The second category is decimal literals which are recognised as likely valid |
| 48 | +/// but in decimal form: |
| 49 | +/// |
| 50 | +/// ```python |
| 51 | +/// os.chmod("foo", 256) |
| 52 | +/// os.chmod("foo", 493) |
| 53 | +/// ``` |
| 54 | +/// |
| 55 | +/// `256` corresponds to `0o400` (`u=r,go=`) and `493` corresponds to `0o755` |
| 56 | +/// (`u=rwx,go=rx`). Both of these fixes keep runtime behavior unchanged. If the |
| 57 | +/// original code really intended to use `0o256` (`u=w,g=rx,o=rw`) instead of |
| 58 | +/// `256`, this fix should not be accepted. |
| 59 | +/// |
| 60 | +/// ## Fix availability |
| 61 | +/// |
| 62 | +/// A fix is only available if the integer literal matches a set of common modes. |
| 63 | +#[derive(ViolationMetadata)] |
| 64 | +pub(crate) struct NonOctalPermissions; |
| 65 | + |
| 66 | +impl Violation for NonOctalPermissions { |
| 67 | + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; |
| 68 | + |
| 69 | + #[derive_message_formats] |
| 70 | + fn message(&self) -> String { |
| 71 | + "Non-octal mode".to_string() |
| 72 | + } |
| 73 | + |
| 74 | + fn fix_title(&self) -> Option<String> { |
| 75 | + Some("Replace with octal literal".to_string()) |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +/// RUF064 |
| 80 | +pub(crate) fn non_octal_permissions(checker: &Checker, call: &ExprCall) { |
| 81 | + let mode_arg = find_func_mode_arg(call, checker.semantic()) |
| 82 | + .or_else(|| find_method_mode_arg(call, checker.semantic())); |
| 83 | + |
| 84 | + let Some(mode_arg) = mode_arg else { |
| 85 | + return; |
| 86 | + }; |
| 87 | + |
| 88 | + let Expr::NumberLiteral(ast::ExprNumberLiteral { |
| 89 | + value: ast::Number::Int(int), |
| 90 | + .. |
| 91 | + }) = mode_arg |
| 92 | + else { |
| 93 | + return; |
| 94 | + }; |
| 95 | + |
| 96 | + let mode_literal = &checker.locator().contents()[mode_arg.range()]; |
| 97 | + |
| 98 | + if mode_literal.starts_with("0o") || mode_literal.starts_with("0O") || mode_literal == "0" { |
| 99 | + return; |
| 100 | + } |
| 101 | + |
| 102 | + let mut diagnostic = checker.report_diagnostic(NonOctalPermissions, mode_arg.range()); |
| 103 | + |
| 104 | + // Don't suggest a fix for 0x or 0b literals. |
| 105 | + if mode_literal.starts_with('0') { |
| 106 | + return; |
| 107 | + } |
| 108 | + |
| 109 | + let Some(suggested) = int.as_u16().and_then(suggest_fix) else { |
| 110 | + return; |
| 111 | + }; |
| 112 | + |
| 113 | + let edit = Edit::range_replacement(format!("{suggested:#o}"), mode_arg.range()); |
| 114 | + diagnostic.set_fix(Fix::unsafe_edit(edit)); |
| 115 | +} |
| 116 | + |
| 117 | +fn find_func_mode_arg<'a>(call: &'a ExprCall, semantic: &SemanticModel) -> Option<&'a Expr> { |
| 118 | + let qualified_name = semantic.resolve_qualified_name(&call.func)?; |
| 119 | + |
| 120 | + match qualified_name.segments() { |
| 121 | + ["os", "umask"] => call.arguments.find_argument_value("mode", 0), |
| 122 | + [ |
| 123 | + "os", |
| 124 | + "chmod" | "fchmod" | "lchmod" | "mkdir" | "makedirs" | "mkfifo" | "mknod", |
| 125 | + ] => call.arguments.find_argument_value("mode", 1), |
| 126 | + ["os", "open"] => call.arguments.find_argument_value("mode", 2), |
| 127 | + ["dbm", "open"] | ["dbm", "gnu" | "ndbm", "open"] => { |
| 128 | + call.arguments.find_argument_value("mode", 2) |
| 129 | + } |
| 130 | + _ => None, |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +fn find_method_mode_arg<'a>(call: &'a ExprCall, semantic: &SemanticModel) -> Option<&'a Expr> { |
| 135 | + let (type_name, attr_name) = resolve_method_call(&call.func, semantic)?; |
| 136 | + |
| 137 | + match (type_name.segments(), attr_name) { |
| 138 | + ( |
| 139 | + ["pathlib", "Path" | "PosixPath" | "WindowsPath"], |
| 140 | + "chmod" | "lchmod" | "mkdir" | "touch", |
| 141 | + ) => call.arguments.find_argument_value("mode", 0), |
| 142 | + _ => None, |
| 143 | + } |
| 144 | +} |
| 145 | + |
| 146 | +fn resolve_method_call<'a>( |
| 147 | + func: &'a Expr, |
| 148 | + semantic: &'a SemanticModel, |
| 149 | +) -> Option<(QualifiedName<'a>, &'a str)> { |
| 150 | + let Expr::Attribute(ast::ExprAttribute { attr, value, .. }) = func else { |
| 151 | + return None; |
| 152 | + }; |
| 153 | + |
| 154 | + // First: is this an inlined call like `pathlib.Path.chmod`? |
| 155 | + // ```python |
| 156 | + // from pathlib import Path |
| 157 | + // Path("foo").chmod(0o644) |
| 158 | + // ``` |
| 159 | + if let Expr::Call(call) = value.as_ref() { |
| 160 | + let qualified_name = semantic.resolve_qualified_name(&call.func)?; |
| 161 | + return Some((qualified_name, attr)); |
| 162 | + } |
| 163 | + |
| 164 | + // Second, is this a call like `pathlib.Path.chmod` via a variable? |
| 165 | + // ```python |
| 166 | + // from pathlib import Path |
| 167 | + // path = Path("foo") |
| 168 | + // path.chmod() |
| 169 | + // ``` |
| 170 | + let Expr::Name(name) = value.as_ref() else { |
| 171 | + return None; |
| 172 | + }; |
| 173 | + |
| 174 | + let binding_id = semantic.resolve_name(name)?; |
| 175 | + |
| 176 | + let binding = semantic.binding(binding_id); |
| 177 | + |
| 178 | + let Some(Expr::Call(call)) = analyze::typing::find_binding_value(binding, semantic) else { |
| 179 | + return None; |
| 180 | + }; |
| 181 | + |
| 182 | + let qualified_name = semantic.resolve_qualified_name(&call.func)?; |
| 183 | + |
| 184 | + Some((qualified_name, attr)) |
| 185 | +} |
| 186 | + |
| 187 | +/// Try to determine whether the integer literal |
| 188 | +fn suggest_fix(mode: u16) -> Option<u16> { |
| 189 | + // These suggestions are in the form of |
| 190 | + // <missing `0o` prefix> | <mode as decimal> => <octal> |
| 191 | + // If <as decimal> could theoretically be a valid octal literal, the |
| 192 | + // comment explains why it's deemed unlikely to be intentional. |
| 193 | + match mode { |
| 194 | + 400 | 256 => Some(0o400), // -w-r-xrw-, group/other > user unlikely |
| 195 | + 440 | 288 => Some(0o440), |
| 196 | + 444 | 292 => Some(0o444), |
| 197 | + 600 | 384 => Some(0o600), |
| 198 | + 640 | 416 => Some(0o640), // r----xrw-, other > user unlikely |
| 199 | + 644 | 420 => Some(0o644), // r---w----, group write but not read unlikely |
| 200 | + 660 | 432 => Some(0o660), // r---wx-w-, write but not read unlikely |
| 201 | + 664 | 436 => Some(0o664), // r---wxrw-, other > user unlikely |
| 202 | + 666 | 438 => Some(0o666), |
| 203 | + 700 | 448 => Some(0o700), |
| 204 | + 744 | 484 => Some(0o744), |
| 205 | + 750 | 488 => Some(0o750), |
| 206 | + 755 | 493 => Some(0o755), |
| 207 | + 770 | 504 => Some(0o770), // r-x---r--, other > group unlikely |
| 208 | + 775 | 509 => Some(0o775), |
| 209 | + 776 | 510 => Some(0o776), // r-x--x---, seems unlikely |
| 210 | + 777 | 511 => Some(0o777), // r-x--x--x, seems unlikely |
| 211 | + _ => None, |
| 212 | + } |
| 213 | +} |
0 commit comments