Skip to content

Commit f67ff33

Browse files
RazerMMichaReiser
andauthored
Add lint rule for calling chmod with non-octal integers (astral-sh#18541)
Co-authored-by: Micha Reiser <[email protected]>
1 parent dcf0a8d commit f67ff33

File tree

8 files changed

+621
-0
lines changed

8 files changed

+621
-0
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import dbm.gnu
2+
import dbm.ndbm
3+
import os
4+
from pathlib import Path
5+
6+
os.chmod("foo", 444) # Error
7+
os.chmod("foo", 0o444) # OK
8+
os.chmod("foo", 7777) # Error
9+
os.chmod("foo", 10000) # Error
10+
os.chmod("foo", 99999) # Error
11+
12+
os.umask(777) # Error
13+
os.umask(0o777) # OK
14+
15+
os.fchmod(0, 400) # Error
16+
os.fchmod(0, 0o400) # OK
17+
18+
os.lchmod("foo", 755) # Error
19+
os.lchmod("foo", 0o755) # OK
20+
21+
os.mkdir("foo", 600) # Error
22+
os.mkdir("foo", 0o600) # OK
23+
24+
os.makedirs("foo", 644) # Error
25+
os.makedirs("foo", 0o644) # OK
26+
27+
os.mkfifo("foo", 640) # Error
28+
os.mkfifo("foo", 0o640) # OK
29+
30+
os.mknod("foo", 660) # Error
31+
os.mknod("foo", 0o660) # OK
32+
33+
os.open("foo", os.O_CREAT, 644) # Error
34+
os.open("foo", os.O_CREAT, 0o644) # OK
35+
36+
Path("bar").chmod(755) # Error
37+
Path("bar").chmod(0o755) # OK
38+
39+
path = Path("bar")
40+
path.chmod(755) # Error
41+
path.chmod(0o755) # OK
42+
43+
dbm.open("db", "r", 600) # Error
44+
dbm.open("db", "r", 0o600) # OK
45+
46+
dbm.gnu.open("db", "r", 600) # Error
47+
dbm.gnu.open("db", "r", 0o600) # OK
48+
49+
dbm.ndbm.open("db", "r", 600) # Error
50+
dbm.ndbm.open("db", "r", 0o600) # OK
51+
52+
os.fchmod(0, 256) # 0o400
53+
os.fchmod(0, 493) # 0o755

crates/ruff_linter/src/checkers/ast/analyze/expression.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,6 +1205,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
12051205
if checker.enabled(Rule::FromisoformatReplaceZ) {
12061206
refurb::rules::fromisoformat_replace_z(checker, call);
12071207
}
1208+
if checker.enabled(Rule::NonOctalPermissions) {
1209+
ruff::rules::non_octal_permissions(checker, call);
1210+
}
12081211
}
12091212
Expr::Dict(dict) => {
12101213
if checker.any_enabled(&[

crates/ruff_linter/src/codes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
10281028
(Ruff, "059") => (RuleGroup::Preview, rules::ruff::rules::UnusedUnpackedVariable),
10291029
(Ruff, "060") => (RuleGroup::Preview, rules::ruff::rules::InEmptyCollection),
10301030
(Ruff, "061") => (RuleGroup::Preview, rules::ruff::rules::LegacyFormPytestRaises),
1031+
(Ruff, "064") => (RuleGroup::Preview, rules::ruff::rules::NonOctalPermissions),
10311032
(Ruff, "100") => (RuleGroup::Stable, rules::ruff::rules::UnusedNOQA),
10321033
(Ruff, "101") => (RuleGroup::Stable, rules::ruff::rules::RedirectedNOQA),
10331034
(Ruff, "102") => (RuleGroup::Preview, rules::ruff::rules::InvalidRuleCode),

crates/ruff_linter/src/rules/ruff/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ mod tests {
109109
#[test_case(Rule::LegacyFormPytestRaises, Path::new("RUF061_raises.py"))]
110110
#[test_case(Rule::LegacyFormPytestRaises, Path::new("RUF061_warns.py"))]
111111
#[test_case(Rule::LegacyFormPytestRaises, Path::new("RUF061_deprecated_call.py"))]
112+
#[test_case(Rule::NonOctalPermissions, Path::new("RUF064.py"))]
112113
#[test_case(Rule::RedirectedNOQA, Path::new("RUF101_0.py"))]
113114
#[test_case(Rule::RedirectedNOQA, Path::new("RUF101_1.py"))]
114115
#[test_case(Rule::InvalidRuleCode, Path::new("RUF102.py"))]

crates/ruff_linter/src/rules/ruff/rules/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub(crate) use mutable_dataclass_default::*;
2929
pub(crate) use mutable_fromkeys_value::*;
3030
pub(crate) use needless_else::*;
3131
pub(crate) use never_union::*;
32+
pub(crate) use non_octal_permissions::*;
3233
pub(crate) use none_not_at_end_of_union::*;
3334
pub(crate) use parenthesize_chained_operators::*;
3435
pub(crate) use post_init_default::*;
@@ -91,6 +92,7 @@ mod mutable_dataclass_default;
9192
mod mutable_fromkeys_value;
9293
mod needless_else;
9394
mod never_union;
95+
mod non_octal_permissions;
9496
mod none_not_at_end_of_union;
9597
mod parenthesize_chained_operators;
9698
mod post_init_default;
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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

Comments
 (0)