• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bleachbit / bleachbit / 22514799245

28 Feb 2026 06:00AM UTC coverage: 71.396% (-0.5%) from 71.867%
22514799245

push

github

az0
Add fail messages

6345 of 8887 relevant lines covered (71.4%)

0.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.96
/bleachbit/ProtectedPath.py
1
# vim: ts=4:sw=4:expandtab
2
# -*- coding: UTF-8 -*-
3

4
# BleachBit
5
# Copyright (C) 2008-2025 Andrew Ziem
6
# https://www.bleachbit.org
7
#
8
# This program is free software: you can redistribute it and/or modify
9
# it under the terms of the GNU General Public License as published by
10
# the Free Software Foundation, either version 3 of the License, or
11
# (at your option) any later version.
12
#
13
# This program is distributed in the hope that it will be useful,
14
# but WITHOUT ANY WARRANTY; without even the implied warranty of
15
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16
# GNU General Public License for more details.
17
#
18
# You should have received a copy of the GNU General Public License
19
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
20

21
"""
22
The protected path warning system is a safety net
23

24
This module loads protected path definitions from XML and checks whether
25
user-specified paths match protected paths, warning users before they
26
accidentally delete important system or application files.
27
"""
28

29
import logging
1✔
30
import os
1✔
31
import re
1✔
32
import xml.dom.minidom
1✔
33

34
import bleachbit
1✔
35
from bleachbit import FileUtilities
1✔
36
from bleachbit.General import getText, os_match
1✔
37
from bleachbit.Language import get_text as _
1✔
38

39
logger = logging.getLogger(__name__)
1✔
40

41
# Cache for loaded protected paths
42
_protected_paths_cache = None
1✔
43

44
_ENV_VAR_ONLY_PATTERN = re.compile(
1✔
45
    r"""
46
    ^
47
    (?:
48
        \$(?P<unix>[A-Za-z_][A-Za-z0-9_]*)
49
        |
50
        \${(?P<brace>[A-Za-z_][A-Za-z0-9_]*)}
51
        |
52
        %(?P<windows>[A-Za-z_][A-Za-z0-9_]*)%
53
    )
54
    $
55
    """,
56
    re.VERBOSE,
57
)
58

59

60
def _expand_path(raw_path):
1✔
61
    """Expand environment variables, user home, and normalize path.
62

63
    Returns one string.
64
    """
65
    assert isinstance(raw_path, str)
1✔
66
    # Expand user home (~)
67
    path = os.path.expanduser(raw_path)
1✔
68
    # Expand environment variables
69
    path = os.path.expandvars(path)
1✔
70
    # Normalize path separators
71
    if path:
1✔
72
        path = os.path.normpath(path)
1✔
73
    return path
1✔
74

75

76
def _expand_path_entries(raw_path):
1✔
77
    """Expand a raw path into one or more normalized paths.
78

79
    Handles environment variables whose values are lists separated by
80
    os.pathsep (e.g., XDG_DATA_DIRS=/usr/share:/usr/local/share) by
81
    returning a separate entry per value when the raw path consists of
82
    the environment variable placeholder alone.
83
    """
84

85
    expanded = _expand_path(raw_path)
1✔
86
    if not expanded:
1✔
87
        return tuple()
×
88

89
    if (_ENV_VAR_ONLY_PATTERN.match(raw_path)
1✔
90
            and os.pathsep in expanded):
91
        candidates = [segment.strip()
1✔
92
                      for segment in expanded.split(os.pathsep)]
93
        normalized = [os.path.normpath(segment)
1✔
94
                      for segment in candidates if segment]
95
        if normalized:
1✔
96
            return tuple(normalized)
1✔
97

98
    return (expanded,)
1✔
99

100

101
# this function is used in GuiPreferences.py
102
def _normalize_for_comparison(path, case_sensitive):
1✔
103
    """Normalize a path for comparison.
104

105
    Args:
106
        path: The path to normalize
107
        case_sensitive: If False, convert to lowercase for comparison
108
    """
109
    normalized = os.path.normpath(path)
1✔
110
    if not case_sensitive:
1✔
111
        normalized = normalized.lower()
1✔
112
    return normalized
1✔
113

114

115
def _get_protected_path_xml():
1✔
116
    """Return the path to the protected_path.xml file."""
117
    return bleachbit.get_share_path('protected_path.xml')
1✔
118

119

120
def load_protected_paths(force_reload=False):
1✔
121
    """Load protected path definitions from XML.
122

123
    Returns a list of dictionaries with keys:
124
        - path: The expanded, normalized path
125
        - depth: How many levels deep to protect (0=exact, 1=children, etc.)
126
        - case_sensitive: Whether matching should be case-sensitive
127

128
    Args:
129
        force_reload: If True, reload from XML even if cached
130
    """
131
    global _protected_paths_cache
132

133
    if _protected_paths_cache is not None and not force_reload:
1✔
134
        return _protected_paths_cache
1✔
135

136
    xml_path = _get_protected_path_xml()
1✔
137
    if xml_path is None:
1✔
138
        logger.warning("Protected path XML file not found")
×
139
        return []
×
140

141
    protected_paths = []
1✔
142

143
    try:
1✔
144
        dom = xml.dom.minidom.parse(xml_path)
1✔
145
    except Exception as e:
×
146
        logger.error("Error parsing protected path XML: %s", e)
×
147
        return []
×
148

149
    for paths_node in dom.getElementsByTagName('paths'):
1✔
150
        # Check OS match for this <paths> group
151
        os_attr = paths_node.getAttribute('os') or ''
1✔
152
        if not os_match(os_attr):
1✔
153
            continue
1✔
154

155
        for path_node in paths_node.getElementsByTagName('path'):
1✔
156
            # Get path attributes (inherit from parent <paths> when omitted)
157
            depth_attr = (path_node.getAttribute('depth') or
1✔
158
                          paths_node.getAttribute('depth') or '0')
159
            if depth_attr == 'any':
1✔
160
                depth = None
1✔
161
            else:
162
                try:
1✔
163
                    depth = int(depth_attr)
1✔
164
                except ValueError:
×
165
                    depth = 0
×
166

167
            case_attr = (path_node.getAttribute('case') or
1✔
168
                         paths_node.getAttribute('case') or '')
169
            if case_attr == 'insensitive':
1✔
170
                case_sensitive = False
1✔
171
            elif case_attr == 'sensitive':
1✔
172
                case_sensitive = True
×
173
            else:
174
                # Default: Windows is case-insensitive, others are case-sensitive
175
                case_sensitive = os.name != 'nt'
1✔
176

177
            # Get the path text
178
            raw_path = getText(path_node.childNodes).strip()
1✔
179
            if not raw_path:
1✔
180
                continue
×
181

182
            # Expand the path (possibly into multiple entries)
183
            for expanded_path in _expand_path_entries(raw_path):
1✔
184
                protected_paths.append({
1✔
185
                    'path': expanded_path,
186
                    'depth': depth,
187
                    'case_sensitive': case_sensitive,
188
                })
189

190
    _protected_paths_cache = protected_paths
1✔
191
    logger.debug("Loaded %d protected paths", len(protected_paths))
1✔
192
    return protected_paths
1✔
193

194

195
def _check_exempt(user_path):
1✔
196
    """Check if path is exempt from protection
197

198
    For ignoring paths like .git under ~/.cache/
199
    """
200
    assert isinstance(user_path, str)
1✔
201
    exempt_paths = ('~/.cache', '%temp%', '%tmp%', '/tmp')
1✔
202
    case_sensitive = os.name != 'nt'
1✔
203
    user_path_normalized = _normalize_for_comparison(
1✔
204
        user_path, case_sensitive=case_sensitive)
205
    for path in exempt_paths:
1✔
206
        exempt_expanded = _expand_path(path)
1✔
207
        if not exempt_expanded:
1✔
208
            continue
×
209

210
        exempt_normalized = _normalize_for_comparison(
1✔
211
            exempt_expanded, case_sensitive=case_sensitive)
212

213
        if user_path_normalized == exempt_normalized:
1✔
214
            return True
1✔
215

216
        exempt_with_sep = exempt_normalized + os.sep
1✔
217
        if user_path_normalized.startswith(exempt_with_sep):
1✔
218
            return True
1✔
219
    return False
1✔
220

221

222
def check_protected_path(user_path):
1✔
223
    """Check if a user path matches a protected path.
224

225
    Args:
226
        user_path: The path the user wants to add to delete list
227

228
    Returns:
229
        A dictionary with match info if protected, None otherwise:
230
        - protected_path: The matched protected path
231
        - depth: The depth of the protection
232
        - case_sensitive: Whether the match was case-sensitive
233
    """
234
    if _check_exempt(user_path):
1✔
235
        return None
1✔
236
    protected_paths = load_protected_paths()
1✔
237
    if not protected_paths:
1✔
238
        return None
×
239

240
    # Normalize the user path
241
    user_path_norm = os.path.normpath(user_path)
1✔
242

243
    for ppath in protected_paths:
1✔
244
        protected = ppath['path']
1✔
245
        depth = ppath['depth']
1✔
246
        case_sensitive = ppath['case_sensitive']
1✔
247

248
        # Normalize both paths for comparison
249
        if case_sensitive:
1✔
250
            user_cmp = user_path_norm
1✔
251
            protected_cmp = protected
1✔
252
        else:
253
            user_cmp = user_path_norm.lower()
1✔
254
            protected_cmp = protected.lower()
1✔
255

256
        protected_is_absolute = os.path.isabs(ppath['path'])
1✔
257
        if not protected_is_absolute:
1✔
258
            # Relative protected paths should match when user path ends with them
259
            if user_cmp == protected_cmp:
1✔
260
                return ppath
×
261
            relative_suffix = os.sep + protected_cmp.lstrip(os.sep)
1✔
262
            if user_cmp.endswith(relative_suffix):
1✔
263
                return ppath
1✔
264
            continue
1✔
265

266
        # Exact match
267
        if user_cmp == protected_cmp:
1✔
268
            return ppath
1✔
269

270
        # Check if user path is a parent of protected path
271
        # (user wants to delete a folder that contains protected items)
272
        protected_with_sep = protected_cmp + os.sep
1✔
273
        user_with_sep = user_cmp + os.sep
1✔
274
        if protected_cmp.startswith(user_with_sep):
1✔
275
            return ppath
1✔
276

277
        # Check if user path is a child of protected path (within depth)
278
        if (depth is None or depth > 0) and user_cmp.startswith(protected_with_sep):
1✔
279
            if depth is None:
1✔
280
                return ppath
1✔
281
            # Calculate how many levels deep the user path is
282
            relative = user_cmp[len(protected_with_sep):]
1✔
283
            levels = relative.count(os.sep) + 1
1✔
284
            if levels <= depth:
1✔
285
                return ppath
1✔
286

287
    return None
1✔
288

289

290
def calculate_impact(path):
1✔
291
    """Calculate the impact of deleting a path.
292

293
    Args:
294
        path: The path to calculate impact for
295

296
    Returns:
297
        A dictionary with:
298
        - file_count: Number of files
299
        - total_size: Total size in bytes
300
        - size_human: Human-readable size string
301
    """
302
    if not os.path.exists(path):
1✔
303
        return {
1✔
304
            'file_count': 0,
305
            'total_size': 0,
306
            'size_human': '0B',
307
        }
308

309
    file_count = 0
1✔
310
    total_size = 0
1✔
311

312
    try:
1✔
313
        if os.path.isfile(path):
1✔
314
            file_count = 1
1✔
315
            total_size = FileUtilities.getsize(path)
1✔
316
        elif os.path.isdir(path):
1✔
317
            for child in FileUtilities.children_in_directory(path, list_directories=False):
1✔
318
                file_count += 1
1✔
319
                try:
1✔
320
                    total_size += FileUtilities.getsize(child)
1✔
321
                except (OSError, PermissionError):
×
322
                    pass
×
323
    except (OSError, PermissionError) as e:
×
324
        logger.debug("Error calculating impact for %s: %s", path, e)
×
325

326
    return {
1✔
327
        'file_count': file_count,
328
        'total_size': total_size,
329
        'size_human': FileUtilities.bytes_to_human(total_size),
330
    }
331

332

333
def get_warning_message(user_path, impact):
1✔
334
    """Generate a warning message for a protected path.
335

336
    Args:
337
        user_path: The path the user wants to add
338
        impact: The impact info from calculate_impact
339

340
    Returns:
341
        A formatted warning message string
342
    """
343

344
    if impact['file_count'] > 0:
1✔
345
        # TRANSLATORS: Warning shown when user tries to add a protected path.
346
        # %(path)s is the path, %(files)d is number of files, %(size)s is human-readable size
347
        msg = _("Warning: '%(path)s' may contain important files.\n\n"
1✔
348
                "Impact: %(files)d file(s), %(size)s\n\n"
349
                "Are you sure you want to add this path?") % {
350
            'path': user_path,
351
            'files': impact['file_count'],
352
            'size': impact['size_human'],
353
        }
354
    else:
355
        # TRANSLATORS: Warning shown when user tries to add a protected path (no files found)
356
        msg = _("Warning: '%(path)s' may contain important files.\n\n"
1✔
357
                "Are you sure you want to add this path?") % {
358
            'path': user_path,
359
        }
360

361
    return msg
1✔
362

363

364
def clear_cache():
1✔
365
    """Clear the protected paths cache."""
366
    global _protected_paths_cache
367
    _protected_paths_cache = None
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc