Ctrl K
ComunidadGuías…

tests/TestProtectedPath.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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()