dotless 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dotless/__init__.py +3 -0
- dotless/__main__.py +3 -0
- dotless/analysis.py +350 -0
- dotless/cli.py +173 -0
- dotless/config.py +91 -0
- dotless/formats.py +148 -0
- dotless/lexer.py +286 -0
- dotless/rules.py +127 -0
- dotless/runtime.py +132 -0
- dotless/scanner.py +418 -0
- dotless-1.0.0.dist-info/METADATA +278 -0
- dotless-1.0.0.dist-info/RECORD +16 -0
- dotless-1.0.0.dist-info/WHEEL +5 -0
- dotless-1.0.0.dist-info/entry_points.txt +2 -0
- dotless-1.0.0.dist-info/licenses/LICENSE +674 -0
- dotless-1.0.0.dist-info/top_level.txt +1 -0
dotless/scanner.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Find locale-dependent case conversions in source files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from typing import Callable, Iterable, Iterator
|
|
10
|
+
|
|
11
|
+
from . import lexer
|
|
12
|
+
from .analysis import (
|
|
13
|
+
BOTH,
|
|
14
|
+
LOWER,
|
|
15
|
+
UPPER,
|
|
16
|
+
Verdict,
|
|
17
|
+
enclosing,
|
|
18
|
+
judge_call,
|
|
19
|
+
judge_literals,
|
|
20
|
+
literals_in,
|
|
21
|
+
matching_close,
|
|
22
|
+
)
|
|
23
|
+
from .lexer import Source
|
|
24
|
+
from .rules import RULES, severity_rank
|
|
25
|
+
|
|
26
|
+
EXTENSIONS = {
|
|
27
|
+
".c": "c", ".h": "c", ".cc": "c", ".cpp": "c", ".cxx": "c", ".hh": "c", ".hpp": "c",
|
|
28
|
+
".vala": "vala", ".gs": "vala",
|
|
29
|
+
".py": "python",
|
|
30
|
+
".js": "js", ".mjs": "js",
|
|
31
|
+
".rs": "rust",
|
|
32
|
+
}
|
|
33
|
+
SYNTAX = {"c": lexer.C, "vala": lexer.VALA, "python": lexer.PYTHON, "js": lexer.JS, "rust": lexer.RUST}
|
|
34
|
+
|
|
35
|
+
DEFAULT_EXCLUDES = (
|
|
36
|
+
".git", ".hg", "node_modules", "_build", "build", "builddir", "target",
|
|
37
|
+
"__pycache__", ".venv", "venv", ".flatpak-builder", "subprojects", "po",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_SUPPRESS = re.compile(r"dotless:\s*ignore(?P<file>-file)?(?:\[(?P<ids>[A-Z0-9_,\s]+)\])?")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Finding:
|
|
45
|
+
rule: str
|
|
46
|
+
severity: str
|
|
47
|
+
path: str
|
|
48
|
+
line: int
|
|
49
|
+
column: int
|
|
50
|
+
end_column: int
|
|
51
|
+
language: str
|
|
52
|
+
call: str
|
|
53
|
+
message: str
|
|
54
|
+
reason: str
|
|
55
|
+
fix: str
|
|
56
|
+
snippet: str
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict:
|
|
59
|
+
return asdict(self)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Match:
|
|
64
|
+
rule: str
|
|
65
|
+
start: int # start of the reported expression
|
|
66
|
+
end: int # end of the reported expression
|
|
67
|
+
call: str
|
|
68
|
+
kind: str
|
|
69
|
+
verdict: Verdict | None = None # pre-decided verdict
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
Matcher = Callable[[Source], Iterator[Match]]
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------- C / C++
|
|
75
|
+
|
|
76
|
+
_C_DECL_BEFORE = re.compile(r"(?:\b(?:int|extern|static|inline|define|undef|wint_t|char)\s*\**\s*|#\s*(?:define|undef)\s+)$")
|
|
77
|
+
_C_PARAMS = re.compile(r"\s*(?:(?:const|unsigned|signed|struct|volatile)\s+)*[A-Za-z_]\w*(?:\s+|\s*\*+\s*)[A-Za-z_]\w*\s*[,)]")
|
|
78
|
+
_C_CTYPE = re.compile(r"(?<![\w$.>:])(?:std\s*::\s*)?(tolower|toupper|_tolower|_toupper)\s*\(")
|
|
79
|
+
_C_WIDE = re.compile(r"(?<![\w$.>:])(?:std\s*::\s*)?(towlower|towupper)\s*\(")
|
|
80
|
+
_C_CASECMP = re.compile(r"(?<![\w$.>:])(strcasecmp|strncasecmp|strcasestr|wcscasecmp|wcsncasecmp)\s*\(")
|
|
81
|
+
_C_GLIB_DEPR = re.compile(r"(?<![\w$.>:])(g_strdown|g_strup|g_strcasecmp|g_strncasecmp)\s*\(")
|
|
82
|
+
_C_GLIB_UTF8 = re.compile(r"(?<![\w$.>:])(g_utf8_strdown|g_utf8_strup)\s*\(")
|
|
83
|
+
_C_CALLBACK = re.compile(r"(?<=[(,])\s*(?:\(\s*\w+\s*\)\s*)?(strcasecmp|strncasecmp|g_strcasecmp|wcscasecmp)\s*(?=[,)])")
|
|
84
|
+
_LOOKUP_CALLERS = re.compile(r"^(bsearch|lfind|lsearch|g_hash_table_new\w*|g_tree_new\w*|g_\w*find_custom|g_ptr_array_find_with_equal_func|g_\w*_lookup\w*)$")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _kind_of(name: str) -> str:
|
|
88
|
+
n = name.lower()
|
|
89
|
+
if "casecmp" in n or "casestr" in n:
|
|
90
|
+
return BOTH
|
|
91
|
+
if "low" in n or "down" in n:
|
|
92
|
+
return LOWER
|
|
93
|
+
return UPPER
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _calls(src: Source, pattern: re.Pattern, rule: str, decl_guard: bool = True) -> Iterator[Match]:
|
|
97
|
+
bare = src.bare
|
|
98
|
+
for m in pattern.finditer(bare):
|
|
99
|
+
if decl_guard and _C_DECL_BEFORE.search(bare, max(0, m.start() - 40), m.start()):
|
|
100
|
+
continue
|
|
101
|
+
close = matching_close(bare, m.end() - 1)
|
|
102
|
+
# Function definitions and prototypes: "name (const gchar *str, ..."
|
|
103
|
+
if decl_guard and _C_PARAMS.match(bare, m.end()):
|
|
104
|
+
continue
|
|
105
|
+
yield Match(rule, m.start(), close, re.sub(r"\s+", "", m.group(0)[:-1]), _kind_of(m.group(1)))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _casecmp_calls(src: Source, pattern: re.Pattern, rule: str) -> Iterator[Match]:
|
|
109
|
+
"""strcasecmp-family: the comparison constant is an argument."""
|
|
110
|
+
for match in _calls(src, pattern, rule):
|
|
111
|
+
open_idx = src.bare.index("(", match.start)
|
|
112
|
+
lits = literals_in(src, open_idx, match.end)
|
|
113
|
+
verdict = judge_literals(lits, BOTH, "compares with") if lits else None
|
|
114
|
+
if verdict is not None and verdict.severity is None:
|
|
115
|
+
continue
|
|
116
|
+
match.verdict = verdict
|
|
117
|
+
yield match
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def match_c(src: Source) -> Iterator[Match]:
|
|
121
|
+
yield from _calls(src, _C_CTYPE, "DL001")
|
|
122
|
+
yield from _casecmp_calls(src, _C_CASECMP, "DL002")
|
|
123
|
+
yield from _calls(src, _C_WIDE, "DL003")
|
|
124
|
+
yield from _calls(src, _C_GLIB_UTF8, "DL004")
|
|
125
|
+
for m in _calls(src, _C_GLIB_DEPR, "DL005"):
|
|
126
|
+
if m.kind == BOTH:
|
|
127
|
+
open_idx = src.bare.index("(", m.start)
|
|
128
|
+
lits = literals_in(src, open_idx, m.end)
|
|
129
|
+
m.verdict = judge_literals(lits, BOTH, "compares with") if lits else None
|
|
130
|
+
if m.verdict is not None and m.verdict.severity is None:
|
|
131
|
+
continue
|
|
132
|
+
yield m
|
|
133
|
+
# strcasecmp passed as a callback (bsearch, g_hash_table_new, ...)
|
|
134
|
+
for m in _C_CALLBACK.finditer(src.bare):
|
|
135
|
+
enc = enclosing(src.bare, m.start(1))
|
|
136
|
+
name = enc[1] if enc else ""
|
|
137
|
+
if _LOOKUP_CALLERS.match(name):
|
|
138
|
+
v = Verdict("high", f"used as the comparison function of {name}()")
|
|
139
|
+
else:
|
|
140
|
+
v = Verdict("low", f"passed as a callback to {name or 'a function'}()")
|
|
141
|
+
rule = "DL005" if m.group(1).startswith("g_") else "DL002"
|
|
142
|
+
yield Match(rule, m.start(1), m.end(1), m.group(1), BOTH, v)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------- Vala
|
|
146
|
+
|
|
147
|
+
_VALA_CASE = re.compile(r"\.\s*(down|up)\s*\(")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def match_vala(src: Source) -> Iterator[Match]:
|
|
151
|
+
bare = src.bare
|
|
152
|
+
for m in _VALA_CASE.finditer(bare):
|
|
153
|
+
close = matching_close(bare, m.end() - 1)
|
|
154
|
+
args = bare[m.end() : close - 1].strip()
|
|
155
|
+
if args and not re.fullmatch(r"[\w.\-+ ]+", args):
|
|
156
|
+
continue
|
|
157
|
+
# Start the expression at the receiver so comparisons in front of
|
|
158
|
+
# it ("x" == s.down ()) are seen.
|
|
159
|
+
rs = m.start()
|
|
160
|
+
recv = re.search(r"([\w.]+|\))\s*$", bare[max(0, rs - 200) : rs])
|
|
161
|
+
start = rs - len(recv.group(0)) if recv and recv.group(1) != ")" else rs
|
|
162
|
+
yield Match("DL006", start, close, m.group(1) + "()", LOWER if m.group(1) == "down" else UPPER)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ----------------------------------------------------------------- Python
|
|
166
|
+
|
|
167
|
+
_PY_GLIB = re.compile(r"\bGLib\s*\.\s*(utf8_strdown|utf8_strup)\s*\(")
|
|
168
|
+
_PY_RE_LOCALE = re.compile(r"\bre\s*\.\s*(?:L|LOCALE)\b")
|
|
169
|
+
_PY_RE_ICASE = re.compile(r"\bre\s*\.\s*(?:I|IGNORECASE)\b")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def match_python(src: Source) -> Iterator[Match]:
|
|
173
|
+
yield from _calls(src, _PY_GLIB, "DL004", decl_guard=False)
|
|
174
|
+
bare = src.bare
|
|
175
|
+
for m in _PY_RE_LOCALE.finditer(bare):
|
|
176
|
+
enc = enclosing(bare, m.start())
|
|
177
|
+
if enc is None:
|
|
178
|
+
continue
|
|
179
|
+
open_idx, name, _ = enc
|
|
180
|
+
close = matching_close(bare, open_idx)
|
|
181
|
+
call_text = bare[open_idx:close]
|
|
182
|
+
lits = literals_in(src, open_idx, close)
|
|
183
|
+
if not (_PY_RE_ICASE.search(call_text) or any(v.startswith("(?i") for v in lits)):
|
|
184
|
+
continue
|
|
185
|
+
pattern = lits[0] if lits else None
|
|
186
|
+
if pattern is None:
|
|
187
|
+
v = Verdict("low", "pattern is not a constant")
|
|
188
|
+
elif re.search(r"[iI]|a-z|A-Z", pattern):
|
|
189
|
+
v = Verdict("high", f"case-insensitive pattern {pattern!r} contains i/I or a letter range")
|
|
190
|
+
else:
|
|
191
|
+
continue
|
|
192
|
+
name_start = bare.rfind(name, 0, open_idx)
|
|
193
|
+
yield Match("DL009", name_start if name_start >= 0 else open_idx, close, f"re.{name}(..., re.LOCALE)", BOTH, v)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------- JavaScript
|
|
197
|
+
|
|
198
|
+
_JS_LOCALE_CASE = re.compile(r"\.\s*(toLocaleLowerCase|toLocaleUpperCase)\s*\(")
|
|
199
|
+
_JS_GLIB = re.compile(r"\bGLib\s*\.\s*(utf8_strdown|utf8_strup)\s*\(")
|
|
200
|
+
_JS_LOCALE_COMPARE = re.compile(r"\.\s*localeCompare\s*\(")
|
|
201
|
+
_JS_NO_LOCALE = re.compile(r"^\s*(?:undefined|null|\[\s*\])?\s*$")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def match_js(src: Source) -> Iterator[Match]:
|
|
205
|
+
bare = src.bare
|
|
206
|
+
for m in _JS_LOCALE_CASE.finditer(bare):
|
|
207
|
+
close = matching_close(bare, m.end() - 1)
|
|
208
|
+
args = bare[m.end() : close - 1]
|
|
209
|
+
if not _JS_NO_LOCALE.match(args):
|
|
210
|
+
lits = literals_in(src, m.end(), close)
|
|
211
|
+
if lits:
|
|
212
|
+
continue # explicit locale, e.g. 'en-US' or deliberately 'tr'
|
|
213
|
+
start = _receiver_start(bare, m.start())
|
|
214
|
+
yield Match("DL007", start, close, m.group(1) + "()", LOWER if "Lower" in m.group(1) else UPPER)
|
|
215
|
+
yield from _calls(src, _JS_GLIB, "DL004", decl_guard=False)
|
|
216
|
+
for m in _JS_LOCALE_COMPARE.finditer(bare):
|
|
217
|
+
close = matching_close(bare, m.end() - 1)
|
|
218
|
+
args_code = src.code[m.end() : close - 1]
|
|
219
|
+
if not re.search(r"sensitivity\s*:\s*['\"](base|accent)['\"]", args_code):
|
|
220
|
+
continue
|
|
221
|
+
# Explicit locale as second argument?
|
|
222
|
+
lits = literals_in(src, m.end(), close)
|
|
223
|
+
start = _receiver_start(bare, m.start())
|
|
224
|
+
cmp = re.match(r"\s*(===|!==|==|!=|<|>)\s*0\b", bare[close:])
|
|
225
|
+
if lits and re.match(r"['\"]", args_code.split(",")[1].strip() if "," in args_code else ""):
|
|
226
|
+
continue
|
|
227
|
+
receiver_lits = literals_in(src, start, m.start())
|
|
228
|
+
candidates = [v for v in lits if not re.fullmatch(r"base|accent", v)] + receiver_lits
|
|
229
|
+
if cmp and any(_has_i(v) for v in candidates):
|
|
230
|
+
v = Verdict("high", "equality check against a constant containing i/I")
|
|
231
|
+
elif cmp:
|
|
232
|
+
v = Verdict("low", "equality check with sensitivity base/accent")
|
|
233
|
+
else:
|
|
234
|
+
continue # sorting: locale-aware order is intended
|
|
235
|
+
yield Match("DL008", start, close, "localeCompare()", BOTH, v)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _has_i(v: str) -> bool:
|
|
239
|
+
return v.isascii() and ("i" in v or "I" in v)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _receiver_start(bare: str, dot: int) -> int:
|
|
243
|
+
recv = re.search(r"([\w$.]+)\s*$", bare[max(0, dot - 200) : dot])
|
|
244
|
+
return dot - len(recv.group(0)) if recv else dot
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------- Rust
|
|
248
|
+
|
|
249
|
+
_RS_LIBC_CTYPE = re.compile(r"\blibc\s*::\s*(tolower|toupper)\s*\(")
|
|
250
|
+
_RS_LIBC_CASECMP = re.compile(r"\blibc\s*::\s*(strcasecmp|strncasecmp)\s*\(")
|
|
251
|
+
_RS_GLIB_FFI = re.compile(r"(?<![\w$])(?:(?:glib\s*::\s*)?ffi\s*::\s*|glib_sys\s*::\s*)?(g_utf8_strdown|g_utf8_strup)\s*\(")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def match_rust(src: Source) -> Iterator[Match]:
|
|
255
|
+
yield from _calls(src, _RS_LIBC_CTYPE, "DL001", decl_guard=False)
|
|
256
|
+
yield from _casecmp_calls(src, _RS_LIBC_CASECMP, "DL002")
|
|
257
|
+
for m in _calls(src, _RS_GLIB_FFI, "DL004", decl_guard=False):
|
|
258
|
+
if re.search(r"\bfn\s*$", src.bare[max(0, m.start - 20) : m.start]):
|
|
259
|
+
continue
|
|
260
|
+
yield m
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
MATCHERS: dict[str, Matcher] = {
|
|
264
|
+
"c": match_c,
|
|
265
|
+
"vala": match_vala,
|
|
266
|
+
"python": match_python,
|
|
267
|
+
"js": match_js,
|
|
268
|
+
"rust": match_rust,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
# Cheap prefilter: skip lexing files that cannot contain a match.
|
|
272
|
+
PREFILTER = {
|
|
273
|
+
"c": re.compile(r"tolower|toupper|towlower|towupper|casecmp|casestr|g_strdown|g_strup|g_utf8_str(?:down|up)"),
|
|
274
|
+
"vala": re.compile(r"\.\s*(?:down|up)\s*\("),
|
|
275
|
+
"python": re.compile(r"utf8_str(?:down|up)|re\s*\.\s*(?:L\b|LOCALE)"),
|
|
276
|
+
"js": re.compile(r"toLocale(?:Lower|Upper)Case|utf8_str(?:down|up)|localeCompare"),
|
|
277
|
+
"rust": re.compile(r"libc\s*::\s*(?:to(?:lower|upper)|str\w*casecmp)|g_utf8_str(?:down|up)"),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def language_of(path: str) -> str | None:
|
|
282
|
+
return EXTENSIONS.get(os.path.splitext(path)[1].lower())
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _suppressions(src: Source) -> tuple[bool, dict[int, set[str] | None]]:
|
|
286
|
+
"""(whole file ignored, {line: rule ids or None for all rules})."""
|
|
287
|
+
lines: dict[int, set[str] | None] = {}
|
|
288
|
+
for line, text in src.comments:
|
|
289
|
+
for m in _SUPPRESS.finditer(text):
|
|
290
|
+
if m.group("file"):
|
|
291
|
+
return True, {}
|
|
292
|
+
ids = {i.strip() for i in m.group("ids").split(",")} if m.group("ids") else None
|
|
293
|
+
comment_line = line + text[: m.start()].count("\n")
|
|
294
|
+
targets = [comment_line]
|
|
295
|
+
if src.line_text(comment_line).strip().startswith(("//", "/*", "#", "*")):
|
|
296
|
+
targets.append(comment_line + 1)
|
|
297
|
+
for t in targets:
|
|
298
|
+
prev = lines.get(t, set())
|
|
299
|
+
lines[t] = None if ids is None or prev is None else prev | ids
|
|
300
|
+
return False, lines
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def scan_text(text: str, path: str, lang: str) -> list[Finding]:
|
|
304
|
+
if not PREFILTER[lang].search(text):
|
|
305
|
+
return []
|
|
306
|
+
src = lexer.lex(text, SYNTAX[lang])
|
|
307
|
+
ignore_file, suppressed = _suppressions(src)
|
|
308
|
+
if ignore_file:
|
|
309
|
+
return []
|
|
310
|
+
findings = []
|
|
311
|
+
seen: set[tuple[str, int]] = set()
|
|
312
|
+
for m in MATCHERS[lang](src):
|
|
313
|
+
verdict = m.verdict or judge_call(src, m.start, m.end, m.kind, lang)
|
|
314
|
+
if verdict.severity is None:
|
|
315
|
+
continue
|
|
316
|
+
line = src.line_of(m.start)
|
|
317
|
+
sup = suppressed.get(line, set())
|
|
318
|
+
if sup is None or m.rule in sup:
|
|
319
|
+
continue
|
|
320
|
+
key = (m.rule, m.start)
|
|
321
|
+
if key in seen:
|
|
322
|
+
continue
|
|
323
|
+
seen.add(key)
|
|
324
|
+
rule = RULES[m.rule]
|
|
325
|
+
col = src.col_of(m.start)
|
|
326
|
+
end_col = col + (m.end - m.start) if src.line_of(m.end) == line else len(src.line_text(line)) + 1
|
|
327
|
+
findings.append(
|
|
328
|
+
Finding(
|
|
329
|
+
rule=m.rule,
|
|
330
|
+
severity=verdict.severity,
|
|
331
|
+
path=path,
|
|
332
|
+
line=line,
|
|
333
|
+
column=col,
|
|
334
|
+
end_column=end_col,
|
|
335
|
+
language=lang,
|
|
336
|
+
call=m.call,
|
|
337
|
+
message=f"{m.call}: {rule.summary}",
|
|
338
|
+
reason=verdict.reason,
|
|
339
|
+
fix=rule.fix,
|
|
340
|
+
snippet=src.line_text(line).strip(),
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
findings.sort(key=lambda f: (f.line, f.column))
|
|
344
|
+
return findings
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def iter_files(
|
|
348
|
+
paths: Iterable[str], excludes: Iterable[str] = (), force_exclude: bool = False
|
|
349
|
+
) -> Iterator[tuple[str, str]]:
|
|
350
|
+
"""Source files under ``paths`` as (path, language).
|
|
351
|
+
|
|
352
|
+
Exclude patterns apply to files found while walking directories. Files
|
|
353
|
+
named explicitly are always scanned, unless ``force_exclude`` is set
|
|
354
|
+
(useful for pre-commit, which passes file names).
|
|
355
|
+
"""
|
|
356
|
+
excludes = tuple(excludes)
|
|
357
|
+
|
|
358
|
+
def excluded(rel: str) -> bool:
|
|
359
|
+
rel = rel.replace(os.sep, "/")
|
|
360
|
+
return any(fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(os.path.basename(rel), pat) for pat in excludes)
|
|
361
|
+
|
|
362
|
+
for root in paths:
|
|
363
|
+
if os.path.isfile(root):
|
|
364
|
+
lang = language_of(root)
|
|
365
|
+
if lang and not (force_exclude and _explicit_excluded(root, excluded)):
|
|
366
|
+
yield root, lang
|
|
367
|
+
continue
|
|
368
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
369
|
+
dirnames[:] = sorted(
|
|
370
|
+
d for d in dirnames
|
|
371
|
+
if d not in DEFAULT_EXCLUDES and not excluded(os.path.relpath(os.path.join(dirpath, d), root))
|
|
372
|
+
)
|
|
373
|
+
for f in sorted(filenames):
|
|
374
|
+
full = os.path.join(dirpath, f)
|
|
375
|
+
lang = language_of(f)
|
|
376
|
+
if lang and not excluded(os.path.relpath(full, root)):
|
|
377
|
+
yield full, lang
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _explicit_excluded(path: str, excluded: Callable[[str], bool]) -> bool:
|
|
381
|
+
"""Whether an explicitly named file, or one of its directories, is excluded."""
|
|
382
|
+
rel = os.path.relpath(path).replace(os.sep, "/")
|
|
383
|
+
parts = rel.split("/")
|
|
384
|
+
if any(p in DEFAULT_EXCLUDES for p in parts[:-1]):
|
|
385
|
+
return True
|
|
386
|
+
return any(excluded("/".join(parts[: i + 1])) for i in range(len(parts)))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def scan_paths(
|
|
390
|
+
paths: Iterable[str],
|
|
391
|
+
min_severity: str = "low",
|
|
392
|
+
excludes: Iterable[str] = (),
|
|
393
|
+
disable: Iterable[str] = (),
|
|
394
|
+
force_exclude: bool = False,
|
|
395
|
+
errors: list[str] | None = None,
|
|
396
|
+
) -> tuple[list[Finding], int]:
|
|
397
|
+
"""Scan files and directories. Returns (findings, number of files scanned).
|
|
398
|
+
|
|
399
|
+
Files that cannot be read are skipped; their error messages are added
|
|
400
|
+
to ``errors`` when given.
|
|
401
|
+
"""
|
|
402
|
+
findings: list[Finding] = []
|
|
403
|
+
count = 0
|
|
404
|
+
floor = severity_rank(min_severity)
|
|
405
|
+
disabled = set(disable)
|
|
406
|
+
for path, lang in iter_files(paths, excludes, force_exclude):
|
|
407
|
+
try:
|
|
408
|
+
with open(path, "rb") as fh:
|
|
409
|
+
text = fh.read().decode("utf-8", errors="replace")
|
|
410
|
+
except OSError as e:
|
|
411
|
+
if errors is not None:
|
|
412
|
+
errors.append(f"{path}: {e.strerror or e}")
|
|
413
|
+
continue
|
|
414
|
+
count += 1
|
|
415
|
+
for f in scan_text(text, path.replace(os.sep, "/"), lang):
|
|
416
|
+
if severity_rank(f.severity) >= floor and f.rule not in disabled:
|
|
417
|
+
findings.append(f)
|
|
418
|
+
return findings, count
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dotless
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Find Turkish and Azerbaijani dotted/dotless I bugs caused by locale-dependent case mapping in C/GLib, Vala, Python, JavaScript and Rust
|
|
5
|
+
Author-email: Ramal Rahimov <jhonaluman@gmail.com>
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://gitlab.com/rehimovv/dotless
|
|
8
|
+
Project-URL: Source, https://gitlab.com/rehimovv/dotless
|
|
9
|
+
Project-URL: Issues, https://gitlab.com/rehimovv/dotless/-/issues
|
|
10
|
+
Project-URL: Changelog, https://gitlab.com/rehimovv/dotless/-/blob/main/CHANGELOG.md
|
|
11
|
+
Project-URL: Mirror, https://github.com/rehimovv/dotless
|
|
12
|
+
Keywords: i18n,l10n,locale,turkish,azerbaijani,gnome,glib,linter,static-analysis,dotless-i,case-mapping
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Natural Language :: English
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: C
|
|
19
|
+
Classifier: Programming Language :: JavaScript
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Programming Language :: Rust
|
|
28
|
+
Classifier: Topic :: Software Development :: Localization
|
|
29
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
30
|
+
Requires-Python: >=3.9
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Provides-Extra: test
|
|
34
|
+
Requires-Dist: pytest; extra == "test"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# dotless
|
|
38
|
+
|
|
39
|
+
[](https://pypi.org/project/dotless/)
|
|
40
|
+
[](https://gitlab.com/rehimovv/dotless/-/pipelines)
|
|
41
|
+
[](https://pypi.org/project/dotless/)
|
|
42
|
+
[](https://gitlab.com/rehimovv/dotless/-/blob/main/LICENSE)
|
|
43
|
+
|
|
44
|
+
Find the Turkish and Azerbaijani "I" bug in C/GLib, Vala, Python,
|
|
45
|
+
JavaScript (gjs) and Rust code.
|
|
46
|
+
|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
In Turkish and Azerbaijani, the upper-case form of `i` is `İ` and the
|
|
50
|
+
lower-case form of `I` is `ı`. Code that changes case with the user's locale
|
|
51
|
+
and then compares the result with an ASCII keyword breaks for everyone
|
|
52
|
+
running `tr_TR` or `az_AZ`:
|
|
53
|
+
|
|
54
|
+
```c
|
|
55
|
+
char *t = g_utf8_strdown (type, -1); /* "INTEGER" -> "ınteger" in tr_TR */
|
|
56
|
+
if (strcmp (t, "integer") == 0) /* never true for Turkish users */
|
|
57
|
+
...
|
|
58
|
+
if (strcasecmp (ext, "ini") == 0) /* "INI" != "ini" in tr_TR/az_AZ */
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| | Upper | Lower |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| English | I | i |
|
|
64
|
+
| Turkish / Azerbaijani | I | ı |
|
|
65
|
+
| Turkish / Azerbaijani | İ | i |
|
|
66
|
+
|
|
67
|
+
## Why dotless?
|
|
68
|
+
|
|
69
|
+
This bug has been found and fixed one project at a time for decades.
|
|
70
|
+
Python's codec lookup failed under Turkish locales because it normalised
|
|
71
|
+
names with the C library's `tolower()`
|
|
72
|
+
([bpo-1813](https://bugs.python.org/issue1813), fixed 2011). LibreOffice's
|
|
73
|
+
resource compiler had to be fixed to work in `tr_TR.UTF-8` because of
|
|
74
|
+
`toupper()`/`tolower()`
|
|
75
|
+
([commit](https://github.com/LibreOffice/core/commit/81d2967bc7be11e7efa40a296fee40210843e5e7),
|
|
76
|
+
2016), and later failed to substitute configuration entries
|
|
77
|
+
([tdf#125284](https://github.com/LibreOffice/core/commit/7bc3da4ff0a6fda621340a4ded79f7ed4ce31d85),
|
|
78
|
+
2019). Geary did not sync mail from Gmail with `LC_CTYPE=tr_TR.UTF-8`
|
|
79
|
+
([GNOME bug 795906](https://bugzilla.gnome.org/show_bug.cgi?id=795906), fixed in 0.12.3).
|
|
80
|
+
dotless looks for the pattern systematically.
|
|
81
|
+
|
|
82
|
+
Every rule is backed by a measurement, not by documentation alone: small
|
|
83
|
+
probe programs run under `C.UTF-8`, `tr_TR.UTF-8`, `az_AZ.UTF-8` and
|
|
84
|
+
`tr_TR.ISO-8859-9` in CI ([docs/research.md](https://gitlab.com/rehimovv/dotless/-/blob/main/docs/research.md)). Some
|
|
85
|
+
results were not what one would guess:
|
|
86
|
+
|
|
87
|
+
* glibc's `toupper('i')` in a UTF-8 Turkish locale returns `'i'`, not `İ`:
|
|
88
|
+
the answer does not fit in a byte, so the letter is left alone.
|
|
89
|
+
* GLib only applies Turkic rules after the program calls `setlocale()`.
|
|
90
|
+
Every GTK application does; a plain Rust binary using `glib::ffi` does not.
|
|
91
|
+
* gjs and node disagree: `toLocaleLowerCase()` without arguments follows the
|
|
92
|
+
locale in gjs but not in node 20.
|
|
93
|
+
* musl (Alpine) has no Turkish case mapping at all; the bug is a glibc bug in
|
|
94
|
+
practice, which is what GNOME distributions ship.
|
|
95
|
+
* Python 3's `str.lower()` is locale independent. Only `re.LOCALE` on bytes
|
|
96
|
+
is affected.
|
|
97
|
+
|
|
98
|
+
## Install
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pipx install dotless
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
or `pip install dotless`. dotless needs Python 3.9 or newer and has no
|
|
105
|
+
dependencies. The development version installs with
|
|
106
|
+
`pipx install git+https://gitlab.com/rehimovv/dotless.git`.
|
|
107
|
+
|
|
108
|
+
## Usage
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
dotless scan path/to/project
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
| Option | |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `--format text\|json\|sarif\|gitlab` | output format (default `text`) |
|
|
117
|
+
| `--min-severity low\|high` | hide low-severity findings |
|
|
118
|
+
| `--output FILE` | write the report to a file |
|
|
119
|
+
| `--exclude GLOB` | skip paths (repeatable); `.git`, `build`, `subprojects`, `node_modules`, `po` ... are skipped by default |
|
|
120
|
+
| `--disable RULE` | turn a rule off, e.g. `--disable DL005` (repeatable) |
|
|
121
|
+
| `--color auto\|always\|never` | colour in the terminal; `auto` honours `NO_COLOR` and turns colour off when output is piped |
|
|
122
|
+
| `--config FILE`, `--no-config` | read settings from another file, or ignore `pyproject.toml` |
|
|
123
|
+
| `--force-exclude` | apply excludes to files named on the command line too |
|
|
124
|
+
| `--exit-zero` | always exit with 0 |
|
|
125
|
+
|
|
126
|
+
`dotless rules` lists the rules and `dotless --version` prints the version.
|
|
127
|
+
|
|
128
|
+
**Exit status:** `0` nothing found, `1` findings reported, `2` usage or
|
|
129
|
+
configuration error (unknown option or rule, missing path, invalid
|
|
130
|
+
`[tool.dotless]`). Files that cannot be read are reported on stderr and
|
|
131
|
+
skipped.
|
|
132
|
+
|
|
133
|
+
To silence a finding, add a comment `dotless: ignore` (or
|
|
134
|
+
`dotless: ignore[DL002]`) on the same line or on the line above;
|
|
135
|
+
`dotless: ignore-file` skips a file.
|
|
136
|
+
|
|
137
|
+
### Configuration
|
|
138
|
+
|
|
139
|
+
dotless reads `[tool.dotless]` from the nearest `pyproject.toml` (in the
|
|
140
|
+
current directory or a parent). Command line options take precedence.
|
|
141
|
+
|
|
142
|
+
```toml
|
|
143
|
+
[tool.dotless]
|
|
144
|
+
exclude = ["vendor", "tests/data/*"]
|
|
145
|
+
min-severity = "high"
|
|
146
|
+
disable = ["DL005"]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Reading `pyproject.toml` needs Python 3.11 or newer (`tomllib`). On 3.9 and
|
|
150
|
+
3.10 dotless prints a warning and ignores the table; command line options
|
|
151
|
+
work everywhere.
|
|
152
|
+
|
|
153
|
+
### pre-commit
|
|
154
|
+
|
|
155
|
+
```yaml
|
|
156
|
+
repos:
|
|
157
|
+
- repo: https://gitlab.com/rehimovv/dotless
|
|
158
|
+
rev: v1.0.0
|
|
159
|
+
hooks:
|
|
160
|
+
- id: dotless
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The hook passes `--force-exclude`, so `exclude` patterns from
|
|
164
|
+
`[tool.dotless]` also apply to the files pre-commit hands over.
|
|
165
|
+
|
|
166
|
+
### CI
|
|
167
|
+
|
|
168
|
+
GitLab — one line in `.gitlab-ci.yml`; findings show up in the merge
|
|
169
|
+
request Code Quality widget:
|
|
170
|
+
|
|
171
|
+
```yaml
|
|
172
|
+
include: 'https://gitlab.com/rehimovv/dotless/-/raw/main/ci/dotless.gitlab-ci.yml'
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
GitHub Actions — upload SARIF to code scanning:
|
|
176
|
+
|
|
177
|
+
```yaml
|
|
178
|
+
- run: pipx install dotless
|
|
179
|
+
- run: dotless scan . --format sarif --output dotless.sarif --exit-zero
|
|
180
|
+
- uses: github/codeql-action/upload-sarif@v4
|
|
181
|
+
with:
|
|
182
|
+
sarif_file: dotless.sarif
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Runtime check
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
dotless run -- ./my-program --some-args
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
runs the command under `C.UTF-8`, then under `tr_TR.UTF-8` and
|
|
192
|
+
`az_AZ.UTF-8`, and shows any difference in exit status, stdout or stderr.
|
|
193
|
+
Messages stay untranslated (`LANGUAGE=en`), so differences come from the
|
|
194
|
+
locale itself. Missing locales can be generated with
|
|
195
|
+
`localedef -i tr_TR -f UTF-8 tr_TR.UTF-8`. Exit status: `0` same output,
|
|
196
|
+
`1` differences, `2` the comparison could not run.
|
|
197
|
+
|
|
198
|
+
## Severity
|
|
199
|
+
|
|
200
|
+
Only I/i behaves differently, so dotless can often tell whether a call is
|
|
201
|
+
actually broken. A conversion is **high** when its result is compared with
|
|
202
|
+
an ASCII constant containing the affected letter (`"ini"`, `"file"`, `'I'`),
|
|
203
|
+
or is looked up as a hash table key, config key, file extension, MIME type
|
|
204
|
+
or protocol name. A comparison with a constant that has no i/I (`"png"`) is
|
|
205
|
+
the same in every locale and is not reported. Everything else is **low**.
|
|
206
|
+
Details: [docs/rules.md](https://gitlab.com/rehimovv/dotless/-/blob/main/docs/rules.md).
|
|
207
|
+
|
|
208
|
+
## Languages and rules
|
|
209
|
+
|
|
210
|
+
| Rule | Language | Flagged (locale-dependent) | Not flagged (measured safe) |
|
|
211
|
+
|---|---|---|---|
|
|
212
|
+
| DL001 | C, Rust `libc::` | `tolower`, `toupper` | `g_ascii_tolower`, `g_ascii_toupper` |
|
|
213
|
+
| DL002 | C, Rust `libc::` | `strcasecmp`, `strncasecmp`, `strcasestr`, `wcscasecmp` | `g_ascii_strcasecmp`, `g_ascii_strncasecmp` |
|
|
214
|
+
| DL003 | C | `towlower`, `towupper` | `g_unichar_tolower`, `g_unichar_toupper` |
|
|
215
|
+
| DL004 | C, Python, gjs, Rust `glib::ffi` | `g_utf8_strdown`, `g_utf8_strup` | `g_ascii_strdown`, `g_utf8_casefold` |
|
|
216
|
+
| DL005 | C | `g_strdown`, `g_strup`, `g_strcasecmp`, `g_strncasecmp` (deprecated) | |
|
|
217
|
+
| DL006 | Vala | `string.down()`, `string.up()` | `ascii_down()`, `ascii_up()`, `casefold()` |
|
|
218
|
+
| DL007 | JavaScript (gjs) | `toLocaleLowerCase()`, `toLocaleUpperCase()` without a locale | `toLowerCase()`, `toUpperCase()` |
|
|
219
|
+
| DL008 | JavaScript | `localeCompare(..., {sensitivity: 'base'}) === 0` | `localeCompare` for sorting |
|
|
220
|
+
| DL009 | Python | `re.IGNORECASE \| re.LOCALE` | `str.lower()`, `str.upper()`, `str.casefold()`, `re.IGNORECASE` |
|
|
221
|
+
| — | Rust | | `to_lowercase()`, `to_uppercase()`, `to_ascii_lowercase()` |
|
|
222
|
+
|
|
223
|
+
## Bugs found
|
|
224
|
+
|
|
225
|
+
* evolution-data-server: IMAP `NIL` detection and the NTLM LanManager hash
|
|
226
|
+
use `toupper()`, so a lower-case `nil` is rejected and the LM response
|
|
227
|
+
differs for passwords containing `i` under `tr_TR`/`az_AZ`
|
|
228
|
+
([#677](https://gitlab.gnome.org/GNOME/evolution-data-server/-/issues/677), reported).
|
|
229
|
+
|
|
230
|
+
A scan of ten GNOME modules and the reproducers behind this report are in
|
|
231
|
+
[reports/](https://gitlab.com/rehimovv/dotless/-/blob/main/reports/README.md) and [reproducers/](https://gitlab.com/rehimovv/dotless/-/tree/main/reproducers).
|
|
232
|
+
|
|
233
|
+
## Contributing
|
|
234
|
+
|
|
235
|
+
See [CONTRIBUTING.md](https://gitlab.com/rehimovv/dotless/-/blob/main/CONTRIBUTING.md). False positives are bugs; please
|
|
236
|
+
report them with the code that triggered them.
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
GPL-3.0-or-later.
|
|
241
|
+
|
|
242
|
+
## Türkçe
|
|
243
|
+
|
|
244
|
+
dotless, büyük/küçük harf dönüşümünü sistem diline göre yapıp sonucu ASCII
|
|
245
|
+
bir anahtar kelimeyle karşılaştıran kodu bulur. Bu tür kod `tr_TR` ve
|
|
246
|
+
`az_AZ` yerellerinde bozulur: `"INTEGER"` küçültülünce `"ınteger"` olur,
|
|
247
|
+
`strcasecmp("INI", "ini")` eşit dönmez.
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
pipx install dotless
|
|
251
|
+
dotless scan <dizin>
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Ayarlar `pyproject.toml` içindeki `[tool.dotless]` tablosundan okunur
|
|
255
|
+
(Python 3.11 ve üstü); pre-commit kancası ve GitLab/GitHub CI örnekleri
|
|
256
|
+
yukarıdadır. Bir bulguyu susturmak için satıra `dotless: ignore` yorumu
|
|
257
|
+
eklenir. Hangi fonksiyonun gerçekten yerele bağlı olduğu
|
|
258
|
+
[docs/research.md](https://gitlab.com/rehimovv/dotless/-/blob/main/docs/research.md) dosyasında ölçüm sonuçlarıyla
|
|
259
|
+
belgelenmiştir.
|
|
260
|
+
|
|
261
|
+
## Azərbaycanca
|
|
262
|
+
|
|
263
|
+
dotless böyük/kiçik hərf çevirməsini sistemin dilinə görə edib nəticəni
|
|
264
|
+
ASCII açar sözlə müqayisə edən kodu tapır. Belə kod `az_AZ` və `tr_TR`
|
|
265
|
+
lokallarında səhv işləyir: `"INTEGER"` kiçildiləndə `"ınteger"` olur,
|
|
266
|
+
`strcasecmp("INI", "ini")` bərabər qaytarmır.
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
pipx install dotless
|
|
270
|
+
dotless scan <qovluq>
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Parametrlər `pyproject.toml` faylındakı `[tool.dotless]` cədvəlindən oxunur
|
|
274
|
+
(Python 3.11 və daha yeni); pre-commit və GitLab/GitHub CI nümunələri
|
|
275
|
+
yuxarıdadır. Tapıntını susdurmaq üçün sətrə `dotless: ignore` şərhi
|
|
276
|
+
əlavə olunur. Hansı funksiyanın həqiqətən lokaldan asılı olduğu
|
|
277
|
+
[docs/research.md](https://gitlab.com/rehimovv/dotless/-/blob/main/docs/research.md) faylında ölçmə nəticələri ilə
|
|
278
|
+
sənədləşdirilib.
|