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

bleachbit / bleachbit / 21755992703

06 Feb 2026 03:28PM UTC coverage: 71.843% (+0.1%) from 71.699%
21755992703

push

github

az0
Do not shred hard links

Before, files that were hard links were shredded
and truncated. Now, the file contents are preserved.
The link pathname is still shredded and removed.

- Fix: do not shred contents of hard links.
- Add cross-platform support for hard-link test.
- In test, expect contents are untouched for hard
  links.

2 of 2 new or added lines in 1 file covered. (100.0%)

127 existing lines in 7 files now uncovered.

6246 of 8694 relevant lines covered (71.84%)

0.72 hits per line

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

88.68
/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
    share_dir = bleachbit.get_share_dir()
1✔
118
    if share_dir:
1✔
119
        xml_path = os.path.join(share_dir, 'protected_path.xml')
1✔
120
        if os.path.exists(xml_path):
1✔
121
            return xml_path
1✔
122

UNCOV
123
    return None
×
124

125

126
def load_protected_paths(force_reload=False):
1✔
127
    """Load protected path definitions from XML.
128

129
    Returns a list of dictionaries with keys:
130
        - path: The expanded, normalized path
131
        - depth: How many levels deep to protect (0=exact, 1=children, etc.)
132
        - case_sensitive: Whether matching should be case-sensitive
133

134
    Args:
135
        force_reload: If True, reload from XML even if cached
136
    """
137
    global _protected_paths_cache
138

139
    if _protected_paths_cache is not None and not force_reload:
1✔
140
        return _protected_paths_cache
1✔
141

142
    xml_path = _get_protected_path_xml()
1✔
143
    if xml_path is None:
1✔
UNCOV
144
        logger.warning("Protected path XML file not found")
×
UNCOV
145
        return []
×
146

147
    protected_paths = []
1✔
148

149
    try:
1✔
150
        dom = xml.dom.minidom.parse(xml_path)
1✔
UNCOV
151
    except Exception as e:
×
UNCOV
152
        logger.error("Error parsing protected path XML: %s", e)
×
UNCOV
153
        return []
×
154

155
    for paths_node in dom.getElementsByTagName('paths'):
1✔
156
        # Check OS match for this <paths> group
157
        os_attr = paths_node.getAttribute('os') or ''
1✔
158
        if not os_match(os_attr):
1✔
159
            continue
1✔
160

161
        for path_node in paths_node.getElementsByTagName('path'):
1✔
162
            # Get path attributes (inherit from parent <paths> when omitted)
163
            depth_attr = (path_node.getAttribute('depth') or
1✔
164
                          paths_node.getAttribute('depth') or '0')
165
            if depth_attr == 'any':
1✔
166
                depth = None
1✔
167
            else:
168
                try:
1✔
169
                    depth = int(depth_attr)
1✔
UNCOV
170
                except ValueError:
×
UNCOV
171
                    depth = 0
×
172

173
            case_attr = (path_node.getAttribute('case') or
1✔
174
                         paths_node.getAttribute('case') or '')
175
            if case_attr == 'insensitive':
1✔
176
                case_sensitive = False
1✔
177
            elif case_attr == 'sensitive':
1✔
UNCOV
178
                case_sensitive = True
×
179
            else:
180
                # Default: Windows is case-insensitive, others are case-sensitive
181
                case_sensitive = os.name != 'nt'
1✔
182

183
            # Get the path text
184
            raw_path = getText(path_node.childNodes).strip()
1✔
185
            if not raw_path:
1✔
186
                continue
×
187

188
            # Expand the path (possibly into multiple entries)
189
            for expanded_path in _expand_path_entries(raw_path):
1✔
190
                protected_paths.append({
1✔
191
                    'path': expanded_path,
192
                    'depth': depth,
193
                    'case_sensitive': case_sensitive,
194
                })
195

196
    _protected_paths_cache = protected_paths
1✔
197
    logger.debug("Loaded %d protected paths", len(protected_paths))
1✔
198
    return protected_paths
1✔
199

200

201
def _check_exempt(user_path):
1✔
202
    """Check if path is exempt from protection
203

204
    For ignoring paths like .git under ~/.cache/
205
    """
206
    assert isinstance(user_path, str)
1✔
207
    exempt_paths = ('~/.cache', '%temp%', '%tmp%', '/tmp')
1✔
208
    case_sensitive = os.name != 'nt'
1✔
209
    user_path_normalized = _normalize_for_comparison(
1✔
210
        user_path, case_sensitive=case_sensitive)
211
    for path in exempt_paths:
1✔
212
        exempt_expanded = _expand_path(path)
1✔
213
        if not exempt_expanded:
1✔
UNCOV
214
            continue
×
215

216
        exempt_normalized = _normalize_for_comparison(
1✔
217
            exempt_expanded, case_sensitive=case_sensitive)
218

219
        if user_path_normalized == exempt_normalized:
1✔
220
            return True
1✔
221

222
        exempt_with_sep = exempt_normalized + os.sep
1✔
223
        if user_path_normalized.startswith(exempt_with_sep):
1✔
224
            return True
1✔
225
    return False
1✔
226

227

228
def check_protected_path(user_path):
1✔
229
    """Check if a user path matches a protected path.
230

231
    Args:
232
        user_path: The path the user wants to add to delete list
233

234
    Returns:
235
        A dictionary with match info if protected, None otherwise:
236
        - protected_path: The matched protected path
237
        - depth: The depth of the protection
238
        - case_sensitive: Whether the match was case-sensitive
239
    """
240
    if _check_exempt(user_path):
1✔
241
        return None
1✔
242
    protected_paths = load_protected_paths()
1✔
243
    if not protected_paths:
1✔
UNCOV
244
        return None
×
245

246
    # Normalize the user path
247
    user_path_norm = os.path.normpath(user_path)
1✔
248

249
    for ppath in protected_paths:
1✔
250
        protected = ppath['path']
1✔
251
        depth = ppath['depth']
1✔
252
        case_sensitive = ppath['case_sensitive']
1✔
253

254
        # Normalize both paths for comparison
255
        if case_sensitive:
1✔
256
            user_cmp = user_path_norm
1✔
257
            protected_cmp = protected
1✔
258
        else:
259
            user_cmp = user_path_norm.lower()
1✔
260
            protected_cmp = protected.lower()
1✔
261

262
        protected_is_absolute = os.path.isabs(ppath['path'])
1✔
263
        if not protected_is_absolute:
1✔
264
            # Relative protected paths should match when user path ends with them
265
            if user_cmp == protected_cmp:
1✔
UNCOV
266
                return ppath
×
267
            relative_suffix = os.sep + protected_cmp.lstrip(os.sep)
1✔
268
            if user_cmp.endswith(relative_suffix):
1✔
269
                return ppath
1✔
270
            continue
1✔
271

272
        # Exact match
273
        if user_cmp == protected_cmp:
1✔
274
            return ppath
1✔
275

276
        # Check if user path is a parent of protected path
277
        # (user wants to delete a folder that contains protected items)
278
        protected_with_sep = protected_cmp + os.sep
1✔
279
        user_with_sep = user_cmp + os.sep
1✔
280
        if protected_cmp.startswith(user_with_sep):
1✔
281
            return ppath
1✔
282

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

293
    return None
1✔
294

295

296
def calculate_impact(path):
1✔
297
    """Calculate the impact of deleting a path.
298

299
    Args:
300
        path: The path to calculate impact for
301

302
    Returns:
303
        A dictionary with:
304
        - file_count: Number of files
305
        - total_size: Total size in bytes
306
        - size_human: Human-readable size string
307
    """
308
    if not os.path.exists(path):
1✔
309
        return {
1✔
310
            'file_count': 0,
311
            'total_size': 0,
312
            'size_human': '0B',
313
        }
314

315
    file_count = 0
1✔
316
    total_size = 0
1✔
317

318
    try:
1✔
319
        if os.path.isfile(path):
1✔
320
            file_count = 1
1✔
321
            total_size = FileUtilities.getsize(path)
1✔
322
        elif os.path.isdir(path):
1✔
323
            for child in FileUtilities.children_in_directory(path, list_directories=False):
1✔
324
                file_count += 1
1✔
325
                try:
1✔
326
                    total_size += FileUtilities.getsize(child)
1✔
UNCOV
327
                except (OSError, PermissionError):
×
UNCOV
328
                    pass
×
UNCOV
329
    except (OSError, PermissionError) as e:
×
UNCOV
330
        logger.debug("Error calculating impact for %s: %s", path, e)
×
331

332
    return {
1✔
333
        'file_count': file_count,
334
        'total_size': total_size,
335
        'size_human': FileUtilities.bytes_to_human(total_size),
336
    }
337

338

339
def get_warning_message(user_path, impact):
1✔
340
    """Generate a warning message for a protected path.
341

342
    Args:
343
        user_path: The path the user wants to add
344
        impact: The impact info from calculate_impact
345

346
    Returns:
347
        A formatted warning message string
348
    """
349

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

367
    return msg
1✔
368

369

370
def clear_cache():
1✔
371
    """Clear the protected paths cache."""
372
    global _protected_paths_cache
373
    _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