tests/TestProtectedPath.py
Rama master · réplica de lectura del repositorio original.
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (c) 2008-2026 Andrew Ziem.
#
# This work is licensed under the terms of the GNU GPL, version 3 or
# later. See the COPYING file in the top-level directory.
"""
Test case for module ProtectedPath
"""
# These tests reach into internals on purpose.
# pylint: disable=protected-access
import os
import tempfile
import unittest
from unittest import mock
from functools import wraps
from tests import common
from tests.TestCleaner import register_all_cleaners
from tests.TestPathUtils import RelativeSuffixAssertions
from bleachbit.ProtectedPath import (
_check_exempt,
_get_protected_path_xml,
calculate_impact,
check_protected_path,
clear_cache,
get_warning_message,
load_protected_paths)
from bleachbit import ProtectedPath as protected_path_module
from bleachbit import get_share_path
from bleachbit import IS_MAC, IS_WINDOWS, IS_POSIX
from bleachbit.PathUtils import expand_path, normalize_path
from bleachbit.Cleaner import backends
CASE_METHODS = (
str.lower,
str.upper,
str.title,
str.swapcase,
lambda x: x)
def requirePPXML(test_func):
"""Decorator to skip test if protected path XML is not found"""
@wraps(test_func)
def wrapper(self):
if not _get_protected_path_xml():
self.skipTest('Protected path XML not found')
return test_func(self)
return wrapper
class ProtectedPathTestCase(RelativeSuffixAssertions, common.BleachbitTestCase):
"""Test case for ProtectedPath module"""
def setUp(self):
"""Call before each test method"""
super().setUp()
clear_cache()
def tearDown(self):
"""Call after each test method"""
super().tearDown()
clear_cache()
def test_expand_path(self):
"""Test expand_path function"""
# Test user home expansion
home = os.path.expanduser('~')
result = expand_path('~')
self.assertEqual(result, home)
# Test with subpath
result = expand_path('~/Documents')
expected = os.path.normpath(os.path.join(home, 'Documents'))
self.assertEqual(result, expected)
# Test environment variable expansion
with common.set_temporary_env('TEST_PROTECTED_PATH_VAR', '/test/path'):
if IS_WINDOWS:
result = expand_path('%TEST_PROTECTED_PATH_VAR%')
else:
result = expand_path('$TEST_PROTECTED_PATH_VAR')
self.assertEqual(result, os.path.normpath('/test/path'))
def test_normalize_path(self):
"""Test normalize_path function"""
# Case sensitive
result = normalize_path('/Home/User', True)
self.assertIn('Home', result)
# Case insensitive
result = normalize_path('/Home/User', False)
self.assertEqual(result, result.lower())
def test_path_has_relative_suffix(self):
"""Test path-component matching for relative protected paths"""
self.assert_relative_suffix_matches_components()
def test_get_protected_path_xml(self):
"""Test that protected path XML file can be found"""
xml_path = get_share_path('protected_path.xml')
self.assertIsNotNone(
xml_path, 'protected_path.xml not found via get_share_path')
self.assertExists(xml_path)
xml_path2 = _get_protected_path_xml()
self.assertIsNotNone(xml_path2, 'Protected path XML not found')
self.assertExists(xml_path2)
self.assertTrue(xml_path2.endswith('protected_path.xml'))
@requirePPXML
def test_load_protected_paths(self):
"""Test loading protected paths from XML"""
paths = load_protected_paths()
self.assertIsInstance(paths, list)
# Should have loaded some paths for current OS
self.assertGreater(len(paths), 0)
# Each path should have required keys
for ppath in paths:
self.assertIn('path', ppath)
self.assertIn('depth', ppath)
self.assertIn('case_sensitive', ppath)
self.assertIsInstance(ppath['path'], str)
self.assertTrue(isinstance(
ppath['depth'], int) or ppath['depth'] is None)
self.assertIsInstance(ppath['case_sensitive'], bool)
@requirePPXML
@common.skipIfWindows
def test_expand_path_entries_split_os_pathsep(self):
"""Environment variables with os.pathsep values expand into multiple entries"""
share1 = f"/nonexistent_xdg_share1_{self._testMethodName}"
share2 = f"/nonexistent_xdg_share2_{self._testMethodName}"
expected_paths = [os.path.normpath(share1), os.path.normpath(share2)]
with common.set_temporary_env('XDG_DATA_DIRS', os.pathsep.join(expected_paths)):
clear_cache()
paths = load_protected_paths(force_reload=True)
matches = [pp['path']
for pp in paths if pp['path'] in expected_paths]
self.assertCountEqual(matches, expected_paths)
for candidate in expected_paths:
result = check_protected_path(candidate)
self.assertIsNotNone(result)
clear_cache()
def test_load_protected_paths_rejects_dtd(self):
"""A protected_path.xml with a DTD is rejected (entity-expansion defense)"""
xml_str = (
'<?xml version="1.0"?>\n'
'<!DOCTYPE paths [ <!ENTITY x "y"> ]>\n'
'<paths/>\n')
fn = os.path.join(self.mkdtemp(prefix='bleachbit-protected-path-dtd'),
'protected_path.xml')
self.write_file(fn, text=xml_str)
with mock.patch.object(
protected_path_module, '_get_protected_path_xml',
return_value=fn):
paths = load_protected_paths(force_reload=True)
self.assertEqual(paths, [])
@requirePPXML
def test_load_protected_paths_caching(self):
"""Test that protected paths are cached"""
paths1 = load_protected_paths()
paths2 = load_protected_paths()
# Should be the same object (cached)
self.assertIs(paths1, paths2)
# Force reload should return new list
paths3 = load_protected_paths(force_reload=True)
self.assertIsNot(paths1, paths3)
@common.skipUnlessWindows
def test_check_exempt_windows(self):
"""Test check_exempt() on Windows"""
temp_dir = expand_path('%temp%')
self.assertIsInstance(temp_dir, str)
self.assertGreater(len(temp_dir), 0)
for case_method in CASE_METHODS:
cased_temp_dir = case_method(temp_dir)
self.assertTrue(_check_exempt(cased_temp_dir),
f"Failed for {cased_temp_dir}")
cased_temp_dir += os.sep
self.assertTrue(_check_exempt(cased_temp_dir),
f"Failed for {cased_temp_dir}")
git_dir = os.path.join(temp_dir, '.git')
self.assertTrue(_check_exempt(git_dir),
f"Failed for {git_dir}")
git_dir += os.sep
self.assertTrue(_check_exempt(git_dir),
f"Failed for {git_dir}")
@common.skipIfWindows
def test_check_exempt_unix(self):
"""Test check_exempt() on Unix"""
expect_exempt = (
'~/.cache',
'~/.cache/',
'~/.cache/.git',
'/tmp',
'/tmp/',
'/tmp/.git')
for path in expect_exempt:
expanded_path = expand_path(path)
self.assertTrue(_check_exempt(expanded_path), expanded_path)
# macOS is case-insensitive by default (FS_CASE_SENSITIVE is False
# on macOS), so an upper-cased exempt path is still exempt there.
# Linux is case-sensitive, so an upper-cased path is not exempt.
upper = expanded_path.upper()
if IS_MAC:
self.assertTrue(_check_exempt(upper), upper)
else:
self.assertFalse(_check_exempt(upper), expanded_path)
expect_not_exempt = (
'/',
'/home',
'/home/',
'~/.caches',
'~/.caches/',
'~/.caches/.git',
'~/Documents/',
'/tmps',
'/tmps/',
'/tmps/.git')
for path in expect_not_exempt:
self.assertFalse(_check_exempt(expand_path(path)), path)
def test_check_protected_path_no_match(self):
"""Test check_protected_path with non-protected path"""
# A random temp path should not be protected
with tempfile.TemporaryDirectory() as tmpdir:
result = check_protected_path(tmpdir)
self.assertIsNone(result)
# Check .git (which is protected) under an exempt directory
exempt_dir_raw = None
if IS_POSIX:
exempt_dir_raw = '~/.cache'
elif IS_WINDOWS:
exempt_dir_raw = '%temp%'
else:
self.skipTest("Unsupported OS")
exempt_dir = expand_path(exempt_dir_raw)
self.assertIsInstance(exempt_dir, str)
self.assertTrue(_check_exempt(exempt_dir))
self.assertNotEqual(exempt_dir, exempt_dir_raw)
self.assertGreater(len(exempt_dir), 0)
result = check_protected_path(exempt_dir)
self.assertIsNone(result)
subpath = os.path.join(exempt_dir, '.git')
result = check_protected_path(subpath)
self.assertIsNone(result)
# ~/Documents/Zoom/recording.mp4 is not protected
zoom_path_raw = '~/Documents/Zoom/recording.mp4'
zoom_path = expand_path(zoom_path_raw)
self.assertNotEqual(zoom_path, zoom_path_raw)
result = check_protected_path(zoom_path)
self.assertIsNone(result)
@requirePPXML
def test_check_protected_path_exact_match(self):
"""Test check_protected_path with exact match"""
home = os.path.expanduser('~')
home_result = check_protected_path(home)
documents_path = os.path.expanduser('~/Documents')
documents_result = check_protected_path(documents_path)
for result in (home_result, documents_result):
self.assertIsNotNone(result)
self.assertIn('path', result)
self.assertIn('depth', result)
@requirePPXML
def test_check_protected_path_child_match(self):
"""Test check_protected_path with child of protected path
If /home/foo/.mozilla is protected with depth 1, adding /home/foo/.mozilla/firefox
should also warn because it is a child of the protected directory.
"""
# If home is protected with depth > 0, children should match
paths = load_protected_paths()
# Find a protected path with depth > 0
for ppath in paths:
if ppath['depth'] > 0:
child_path = os.path.join(ppath['path'], 'test_subdir')
result = check_protected_path(child_path)
if result is not None:
self.assertGreater(result['depth'], 0)
break
@requirePPXML
def test_check_protected_path_parent_match(self):
"""Test that parent of protected path triggers warning
If /home/foo/.mozilla is protected with any depth, adding /home/foo
should also warn because it is a parent containing the protected path.
"""
paths = load_protected_paths()
if not paths:
self.skipTest("No protected paths loaded")
# Find a protected path that has a parent directory
for ppath in paths:
protected = ppath['path']
parent = os.path.dirname(protected)
if parent and parent != protected:
result = check_protected_path(parent)
# Parent should match because it contains protected path
if result is not None:
self.assertIsNotNone(result)
break
def test_check_protected_path_root_parent(self):
"""A root that holds a protected folder warns"""
root = os.path.abspath(os.sep)
protected_path_module._protected_paths_cache = [{
'path': os.path.join(root, 'bleachbit_protected'),
'depth': 0,
'case_sensitive': True}]
self.assertIsNotNone(check_protected_path(root))
@requirePPXML
@common.skipUnlessWindows
def test_load_protected_paths_bare_drive(self):
"""%systemdrive% loads as the drive root"""
drive = os.path.expandvars('%systemdrive%')
paths = [ppath['path'] for ppath in load_protected_paths()]
self.assertIn(drive + os.sep, paths)
self.assertNotIn(drive, paths)
@requirePPXML
def test_check_protected_path_git_variations(self):
"""Test that .git entries are detected at various nesting levels"""
git_paths = [
'/home/test/.git',
'/home/test/bleachbit/.git',
'/home/test/projects/bleachbit/.git',
]
for path in git_paths:
result = check_protected_path(path)
self.assertIsNotNone(result, msg=f"Expected .git match for {path}")
self.assertEqual(result['path'], '.git')
@requirePPXML
def test_check_protected_path_git_children(self):
"""Paths inside a .git directory match its depth="any" entry"""
for path in ('/home/test/bleachbit/.git/objects',
'/home/test/bleachbit/.git/objects/pack'):
result = check_protected_path(path)
self.assertIsNotNone(result, msg=f"Expected .git match for {path}")
self.assertEqual(result['path'], '.git')
self.assertIsNone(check_protected_path(
'/home/test/bleachbit/.github/workflows'))
def test_check_protected_path_relative_finite_depth(self):
"""Relative entries protect exactly their configured number of levels."""
for protected in ('.git', os.path.join('metadata', 'store')):
for depth in (0, 1, 2, None):
entry = {
'path': protected, 'depth': depth, 'case_sensitive': True}
protected_path_module._protected_paths_cache = [entry]
base = os.path.join(
os.path.expanduser('~'), 'bleachbit-depth-test', protected)
for levels in range(4):
path = os.path.join(base, *(['child'] * levels))
with self.subTest(
protected=protected, depth=depth, levels=levels):
expected = entry if depth is None or levels <= depth else None
self.assertEqual(check_protected_path(path), expected)
self.assertIsNone(check_protected_path(base + '.old'))
self.assertIsNone(check_protected_path(
os.path.join(base + '.old', 'child')))
@requirePPXML
def test_check_protected_path_desktop_suffix(self):
"""Ensure Desktop.old variants do not match Desktop protected entry"""
desktop = os.path.expanduser('~/Desktop')
paths = [
desktop + '.old',
os.path.join(desktop + '.old', 'photo.jpg'),
]
for path in paths:
result = check_protected_path(path)
self.assertIsNone(
result, msg=f"Unexpected Desktop match for {path}")
def test_calculate_impact_nonexistent(self):
"""Test calculate_impact with non-existent path"""
result = calculate_impact('/nonexistent/path/12345')
self.assertEqual(result['file_count'], 0)
self.assertEqual(result['total_size'], 0)
self.assertEqual(result['size_human'], '0B')
def test_calculate_impact_file(self):
"""Test calculate_impact with a file"""
with tempfile.NamedTemporaryFile(delete=False, dir=self.tempdir) as f:
f.write(b'x' * 1000)
temp_path = f.name
try:
result = calculate_impact(temp_path)
self.assertEqual(result['file_count'], 1)
self.assertGreaterEqual(result['total_size'], 1000)
self.assertIsInstance(result['size_human'], str)
finally:
os.unlink(temp_path)
def test_calculate_impact_directory(self):
"""Test calculate_impact with a directory"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create some test files
for i in range(5):
filepath = os.path.join(tmpdir, f'file{i}.txt')
with open(filepath, 'w', encoding='utf-8') as f:
f.write('x' * 100)
result = calculate_impact(tmpdir)
self.assertEqual(result['file_count'], 5)
self.assertGreaterEqual(result['total_size'], 500)
self.assertIsInstance(result['size_human'], str)
def test_calculate_impact_nested_directory(self):
"""Test calculate_impact with nested directories"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create nested structure
subdir = os.path.join(tmpdir, 'subdir')
os.makedirs(subdir)
# Files in root
with open(os.path.join(tmpdir, 'root.txt'), 'w', encoding='utf-8') as f:
f.write('x' * 100)
# Files in subdir
with open(os.path.join(subdir, 'sub.txt'), 'w', encoding='utf-8') as f:
f.write('x' * 200)
result = calculate_impact(tmpdir)
self.assertEqual(result['file_count'], 2)
self.assertGreaterEqual(result['total_size'], 300)
def test_get_warning_message(self):
"""Test get_warning_message function"""
impact = {
'file_count': 10,
'total_size': 1024000,
'size_human': '1MB',
}
msg = get_warning_message('/home/user', impact)
self.assertIsInstance(msg, str)
self.assertIn('/home/user', msg)
self.assertIn('10', msg) # file count
self.assertIn('1MB', msg) # size
def test_get_warning_message_no_files(self):
"""Test get_warning_message with no files"""
impact = {
'file_count': 0,
'total_size': 0,
'size_human': '0B',
}
msg = get_warning_message('/some/path', impact)
self.assertIsInstance(msg, str)
self.assertIn('/some/path', msg)
@requirePPXML
def test_clear_cache(self):
"""Test clear_cache function"""
# Load to populate cache
load_protected_paths()
self.assertIsNotNone(protected_path_module._protected_paths_cache)
# Clear cache
clear_cache()
self.assertIsNone(protected_path_module._protected_paths_cache)
@requirePPXML
def test_preview_all(self):
"""Check whether any real cleaners return protected paths"""
register_all_cleaners()
def check_option(co_key, co_option_id):
for cmd in backends[co_key].get_commands(co_option_id):
try:
for ret in cmd.execute(False): # False = preview mode
if isinstance(ret, dict) and ret.get('path'):
path = ret['path']
result = check_protected_path(path)
if result is not None:
print(
f'warning: {path} is protected by {result}')
return
except Exception:
continue
option_counter = 0
for key in sorted(backends):
# loop over cleaners
for (option_id, _option_name) in backends[key].get_options():
# loop over options
check_option(key, option_id)
option_counter += 1
print(f'checked {option_counter} options')
@requirePPXML
def test_path_with_trailing_slash(self):
"""Test that paths with trailing slashes are handled correctly"""
home = os.path.expanduser('~')
home_with_slash = home + os.sep
result1 = check_protected_path(home)
result2 = check_protected_path(home_with_slash)
self.assertIsNotNone(result1)
self.assertIsNotNone(result2)
self.assertEqual(result1['path'], result2['path'])
@requirePPXML
@common.skipUnlessWindows
def test_case_insensitive_windows(self):
"""Test case-insensitive matching on Windows"""
profile_var = r'%userprofile%'
profile = os.path.expandvars(profile_var)
self.assertNotEqual(profile, profile_var)
for case_method in CASE_METHODS:
# the whole path is case-insensitive
cased_profile = case_method(profile)
self.assertIsNotNone(check_protected_path(cased_profile))
@requirePPXML
def test_case_insensitive(self):
"""Test case insensitive cross platform"""
home = os.path.expanduser('~')
self.assertIsInstance(home, str)
protected_base = os.path.join(home, 'Desktop')
for case_method in CASE_METHODS:
fname = 'thesis.pdf'
check_path = os.path.join(protected_base, case_method(fname))
result = check_protected_path(check_path)
self.assertIsNotNone(
result, f'Failed to find protected path for {check_path}: {result}')
self.assertEqual(result['path'].lower(), protected_base.lower())
if __name__ == '__main__':
unittest.main()