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

bleachbit / bleachbit / 29212803850

12 Jul 2026 11:04PM UTC coverage: 76.137% (+0.002%) from 76.135%
29212803850

push

github

az0
Improve fish, Python, and Zsh cleaners

- Change license header to shorter version (but same license).
- Write name "fish" in lowercase like official sources do.
- Enable Python cleaner on all operating systems
- Support CPython in python.xml
- Add macOS-specific session cleaner for Zsh
- Remove Python description because it was misspelled and
  obvious (to its users), this avoids a translation.
- Document my testing information

8136 of 10686 relevant lines covered (76.14%)

0.76 hits per line

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

87.6
/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, FS_CASE_SENSITIVE
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
logger = logging.getLogger(__name__)
1✔
47

48
# Cache for loaded protected paths
49
_protected_paths_cache = None
1✔
50

51

52
def _get_protected_path_xml():
1✔
53
    """Return the path to the protected_path.xml file."""
54
    return bleachbit.get_share_path('protected_path.xml')
1✔
55

56

57
def load_protected_paths(force_reload=False):
1✔
58
    """Load protected path definitions from XML.
59

60
    Returns a list of dictionaries with keys:
61
        - path: The expanded, normalized path
62
        - depth: How many levels deep to protect (0=exact, 1=children, etc.)
63
        - case_sensitive: Whether matching should be case-sensitive
64

65
    Args:
66
        force_reload: If True, reload from XML even if cached
67
    """
68
    global _protected_paths_cache
69

70
    if _protected_paths_cache is not None and not force_reload:
1✔
71
        return _protected_paths_cache
1✔
72

73
    xml_path = _get_protected_path_xml()
1✔
74
    if xml_path is None:
1✔
75
        logger.warning("Protected path XML file not found")
×
76
        return []
×
77

78
    protected_paths = []
1✔
79

80
    try:
1✔
81
        dom = xml.dom.minidom.parse(xml_path)
1✔
82
    except Exception as e:
×
83
        logger.error("Error parsing protected path XML: %s", e)
×
84
        return []
×
85

86
    for paths_node in dom.getElementsByTagName('paths'):
1✔
87
        # Check OS match for this <paths> group
88
        os_attr = paths_node.getAttribute('os') or ''
1✔
89
        if not os_match(os_attr):
1✔
90
            continue
1✔
91

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

104
            case_attr = (path_node.getAttribute('case') or
1✔
105
                         paths_node.getAttribute('case') or '')
106
            if case_attr == 'insensitive':
1✔
107
                case_sensitive = False
1✔
108
            elif case_attr == 'sensitive':
1✔
109
                case_sensitive = True
×
110
            else:
111
                case_sensitive = FS_CASE_SENSITIVE
1✔
112

113
            # Get the path text
114
            raw_path = getText(path_node.childNodes).strip()
1✔
115
            if not raw_path:
1✔
116
                continue
×
117

118
            # Expand the path (possibly into multiple entries)
119
            for expanded_path in expand_path_entries(raw_path):
1✔
120
                protected_paths.append({
1✔
121
                    'path': expanded_path,
122
                    'depth': depth,
123
                    'case_sensitive': case_sensitive,
124
                })
125

126
    _protected_paths_cache = protected_paths
1✔
127
    logger.debug("Loaded %d protected paths", len(protected_paths))
1✔
128
    return protected_paths
1✔
129

130

131
def _check_exempt(user_path):
1✔
132
    """Check if path is exempt from protection
133

134
    For ignoring paths like .git under ~/.cache/
135
    """
136
    assert isinstance(user_path, str)
1✔
137
    exempt_paths = ('~/.cache', '%temp%', '%tmp%', '/tmp')
1✔
138
    user_path_normalized = normalize_path(user_path)
1✔
139
    for path in exempt_paths:
1✔
140
        exempt_expanded = expand_path(path)
1✔
141
        if not exempt_expanded:
1✔
142
            continue
×
143

144
        exempt_normalized = normalize_path(exempt_expanded)
1✔
145

146
        if path_equal(user_path_normalized, exempt_normalized):
1✔
147
            return True
1✔
148

149
        if path_startswith(user_path_normalized, exempt_normalized):
1✔
150
            return True
1✔
151
    return False
1✔
152

153

154
def check_protected_path(user_path):
1✔
155
    """Check if a user path matches a protected path.
156

157
    Args:
158
        user_path: The path the user wants to add to delete list
159

160
    Returns:
161
        A dictionary with match info if protected, None otherwise:
162
        - protected_path: The matched protected path
163
        - depth: The depth of the protection
164
        - case_sensitive: Whether the match was case-sensitive
165
    """
166
    if _check_exempt(user_path):
1✔
167
        return None
1✔
168
    protected_paths = load_protected_paths()
1✔
169
    if not protected_paths:
1✔
170
        return None
×
171

172
    for ppath in protected_paths:
1✔
173
        protected = ppath['path']
1✔
174
        depth = ppath['depth']
1✔
175
        case_sensitive = ppath['case_sensitive']
1✔
176
        user_cmp = normalize_path(user_path, case_sensitive=case_sensitive)
1✔
177
        protected_cmp = normalize_path(
1✔
178
            protected, case_sensitive=case_sensitive)
179

180
        protected_is_absolute = os.path.isabs(ppath['path'])
1✔
181
        if not protected_is_absolute:
1✔
182
            # Relative protected paths should match when user path ends with them
183
            if path_has_relative_suffix(user_cmp, protected_cmp,
1✔
184
                                        case_sensitive=case_sensitive):
185
                return ppath
1✔
186
            continue
1✔
187

188
        # Exact match
189
        if path_equal(user_cmp, protected_cmp, case_sensitive=case_sensitive):
1✔
190
            return ppath
1✔
191

192
        # Check if user path is a parent of protected path
193
        # (user wants to delete a folder that contains protected items)
194
        if path_startswith(protected_cmp, user_cmp,
1✔
195
                           case_sensitive=case_sensitive):
196
            return ppath
1✔
197

198
        # Check if user path is a child of protected path (within depth)
199
        if ((depth is None or depth > 0)
1✔
200
                and path_startswith(user_cmp, protected_cmp,
201
                                    case_sensitive=case_sensitive)):
202
            if depth is None:
1✔
203
                return ppath
1✔
204
            # Calculate how many levels deep the user path is
205
            protected_with_sep = protected_cmp + os.sep
1✔
206
            relative = user_cmp[len(protected_with_sep):]
1✔
207
            levels = relative.count(os.sep) + 1
1✔
208
            if levels <= depth:
1✔
209
                return ppath
1✔
210

211
    return None
1✔
212

213

214
def calculate_impact(path):
1✔
215
    """Calculate the impact of deleting a path.
216

217
    Args:
218
        path: The path to calculate impact for
219

220
    Returns:
221
        A dictionary with:
222
        - file_count: Number of files
223
        - total_size: Total size in bytes
224
        - size_human: Human-readable size string
225
    """
226
    if not os.path.exists(path):
1✔
227
        return {
1✔
228
            'file_count': 0,
229
            'total_size': 0,
230
            'size_human': '0B',
231
        }
232

233
    file_count = 0
1✔
234
    total_size = 0
1✔
235

236
    try:
1✔
237
        if os.path.isfile(path):
1✔
238
            file_count = 1
1✔
239
            total_size = FileUtilities.getsize(path)
1✔
240
        elif os.path.isdir(path):
1✔
241
            for child in FileUtilities.children_in_directory(path, list_directories=False):
1✔
242
                file_count += 1
1✔
243
                try:
1✔
244
                    total_size += FileUtilities.getsize(child)
1✔
245
                except (OSError, PermissionError):
×
246
                    pass
×
247
    except (OSError, PermissionError) as e:
×
248
        logger.debug("Error calculating impact for %s: %s", path, e)
×
249

250
    return {
1✔
251
        'file_count': file_count,
252
        'total_size': total_size,
253
        'size_human': FileUtilities.bytes_to_human(total_size),
254
    }
255

256

257
def get_warning_message(user_path, impact):
1✔
258
    """Generate a warning message for a protected path.
259

260
    Args:
261
        user_path: The path the user wants to add
262
        impact: The impact info from calculate_impact
263

264
    Returns:
265
        A formatted warning message string
266
    """
267

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

291
    return msg
1✔
292

293

294
def clear_cache():
1✔
295
    """Clear the protected paths cache."""
296
    global _protected_paths_cache
297
    _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