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

bleachbit / bleachbit / 29196044324

12 Jul 2026 02:19PM UTC coverage: 76.152% (-0.01%) from 76.166%
29196044324

push

github

az0
Remove httpbin.org from tests

Is sometimes takes 20-30 second, or it may return 502

8133 of 10680 relevant lines covered (76.15%)

0.76 hits per line

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

87.7
/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 xml.dom.minidom
1✔
32

33
import bleachbit
1✔
34
from bleachbit import FileUtilities, IS_WINDOWS
1✔
35
from bleachbit.General import getText, os_match
1✔
36
from bleachbit.Language import get_text as _
1✔
37
from bleachbit.PathUtils import (
1✔
38
    expand_path,
39
    expand_path_entries,
40
    normalize_path,
41
    path_equal,
42
    path_has_relative_suffix,
43
    path_startswith,
44
)
45

46
# Default: Windows is case-insensitive, others are case-sensitive.
47
# Do not yet use bleachbit.FS_CASE_SENSITIVE here: macOS will be addressed
48
# in a future change.
49
PP_CASE_SENSITIVE = not IS_WINDOWS
1✔
50

51
logger = logging.getLogger(__name__)
1✔
52

53
# Cache for loaded protected paths
54
_protected_paths_cache = None
1✔
55

56

57
def _get_protected_path_xml():
1✔
58
    """Return the path to the protected_path.xml file."""
59
    return bleachbit.get_share_path('protected_path.xml')
1✔
60

61

62
def load_protected_paths(force_reload=False):
1✔
63
    """Load protected path definitions from XML.
64

65
    Returns a list of dictionaries with keys:
66
        - path: The expanded, normalized path
67
        - depth: How many levels deep to protect (0=exact, 1=children, etc.)
68
        - case_sensitive: Whether matching should be case-sensitive
69

70
    Args:
71
        force_reload: If True, reload from XML even if cached
72
    """
73
    global _protected_paths_cache
74

75
    if _protected_paths_cache is not None and not force_reload:
1✔
76
        return _protected_paths_cache
1✔
77

78
    xml_path = _get_protected_path_xml()
1✔
79
    if xml_path is None:
1✔
80
        logger.warning("Protected path XML file not found")
×
81
        return []
×
82

83
    protected_paths = []
1✔
84

85
    try:
1✔
86
        dom = xml.dom.minidom.parse(xml_path)
1✔
87
    except Exception as e:
×
88
        logger.error("Error parsing protected path XML: %s", e)
×
89
        return []
×
90

91
    for paths_node in dom.getElementsByTagName('paths'):
1✔
92
        # Check OS match for this <paths> group
93
        os_attr = paths_node.getAttribute('os') or ''
1✔
94
        if not os_match(os_attr):
1✔
95
            continue
1✔
96

97
        for path_node in paths_node.getElementsByTagName('path'):
1✔
98
            # Get path attributes (inherit from parent <paths> when omitted)
99
            depth_attr = (path_node.getAttribute('depth') or
1✔
100
                          paths_node.getAttribute('depth') or '0')
101
            if depth_attr == 'any':
1✔
102
                depth = None
1✔
103
            else:
104
                try:
1✔
105
                    depth = int(depth_attr)
1✔
106
                except ValueError:
×
107
                    depth = 0
×
108

109
            case_attr = (path_node.getAttribute('case') or
1✔
110
                         paths_node.getAttribute('case') or '')
111
            if case_attr == 'insensitive':
1✔
112
                case_sensitive = False
1✔
113
            elif case_attr == 'sensitive':
1✔
114
                case_sensitive = True
×
115
            else:
116
                case_sensitive = PP_CASE_SENSITIVE
1✔
117

118
            # Get the path text
119
            raw_path = getText(path_node.childNodes).strip()
1✔
120
            if not raw_path:
1✔
121
                continue
×
122

123
            # Expand the path (possibly into multiple entries)
124
            for expanded_path in expand_path_entries(raw_path):
1✔
125
                protected_paths.append({
1✔
126
                    'path': expanded_path,
127
                    'depth': depth,
128
                    'case_sensitive': case_sensitive,
129
                })
130

131
    _protected_paths_cache = protected_paths
1✔
132
    logger.debug("Loaded %d protected paths", len(protected_paths))
1✔
133
    return protected_paths
1✔
134

135

136
def _check_exempt(user_path):
1✔
137
    """Check if path is exempt from protection
138

139
    For ignoring paths like .git under ~/.cache/
140
    """
141
    assert isinstance(user_path, str)
1✔
142
    exempt_paths = ('~/.cache', '%temp%', '%tmp%', '/tmp')
1✔
143
    user_path_normalized = normalize_path(
1✔
144
        user_path, case_sensitive=PP_CASE_SENSITIVE)
145
    for path in exempt_paths:
1✔
146
        exempt_expanded = expand_path(path)
1✔
147
        if not exempt_expanded:
1✔
148
            continue
×
149

150
        exempt_normalized = normalize_path(
1✔
151
            exempt_expanded, case_sensitive=PP_CASE_SENSITIVE)
152

153
        if path_equal(user_path_normalized, exempt_normalized,
1✔
154
                      case_sensitive=PP_CASE_SENSITIVE):
155
            return True
1✔
156

157
        if path_startswith(user_path_normalized, exempt_normalized,
1✔
158
                           case_sensitive=PP_CASE_SENSITIVE):
159
            return True
1✔
160
    return False
1✔
161

162

163
def check_protected_path(user_path):
1✔
164
    """Check if a user path matches a protected path.
165

166
    Args:
167
        user_path: The path the user wants to add to delete list
168

169
    Returns:
170
        A dictionary with match info if protected, None otherwise:
171
        - protected_path: The matched protected path
172
        - depth: The depth of the protection
173
        - case_sensitive: Whether the match was case-sensitive
174
    """
175
    if _check_exempt(user_path):
1✔
176
        return None
1✔
177
    protected_paths = load_protected_paths()
1✔
178
    if not protected_paths:
1✔
179
        return None
×
180

181
    for ppath in protected_paths:
1✔
182
        protected = ppath['path']
1✔
183
        depth = ppath['depth']
1✔
184
        case_sensitive = ppath['case_sensitive']
1✔
185
        user_cmp = normalize_path(user_path, case_sensitive=case_sensitive)
1✔
186
        protected_cmp = normalize_path(
1✔
187
            protected, case_sensitive=case_sensitive)
188

189
        protected_is_absolute = os.path.isabs(ppath['path'])
1✔
190
        if not protected_is_absolute:
1✔
191
            # Relative protected paths should match when user path ends with them
192
            if path_has_relative_suffix(user_cmp, protected_cmp,
1✔
193
                                        case_sensitive=case_sensitive):
194
                return ppath
1✔
195
            continue
1✔
196

197
        # Exact match
198
        if path_equal(user_cmp, protected_cmp, case_sensitive=case_sensitive):
1✔
199
            return ppath
1✔
200

201
        # Check if user path is a parent of protected path
202
        # (user wants to delete a folder that contains protected items)
203
        if path_startswith(protected_cmp, user_cmp,
1✔
204
                           case_sensitive=case_sensitive):
205
            return ppath
1✔
206

207
        # Check if user path is a child of protected path (within depth)
208
        if ((depth is None or depth > 0)
1✔
209
                and path_startswith(user_cmp, protected_cmp,
210
                                    case_sensitive=case_sensitive)):
211
            if depth is None:
1✔
212
                return ppath
1✔
213
            # Calculate how many levels deep the user path is
214
            protected_with_sep = protected_cmp + os.sep
1✔
215
            relative = user_cmp[len(protected_with_sep):]
1✔
216
            levels = relative.count(os.sep) + 1
1✔
217
            if levels <= depth:
1✔
218
                return ppath
1✔
219

220
    return None
1✔
221

222

223
def calculate_impact(path):
1✔
224
    """Calculate the impact of deleting a path.
225

226
    Args:
227
        path: The path to calculate impact for
228

229
    Returns:
230
        A dictionary with:
231
        - file_count: Number of files
232
        - total_size: Total size in bytes
233
        - size_human: Human-readable size string
234
    """
235
    if not os.path.exists(path):
1✔
236
        return {
1✔
237
            'file_count': 0,
238
            'total_size': 0,
239
            'size_human': '0B',
240
        }
241

242
    file_count = 0
1✔
243
    total_size = 0
1✔
244

245
    try:
1✔
246
        if os.path.isfile(path):
1✔
247
            file_count = 1
1✔
248
            total_size = FileUtilities.getsize(path)
1✔
249
        elif os.path.isdir(path):
1✔
250
            for child in FileUtilities.children_in_directory(path, list_directories=False):
1✔
251
                file_count += 1
1✔
252
                try:
1✔
253
                    total_size += FileUtilities.getsize(child)
1✔
254
                except (OSError, PermissionError):
×
255
                    pass
×
256
    except (OSError, PermissionError) as e:
×
257
        logger.debug("Error calculating impact for %s: %s", path, e)
×
258

259
    return {
1✔
260
        'file_count': file_count,
261
        'total_size': total_size,
262
        'size_human': FileUtilities.bytes_to_human(total_size),
263
    }
264

265

266
def get_warning_message(user_path, impact):
1✔
267
    """Generate a warning message for a protected path.
268

269
    Args:
270
        user_path: The path the user wants to add
271
        impact: The impact info from calculate_impact
272

273
    Returns:
274
        A formatted warning message string
275
    """
276

277
    if impact['file_count'] > 0:
1✔
278
        # TRANSLATORS: Warning shown when user tries to add a protected path.
279
        # %(path)s is the path, %(files)d is number of files, %(size)s is human-readable size
280
        # Do not translate the placeholders.
281
        # Adapt quotation marks around the path placeholder to the typographic conventions of
282
        # your language.
283
        msg = _("Warning: '%(path)s' may contain important files.\n\n"
1✔
284
                "Impact: %(files)d file(s), %(size)s\n\n"
285
                "Are you sure you want to add this path?") % {
286
            'path': user_path,
287
            'files': impact['file_count'],
288
            'size': impact['size_human'],
289
        }
290
    else:
291
        # TRANSLATORS: Warning shown when user tries to add a protected path (no files found).
292
        # Do not translate the placeholder %(path)s.
293
        # Adapt quotation marks around the path placeholder to the typographic conventions of
294
        # your language.
295
        msg = _("Warning: '%(path)s' may contain important files.\n\n"
1✔
296
                "Are you sure you want to add this path?") % {
297
            'path': user_path,
298
        }
299

300
    return msg
1✔
301

302

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

© 2026 Coveralls, Inc