Ctrl K
ComunidadGuías…

tests/TestWinapp.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 cases for module Winapp
"""

import os
import re
import shutil
import stat
import string
import time
from contextlib import suppress
from unittest import mock

from tests.common import pytest

from tests import common
import bleachbit
from bleachbit.Winapp import Winapp, detectos, detect_file, fnmatch_translate, list_winapp_files, load_cleaners, section2option, winapp_expand_vars
from bleachbit.Windows import detect_registry_key, parse_windows_build
from bleachbit import IS_WINDOWS, logger
from bleachbit.FileUtilities import extended_path_undo
from bleachbit.PathUtils import path_startswith

if IS_WINDOWS:
    import winreg


def _create_registry_keys(*key_paths):
    """Create registry keys, ignoring errors if they already exist"""
    if not IS_WINDOWS:
        return
    for key_path in key_paths:
        with suppress(OSError):
            # pylint: disable-next=possibly-used-before-assignment
            hkey = winreg.CreateKey(winreg.HKEY_CURRENT_USER, key_path)
            hkey.Close()


def _delete_registry_keys(*key_paths):
    """Delete registry keys, ignoring errors if they don't exist"""
    if not IS_WINDOWS:
        return
    for key_path in key_paths:
        with suppress(OSError):
            winreg.DeleteKey(winreg.HKEY_CURRENT_USER, key_path)


KEYFULL = 'HKCU\\Software\\BleachBit\\DeleteThisKey'


def get_winapp2():
    """Download and cache winapp2.ini.  Return local filename."""
    url = ("https://raw.githubusercontent.com/bleachbit/winapp2.ini"
           "/refs/heads/master/Winapp2-BleachBit.ini")
    tmpdir = common.get_volatile_dir()
    fname = os.path.join(tmpdir, 'bleachbit_test_winapp2.ini')
    if os.path.exists(fname):
        age_seconds = time.time() - os.stat(fname)[stat.ST_MTIME]
        if age_seconds > (24 * 36 * 36):
            logger.info('deleting stale file %s ', fname)
            os.remove(fname)
    if not os.path.exists(fname):
        from bleachbit.Network import download_url_to_fn
        assert download_url_to_fn(url, fname)
    assert os.path.exists(fname)
    return fname


class WinappTestCase(common.BleachbitTestCase):
    """Test cases for Winapp"""

    ini_fn = None

    def run_all(self, cleaner, really_delete, allow_volatile=False):
        """Test all cleaner options, optionally tolerating volatile paths"""
        volatile_dir = common.get_volatile_dir()

        def is_volatile(path):
            if not (allow_volatile and path):
                return False
            if IS_WINDOWS:
                path = extended_path_undo(path)
            return path_startswith(path, volatile_dir)

        for (option_id, __name) in cleaner.get_options():
            for cmd in cleaner.get_commands(option_id):
                try:
                    for result in cmd.execute(really_delete):
                        common.validate_result(
                            self, result, really_delete,
                            allow_vanishing=is_volatile(result.get('path')))
                except FileNotFoundError as e:
                    # Tolerate vanished files in the volatile temporary directory.
                    # FileNotFoundError may carry no filename (e.g. pywinerror -> OSError
                    # conversions); tolerate it when allow_volatile is set.
                    if not (allow_volatile and (e.filename is None or is_volatile(e.filename))):
                        raise
                    logger.debug('TOCTOU: file vanished: %s', e.filename)

    def test_run_all_volatile(self):
        """Allow paths in the volatile temporary directory to vanish"""
        path = os.path.join(self.tempdir, 'vanished')
        result = {'label': 'Delete', 'n_deleted': 1, 'n_special': 0,
                  'path': path, 'size': 0}
        cmd = mock.Mock()
        cmd.execute.return_value = iter((result,))
        cleaner = mock.Mock()
        cleaner.get_options.return_value = (('option', 'name'),)
        cleaner.get_commands.return_value = (cmd,)
        self.run_all(cleaner, False, allow_volatile=True)

        cmd.execute.return_value = iter((result,))
        with self.assertRaises(AssertionError):
            self.run_all(cleaner, False)

        cmd.execute.side_effect = FileNotFoundError(2, 'vanished', path)
        self.run_all(cleaner, False, allow_volatile=True)

        with self.assertRaises(FileNotFoundError):
            self.run_all(cleaner, False)

        # Non-volatile paths must not be tolerated even when allow_volatile is set.
        non_volatile_path = (r'C:\bleachbit_nonexistent_test_path'
                             if IS_WINDOWS else '/bleachbit_nonexistent_test_path')
        non_volatile_result = {'label': 'Delete', 'n_deleted': 1, 'n_special': 0,
                               'path': non_volatile_path, 'size': 0}
        cmd.execute.side_effect = None
        cmd.execute.return_value = iter((non_volatile_result,))
        with self.assertRaises(AssertionError):
            self.run_all(cleaner, False, allow_volatile=True)

        cmd.execute.side_effect = FileNotFoundError(
            2, 'vanished', non_volatile_path)
        with self.assertRaises(FileNotFoundError):
            self.run_all(cleaner, False, allow_volatile=True)

        # FileNotFoundError may carry no filename (e.g. pywinerror -> OSError
        # conversions); tolerate it when allow_volatile is set.
        cmd.execute.side_effect = FileNotFoundError(2, 'vanished')
        self.run_all(cleaner, False, allow_volatile=True)

        with self.assertRaises(FileNotFoundError):
            self.run_all(cleaner, False)

    @common.skipUnlessWindows
    def test_remote(self):
        """Test with downloaded file"""
        try:
            fname = get_winapp2()
        except AssertionError as e:
            self.skipTest(f'real network unavailable: {e}')
        else:
            winapps = Winapp(fname)
            for cleaner in winapps.get_cleaners():
                self.run_all(cleaner, False, allow_volatile=True)

    def test_detectos(self):
        """Test detectos function"""
        # Tests are in the format (required_ver, mock, expected_return)
        tests = (('5.1', '5.1', True),
                 ('5.1', '6.0', False),
                 ('6.0', '5.1', False),
                 # 5.1 is the maximum version
                 ('|5.1', '5.1', True),
                 ('|5.1', '6.0', False),
                 ('|5.1', '10.0', False),
                 # 10.0 is the maximum version
                 ('|10.0', '5.1', True),
                 ('|10.0', '10.0', True),
                 ('|10.0', '10.1', False),
                 # 6.1 is the minimum version
                 ('6.1|', '5.1', False),
                 ('6.1|', '6.0', False),
                 ('6.1|', '6.1', True),
                 ('6.1|', '6.2', True),
                 ('6.1|', '10.0', True),
                 ('6.2|', '5.1', False),
                 ('6.2|', '6.0', False),
                 ('6.2|', '6.1', False),
                 ('6.2|', '6.2', True),
                 # must be 6.2 or 6.3
                 ('6.2|6.3', '6.0', False),
                 ('6.2|6.3', '6.1', False),
                 ('6.2|6.3', '6.2', True),
                 ('6.2|6.3', '6.3', True),
                 ('6.2|6.3', '10.0', False),
                 # 10.0 is the minimum
                 ('10.0|', '5.1', False),
                 ('10.0|', '10.0', True))
        for (req, mock_ver, expected_return) in tests:
            mock_ver = parse_windows_build(mock_ver)
            actual_return = detectos(req, mock_ver)
            msg = (f'detectos({req}, {mock_ver})=={actual_return}'
                   f' instead of {expected_return}')
            self.assertEqual(expected_return, actual_return, msg)

    def test_winapp_expand_vars_programfiles(self):
        """%ProgramFiles% adds a path only when %ProgramW6432% differs"""
        tests = (
            # 32-bit process on 64-bit Windows
            (r'C:\Program Files (x86)',
             [r'C:\Program Files (x86)\Foo', r'C:\Program Files\Foo']),
            # 64-bit process
            (r'C:\Program Files', [r'C:\Program Files\Foo']),
        )
        for program_files, expected in tests:
            def expandvars(path, program_files=program_files):
                return path.replace('%ProgramFiles%', program_files).replace(
                    '%ProgramW6432%', r'C:\Program Files')
            with mock.patch('os.path.expandvars', side_effect=expandvars):
                self.assertEqual(
                    expected, winapp_expand_vars(r'%ProgramFiles%\Foo'))

    @common.skipUnlessWindows
    def test_detect_file(self):
        """Test detect_file function"""
        tests = [('%windir%\\system32\\kernel32.dll', True),
                 ('%windir%\\system32', True),
                 ('%ProgramFiles%\\Internet Explorer', True),
                 ('%ProgramFiles%\\Internet Explorer\\', True),
                 ('%windir%\\doesnotexist', False),
                 ('%windir%\\system*', True),
                 ('%windir%\\*ystem32', True),
                 ('%windir%\\*ystem3*', True)]
        # On 64-bit Windows, Winapp2.ini expands the %ProgramFiles% environment
        # variable to also %ProgramW6432%, so test unique entries in
        # %ProgramW6432%.
        if os.getenv('ProgramW6432'):
            dir_64 = os.listdir(os.getenv('ProgramFiles'))
            dir_32 = os.listdir(os.getenv('ProgramW6432'))
            dir_32_unique = set(dir_32) - set(dir_64)
            if dir_32 and not dir_32_unique and bleachbit.ARCH_BITS == 32:
                raise RuntimeError(
                    'Test expects objects in %ProgramW6432% not in %ProgramFiles%')
            for pathname in dir_32_unique:
                tests.append((f'%ProgramFiles%\\{pathname}', True))
        else:
            logger.info(
                'skipping %ProgramW6432% tests because WoW64 not detected')
        for pathname, expected_return in tests:
            actual_return = detect_file(pathname)
            msg = f'detect_file({pathname}) returned {actual_return}'
            self.assertEqual(expected_return, actual_return, msg)

    def setup_fake(self, f1_filename=None):
        """Setup the test environment"""
        subkey = 'Software\\BleachBit\\DeleteThisKey\\AndThisKey'

        # put ampersand in directory name to test
        # https://github.com/bleachbit/bleachbit/issues/308
        dirname = self.mkdtemp(prefix='bleachbit-test-winapp&')
        fname1 = self.write_file(os.path.join(
            dirname, f1_filename or 'deleteme.log'), b'', 'wb')

        dirname2 = self.mkdir(os.path.join(dirname, 'sub'))
        fname2 = self.write_file(os.path.join(
            dirname2, 'deleteme.log'), b'', 'wb')

        fbak = self.write_file(os.path.join(
            dirname, 'deleteme.bak'), b'', 'wb')

        self.assertExists(fname1)
        self.assertExists(fname2)
        self.assertExists(fbak)

        _create_registry_keys(subkey)

        self.assertTrue(detect_registry_key(KEYFULL))
        self.assertTrue(detect_registry_key(f'HKCU\\{subkey}'))

        return dirname, fname1, fname2, fbak

    def ini2cleaner(self, body, do_next=True):
        """Write a minimal Winapp2.ini"""
        with open(self.ini_fn, 'w', encoding='utf-8') as ini:
            ini.write('[someapp]\nLangSecRef=3021\n' + body + '\n')
        self.assertExists(self.ini_fn)
        if do_next:
            return next(Winapp(self.ini_fn).get_cleaners())
        return Winapp(self.ini_fn).get_cleaners()

    @common.skipUnlessWindows
    @pytest.mark.xdist_group('winapp-registry')
    def test_fake(self):
        """Test with fake file"""

        # reuse this path to store a winapp2.ini file in
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')

        # a set of tests
        # this map explains what each position in the test tuple means
        # 0=line to write directly to winapp2.ini
        # 1=filename1 to place in fake environment (default=deleteme.log)
        # 2=auto-hide before cleaning
        # 3=dirname exists after cleaning
        # 4=filename1 (.\deleteme.log) exists after cleaning
        # 5=sub\deleteme.log exists after cleaning
        # 6=.\deleteme.bak exists after cleaning
        tests = [
            # single file
            ('FileKey1=%s|deleteme.log', None,
                False, True, False, True, True),
            # single file, case matching should be insensitive
            ('FileKey1=%s|dEleteme.LOG', None,
                False, True, False, True, True),
            # special characters for XML
            ('FileKey1=%s|special_chars_&-\'.txt', 'special_chars_&-\'.txt',
             False, True, False, True, True),
            # *.log
            ('FileKey1=%s|*.LOG', None, False, True, False, True, True),
            # semicolon separates different file types
            ('FileKey1=%s|*.log;*.bak', None,
             False, True, False, True, False),
            # *.*
            ('FileKey1=%s|*.*', None, False, True, False, True, False),
            # recurse *.*
            ('FileKey1=%s|*.*|RECURSE', None, False,
             True, False, False, False),
            # recurse *.log
            ('FileKey1=%s|*.log|RECURSE', None, False,
             True, False, False, True),
            # remove self *.*, this removes the directory
            ('FileKey1=%s|*.*|REMOVESELF', None,
             False, False, False, False, False),
        ]

        # Add positive detection, where the detection believes the application is present,
        # to all the tests, which are also positive.
        new_tests = []
        for test in tests:
            for detect in (
                "\nDetectFile=%%APPDATA%%\\Microsoft",
                "\nSpecialDetect=DET_WINDOWS",
                "\nDetectFile1=%%APPDATA%%\\Microsoft\nDetectFile2=%%APPDATA%%\\does_not_exist",
                "\nDetectFile1=%%APPDATA%%\\does_not_exist\nDetectFile2=%%APPDATA%%\\Microsoft",
                "\nDetect=HKCU\\Software\\Microsoft",
                # Below checks that a space is OK in the registry key
                "\nDetect=HKCU\\Software\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon",
                # Below checks Detect# where one of two keys exist.
                "\nDetect1=HKCU\\Software\\Microsoft\nDetect2=HKCU\\Software\\does_not_exist",
                "\nDetect1=HKCU\\Software\\does_not_exist\nDetect2=HKCU\\Software\\Microsoft",
                # Below checks Detect with DetectFile where one exists
                "\nDetect=HKCU\\Software\\Microsoft\nDetectFile=%%APPDATA%%\\does_not_exist",
                    "\nDetect=HKCU\\Software\\does_not_exist\nDetectFile=%%APPDATA%%\\Microsoft"):
                new_ini = test[0] + detect
                new_test = [new_ini, ] + list(test[1:])
                new_tests.append(new_test)
        positive_tests = tests + new_tests

        # execute positive tests
        for test in positive_tests:
            print('positive test: ', test)
            self.assertEqual(len(test), 7)
            (dirname, fname1, fname2, fbak) = self.setup_fake(test[1])
            cleaner = self.ini2cleaner(test[0] % dirname)
            self.assertEqual(test[2], cleaner.auto_hide())
            self.run_all(cleaner, False)
            self.run_all(cleaner, True)
            self.assertCondExists(test[3], dirname)
            self.assertCondExists(test[4], fname1)
            self.assertCondExists(test[5], fname2)
            self.assertCondExists(test[6], fbak)
            shutil.rmtree(dirname, True)

        # negative tests where the application detect believes the application
        # is absent
        for test in tests:
            for detect in (
                "\nDetectFile=c:\\does_not_exist",
                "\nSpecialDetect=DET_SPACE_QUEST",
                # special characters for XML
                "\nDetectFile=c:\\does_not_exist_special_chars_&'",
                "\nDetectFile1=c:\\does_not_exist1\nDetectFile2=c:\\does_not_exist2",
                "\nDetect=HKCU\\Software\\does_not_exist",
                "\nDetect=HKCU\\Software\\does_not_exist_&'",
                    "\nDetect1=HKCU\\Software\\does_not_exist1"
                    "\nDetect2=HKCU\\Software\\does_not_exist1"):
                new_ini = test[0] + detect
                test_full = [new_ini, ] + list(test[1:])
                print('negative test', test_full)
                # execute the test
                (dirname, fname1, fname2, fbak) = self.setup_fake()
                cleaner = self.ini2cleaner(test_full[0] % dirname, False)
                self.assertRaises(StopIteration, cleaner.__next__)
                shutil.rmtree(dirname, True)

        # registry key, basic
        (dirname, fname1, fname2, fbak) = self.setup_fake()
        cleaner = self.ini2cleaner(f'RegKey1={KEYFULL}')
        self.run_all(cleaner, False)
        self.assertTrue(detect_registry_key(KEYFULL))
        self.run_all(cleaner, True)
        self.assertFalse(detect_registry_key(KEYFULL))
        shutil.rmtree(dirname, True)

        # check for parse error with ampersand
        (dirname, fname1, fname2, fbak) = self.setup_fake()
        cleaner = self.ini2cleaner(
            'RegKey1=HKCU\\Software\\PeanutButter&Jelly')
        self.run_all(cleaner, False)
        self.run_all(cleaner, True)
        shutil.rmtree(dirname, True)

    @common.skipUnlessWindows
    def test_excludekey(self):
        """Test for ExcludeKey"""

        # reuse this path to store a winapp2.ini file in
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')

        # Each tuple contains:
        #   [0] = Body of winapp2.ini configuration
        #   [1] = Whether .\deleteme.log should exist after cleaning (True=exists)
        #   [2] = Whether .\deleteme.bak should exist after cleaning (True=exists)
        #   [3] = Whether sub\deleteme.log should exist after cleaning (True=exists)
        #
        # Path notation:
        #   %(d)s     - Literal test directory path without a trailing backslash
        #   %(d)s\\   - Literal directory with a trailing backslash
        #   %%bbtestdir%% - Literal environment variable that resolves to same test directory

        tests = (
            # delete everything in single directory (no children) without
            # exclusions
            ('FileKey1=%(d)s|deleteme.*', False, False, True),
            # delete everything in single directory using environment variable
            ('FileKey1=%%bbtestdir%%|deleteme.*', False, False, True),
            # delete everything in parent and child directories without
            # exclusions
            ('FileKey1=%(d)s|deleteme.*|RECURSE', False, False, False),
            # don't delete files containing the described file name
            ('FileKey1=%(d)s|eteme.*|RECURSE', True, True, True),
            # delete a pattern sub folder included
            ('FileKey1=%(d)s|*eteme.*|RECURSE', False, False, False),
            # delete a pattern sub folder not included
            ('FileKey1=%(d)s|*eteme.*', False, False, True),
            # exclude log delimited by pipe
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=FILE|%(d)s|deleteme.log',
             True, False, True),
            # trailing backslash should work the same as without
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=FILE|%(d)s\\|deleteme.log',
             True, False, True),
            # delete / exclude patterns with different complexity
            ('FileKey1=%(d)s|*eteme.*\nExcludeKey1=FILE|%(d)s|deleteme.log',
             True, False, True),
            ('FileKey1=%(d)s|*eteme.*\nExcludeKey1=FILE|%(d)s\\|deleteme.log',
             True, False, True),
            ('FileKey1=%(d)s|*ete*.*\nExcludeKey1=PATH|%(d)s|*ete*.lo?',
             True, False, True),
            ('FileKey1=%(d)s|*eteme*\nExcludeKey1=PATH|%(d)s|*notexist.*',
             False, False, True),
            ('FileKey1=%(d)s|*eteme.log\nExcludeKey1=PATH|%(d)s|do*.log',
             False, True, True),
            # semicolon separates different file types
            ('FileKey1=%(d)s|*.log;*.bak|RECURSE\nExcludeKey1=PATH|%(d)s|*.log',
             True, False, True),
            # exclude log without pipe delimiter
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=FILE|%(d)s\\deleteme.log',
             True, False, True),
            # exclude everything in folder
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%(d)s|*.*',
             True, True, True),
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%(d)s\\|*.*',
             True, True, True),
            ('FileKey1=%(d)s|deleteme.*|RECURSE\nExcludeKey1=PATH|%(d)s|*.*',
             True, True, True),
            ('FileKey1=%(d)s|*.*|RECURSE\nExcludeKey1=PATH|%(d)s|*.*',
             True, True, True),
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%(d)s',
             True, True, True),
            # exclude everything in folder using environment variable
            # use double %% to escape
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%%bbtestdir%%|*.*',
             True, True, True),
            # exclude sub-folder
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%(d)s\\sub',
             False, False, True),
            # exclude multiple file types that do not exist
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%(d)s|*.exe;*.dll',
             False, False, True),
            # exclude multiple file types that do exist, so dlete nothing
            ('FileKey1=%(d)s|deleteme.*\nExcludeKey1=PATH|%(d)s|*.bak;*.log',
             True, True, True),
            ('FileKey1=%(d)s|deleteme.*|RECURSE\nExcludeKey1=PATH|%(d)s|*.bak;*.log',
             True, True, True),
            # multiple ExcludeKey, neither of which should do anything
            ('FileKey1=%(d)s|deleteme.*|RECURSE'
             '\nExcludeKey1=PATH|c:\\doesnotexist|*.*'
             '\nExcludeKey2=PATH|c:\\alsodoesnotexist\\|*.*',
             False, False, False),
            # multiple ExcludeKey, the first should work
            ('FileKey1=%(d)s|deleteme.*|RECURSE'
             '\nExcludeKey1=PATH|%(d)s|*.log'
             '\nExcludeKey2=PATH|c:\\alsodoesnotexist\\|*.*',
             True, False, True),
            # multiple ExcludeKey, both should work
            ('FileKey1=%(d)s|deleteme.*|RECURSE'
             '\nExcludeKey1=PATH|%(d)s|*.log'
             '\nExcludeKey2=PATH|%(d)s|*.bak',
             True, True, True),
            # glob should exclude the directory called 'sub'
            ('FileKey1=%(d)s|*.*\nExcludeKey1=PATH|%(d)s\\s*',
             False, False, True),
        )

        for test in tests:
            msg = f'\nTest:\n{test[0]}'
            # setup
            (dirname, _fname1, _fname2, _fbak) = self.setup_fake()
            self.assertExists(rf'{dirname}\deleteme.log', msg)
            self.assertExists(rf'{dirname}\deleteme.bak', msg)
            self.assertExists(rf'{dirname}\sub\deleteme.log', msg)
            # set environment variable for testing
            with common.set_temporary_env('bbtestdir', dirname):
                self.assertExists(r'$bbtestdir\deleteme.log', msg)
                # delete files
                cleaner = self.ini2cleaner(test[0] % {'d': dirname})
                self.run_all(cleaner, True)
                # test
                self.assertCondExists(test[1], rf'{dirname}\deleteme.log', msg)
                self.assertCondExists(test[2], rf'{dirname}\deleteme.bak', msg)
                self.assertCondExists(
                    test[3], rf'{dirname}\sub\deleteme.log', msg)

            # cleanup
            shutil.rmtree(dirname, True)

    @common.skipUnlessWindows
    def test_excludekey_star_dot_star_extensionless(self):
        """Test ExcludeKey *.* on a file without an extension"""
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')

        def make_dir():
            dirname = self.mkdtemp(prefix='bleachbit-test-winapp')
            self.write_file(os.path.join(dirname, 'deleteme'), b'', 'wb')
            self.write_file(os.path.join(dirname, 'deleteme.log'), b'', 'wb')
            return dirname

        # without the exclude, the FileKey deletes the extensionless file
        dirname = make_dir()
        cleaner = self.ini2cleaner(f'FileKey1={dirname}|deleteme*')
        self.run_all(cleaner, True)
        self.assertNotExists(rf'{dirname}\deleteme')
        shutil.rmtree(dirname, True)

        # *.* excludes the whole folder
        dirname = make_dir()
        body = (f'FileKey1={dirname}|deleteme*'
                f'\nExcludeKey1=PATH|{dirname}|*.*')
        cleaner = self.ini2cleaner(body)
        self.run_all(cleaner, True)
        self.assertExists(rf'{dirname}\deleteme')
        self.assertExists(rf'{dirname}\deleteme.log')
        shutil.rmtree(dirname, True)

    def test_filekey_star_dot_star_every_file(self):
        """FileKey *.* without RECURSE matches every file, as on Windows"""
        dirname = self.mkdtemp(prefix='bleachbit-test-winapp-stardotstar')
        names = ('data_0', 'index', 'deleteme.log', '.hidden')
        for name in names:
            self.write_file(os.path.join(dirname, name), b'', 'wb')
        self.mkdir(os.path.join(dirname, 'sub.dir'))
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')

        cleaner = self.ini2cleaner(f'FileKey1={dirname}|*.*')
        paths = [p for (_o, a) in cleaner.actions for p in a.get_paths()]
        self.assertEqual(sorted(names),
                         sorted(os.path.basename(p) for p in paths))

    def _verify_keys_state(self, expected_state):
        """Verify registry keys match expected state (dict of key_path -> exists)"""
        for key_path, should_exist in expected_state.items():
            exists = detect_registry_key('HKCU\\' + key_path)
            if should_exist:
                self.assertTrue(exists, f'Key {key_path} should exist')
            else:
                self.assertFalse(exists, f'Key {key_path} should not exist')

    @common.skipUnlessWindows
    @pytest.mark.xdist_group('winapp-registry')
    def test_excludekey_reg(self):
        """Test for ExcludeKey with REG type to prevent registry key deletion"""

        # reuse this path to store a winapp2.ini file in
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')

        test_key_parent = r'Software\BleachBitTest\ExcludeKey'
        test_key_child0 = rf'{test_key_parent}\Child0'
        test_key_child1 = rf'{test_key_parent}\Child1'
        test_key_not_exc = f'{test_key_parent}_NotExcluded'

        tests = (
            # (exclude_key, parent_should_remain, child0_should_remain, child1_should_remain)
            # Excluding a child key also implicitly protects the parent,
            # since the parent must exist for the excluded child to persist.
            (test_key_parent, True, True, True),
            (test_key_child0, True, True, False),
            (test_key_child1, True, False, True),
        )

        for exclude_key, parent_should_remain, child0_should_remain, child1_should_remain in tests:
            with self.subTest(exclude_key=exclude_key):
                try:
                    _create_registry_keys(
                        test_key_parent, test_key_child0,
                        test_key_child1, test_key_not_exc)
                    cleaner_ini = f'''RegKey1=HKCU\\{test_key_parent}
RegKey2=HKCU\\{test_key_child0}
RegKey3=HKCU\\{test_key_child1}
RegKey4=HKCU\\{test_key_not_exc}
ExcludeKey1=REG|HKCU\\{exclude_key}'''
                    cleaner = self.ini2cleaner(cleaner_ini)
                    self.run_all(cleaner, False)
                    self._verify_keys_state({
                        test_key_parent: True,
                        test_key_child0: True,
                        test_key_child1: True,
                        test_key_not_exc: True,
                    })
                    self.run_all(cleaner, True)
                    self._verify_keys_state({
                        test_key_parent: parent_should_remain,
                        test_key_child0: child0_should_remain,
                        test_key_child1: child1_should_remain,
                        test_key_not_exc: False,
                    })
                finally:
                    _delete_registry_keys(
                        test_key_parent, test_key_child0,
                        test_key_child1, test_key_not_exc)

    def test_regkey_action_kept(self):
        """RegKey actions are kept wherever winapp2.ini lives"""
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2-trust')
        with open(self.ini_fn, 'w', encoding='utf-8') as ini:
            ini.write('[someapp]\nLangSecRef=3021\n'
                      'FileKey1=%Temp%|bleachbit-test-does-not-exist.tmp\n'
                      f'RegKey1={KEYFULL}\n')

        cleaner = next(Winapp(self.ini_fn).get_cleaners())
        actions = [a.__class__.__name__ for (_option_id, a) in cleaner.actions]
        self.assertIn('Winreg', actions)
        self.assertIn('Delete', actions)

    def _build_actions(self, body, prefix):
        """Return the actions of a one-section winapp2.ini, by class name"""
        self.ini_fn = self.mkstemp(suffix='.ini', prefix=prefix)
        with open(self.ini_fn, 'w', encoding='utf-8') as ini:
            ini.write('[someapp]\nLangSecRef=3021\n' + body)
        cleaner = next(Winapp(self.ini_fn).get_cleaners())
        actions = {}
        for (_option_id, action) in cleaner.actions:
            actions.setdefault(action.__class__.__name__, []).append(action)
        return actions

    def test_excludekey_without_number(self):
        """An ExcludeKey without a number works like FileKey without one"""
        for suffix in ('1', ''):
            with self.subTest(suffix=suffix):
                actions = self._build_actions(
                    'FileKey=C:\\BB Test|*.*|RECURSE\n'
                    f'ExcludeKey{suffix}=FILE|C:\\BB Test\\|keep.ini\n',
                    'winapp2-excludekey')
                self.assertTrue(actions['Delete'][0].nwholeregex)

    def test_excludekey_reg_trailing_backslash(self):
        """A REG ExcludeKey ending in a backslash still excludes the key"""
        actions = self._build_actions(
            'RegKey1=HKCU\\Software\\BleachBit\\Foo\n'
            'RegKey2=HKCU\\Software\\BleachBit\\Foo\\Keep\n'
            'ExcludeKey1=REG|HKCU\\Software\\BleachBit\\Foo\\Keep\\\n',
            'winapp2-regexclude')
        self.assertEqual(['HKCU\\Software\\BleachBit\\Foo'],
                         [a.keyname for a in actions['Winreg']])
        self.assertEqual(['HKCU\\Software\\BleachBit\\Foo\\Keep'],
                         actions['Winreg'][0].excludekeys)

    def test_action_keeps_xml_special_characters(self):
        """XML-special characters in keys reach the providers unchanged"""
        actions = self._build_actions(
            'FileKey1=C:\\BB Test\\A & B <c> "d"|*.log\n'
            'RegKey1=HKCU\\Software\\BleachBit\\A & B|Value & Name\n',
            'winapp2-xmlchars')

        delete_path = actions['Delete'][0].paths[0]
        self.assertIn('A & B <c> "d"', delete_path)
        self.assertNotIn('&amp;', delete_path)
        self.assertNotIn('&lt;', delete_path)
        self.assertNotIn('&quot;', delete_path)

        winreg_action = actions['Winreg'][0]
        self.assertIn('A & B', winreg_action.keyname)
        self.assertNotIn('&amp;', winreg_action.keyname)
        self.assertEqual(winreg_action.name, 'Value & Name')

    def test_action_keeps_control_character_in_path(self):
        """A control character in a path no longer drops its section"""
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2-ctrlchar')
        with open(self.ini_fn, 'w', encoding='utf-8') as ini:
            ini.write('[someapp]\nLangSecRef=3021\n'
                      'FileKey1=C:\\BB Test\\a\x01b|*.log\n')

        winapp = Winapp(self.ini_fn)
        self.assertEqual(winapp.errors, 0)
        cleaner = next(winapp.get_cleaners())
        delete_path = cleaner.actions[0][1].paths[0]
        self.assertIn('a\x01b', delete_path)

    def test_action_keeps_tab_in_path(self):
        """A tab in a path is kept, not collapsed to a space"""
        actions = self._build_actions(
            'FileKey1=C:\\BB Test\\a\tb|*.log\n', 'winapp2-tabpath')

        delete_path = actions['Delete'][0].paths[0]
        self.assertIn('a\tb', delete_path)

    def test_detect_probed_once_per_key(self):
        """A Detect key repeated across sections is probed once per load"""
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2-detectcache')
        key = 'HKCU\\Software\\BleachBit\\DetectCacheTest'
        with open(self.ini_fn, 'w', encoding='utf-8') as ini:
            for i in range(3):
                ini.write(f'[App{i}*]\nLangSecRef=3021\n'
                          f'Detect={key}\n'
                          f'FileKey1=%Temp%|bleachbit-test-{i}.tmp\n')

        with mock.patch('bleachbit.Windows.detect_registry_key',
                        return_value=True) as probe:
            cleaner = next(Winapp(self.ini_fn).get_cleaners())

        probe.assert_called_once_with(key)
        # All three sections are still active.
        self.assertEqual(len(cleaner.actions), 3)

    def test_load_cleaners_yields_between_sections(self):
        """load_cleaners() yields between winapp2.ini sections"""
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2-yield')
        with open(self.ini_fn, 'w', encoding='utf-8') as ini:
            for i in range(3):
                ini.write(f'[App{i}]\nLangSecRef=3021\n'
                          f'FileKey1=%Temp%|bleachbit-test-{i}.tmp\n')

        with mock.patch('bleachbit.Winapp.list_winapp_files',
                        return_value=[self.ini_fn]), \
                mock.patch('bleachbit.Winapp._YIELD_SECONDS', 0), \
                mock.patch.dict('bleachbit.Cleaner.backends'):
            steps = list(load_cleaners())
            cleaner = bleachbit.Cleaner.backends['winapp2_applications']

        self.assertGreaterEqual(len(steps), 3)
        self.assertEqual(len(cleaner.actions), 3)

    def test_load_cleaners_merges_system_file(self):
        """The system winapp2.ini adds to the personal one, not replaces it"""
        ini_dir = self.mkdtemp()
        personal = self.write_file(
            os.path.join(ini_dir, 'winapp2-personal.ini'),
            text='[NewApp *]\nLangSecRef=3021\n'
            'FileKey1=%Temp%|bleachbit-test-new.tmp\n'
            '[SharedApp *]\nLangSecRef=3021\n'
            'FileKey1=%Temp%|bleachbit-test-personal.tmp\n')
        system = self.write_file(
            os.path.join(ini_dir, 'winapp2-system.ini'),
            text='[OldApp *]\nLangSecRef=3021\n'
            'FileKey1=%Temp%|bleachbit-test-old.tmp\n'
            '[SharedApp *]\nLangSecRef=3021\n'
            'FileKey1=%Temp%|bleachbit-test-system.tmp\n')

        with mock.patch('bleachbit.Winapp.list_winapp_files',
                        return_value=[personal, system]), \
                mock.patch.dict('bleachbit.Cleaner.backends'):
            list(load_cleaners())
            cleaner = bleachbit.Cleaner.backends['winapp2_applications']

        self.assertEqual(['newapp', 'oldapp', 'sharedapp'],
                         [o for (o, _name) in cleaner.get_options()])
        # the personal file wins for a section in both
        self.assertEqual(['bleachbit-test-new.tmp', 'bleachbit-test-old.tmp',
                          'bleachbit-test-personal.tmp'],
                         sorted(os.path.basename(a.paths[0])
                                for (_o, a) in cleaner.actions))

    def test_load_cleaners_keeps_option_ids_across_files(self):
        """A section in both winapp2.ini files keeps its option id"""
        ini_dir = self.mkdtemp()
        personal = self.write_file(
            os.path.join(ini_dir, 'winapp2-personal.ini'),
            text='[Notepad++ *]\nLangSecRef=3021\n'
            'FileKey1=%Temp%|bleachbit-test-personal.tmp\n')
        system = self.write_file(
            os.path.join(ini_dir, 'winapp2-system.ini'),
            text='[Notepad *]\nLangSecRef=3021\n'
            'FileKey1=%Temp%|bleachbit-test-notepad.tmp\n'
            '[Notepad++ *]\nLangSecRef=3021\n'
            'FileKey1=%Temp%|bleachbit-test-system.tmp\n')

        with mock.patch('bleachbit.Winapp.list_winapp_files',
                        return_value=[personal, system]), \
                mock.patch.dict('bleachbit.Cleaner.backends'):
            list(load_cleaners())
            cleaner = bleachbit.Cleaner.backends['winapp2_applications']

        self.assertEqual([('notepad', 'Notepad++'), ('notepad__2', 'Notepad')],
                         list(cleaner.get_options()))
        self.assertEqual([('notepad', 'bleachbit-test-personal.tmp'),
                          ('notepad__2', 'bleachbit-test-notepad.tmp')],
                         sorted((o, os.path.basename(a.paths[0]))
                                for (o, a) in cleaner.actions))

    def test_filekey_recurse_rejects_excessive_wildcards(self):
        """A FileKey RECURSE pattern with too many wildcards is rejected (ReDoS defense)"""
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2-redos')
        with open(self.ini_fn, 'w', encoding='utf-8') as ini:
            ini.write('[someapp]\nLangSecRef=3021\n'
                      'FileKey1=%Temp%|' + '*a' * 11 + '|RECURSE\n')
        winapp = Winapp(self.ini_fn)
        self.assertEqual(winapp.errors, 1)

    def test_bad_section_keeps_rest_of_file(self):
        """A malformed section is skipped, not the whole file"""
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2-badsection')
        good = ('[Good App *]\nLangSecRef=3021\n'
                'FileKey1=%Temp%|bleachbit-test-good.tmp\n')
        tests = (
            # duplicate section: the first one wins
            '[Good App *]\nLangSecRef=3021\n'
            'FileKey1=%Temp%|bleachbit-test-dup.tmp\n',
            # duplicate key
            '[Bad App *]\nLangSecRef=3021\n'
            'FileKey1=%Temp%|bleachbit-test-a.tmp\n'
            'FileKey1=%Temp%|bleachbit-test-b.tmp\n',
            # line without a delimiter
            '[Bad App *]\nLangSecRef=3021\n'
            'FileKey1=%Temp%|bleachbit-test-a.tmp\n'
            'not an option\n',
        )
        for bad in tests:
            with self.subTest(bad=bad):
                with open(self.ini_fn, 'w', encoding='utf-8') as ini:
                    ini.write(good + bad)
                winapp = Winapp(self.ini_fn)
                self.assertEqual(winapp.errors, 1)
                cleaner = next(winapp.get_cleaners())
                self.assertEqual(['good_app'],
                                 [o for (o, _name) in cleaner.get_options()])
                paths = [p for (_o, a) in cleaner.actions for p in a.paths]
                self.assertEqual(1, len(paths))
                self.assertTrue(paths[0].endswith('bleachbit-test-good.tmp'))

    @common.skipIfWindows
    def test_list_winapp_files_skips_world_writable(self):
        """A world-writable winapp2.ini is ignored, like CleanerML XML"""
        pcd = bleachbit.personal_cleaners_dir
        bleachbit.personal_cleaners_dir = self.mkdtemp(
            prefix='bleachbit-winapp-worldwritable')
        try:
            fn = os.path.join(bleachbit.personal_cleaners_dir, 'winapp2.ini')
            self.write_file(fn, text='[test]\n')
            os.chmod(fn, 0o666)
            self.assertNotIn(fn, list(list_winapp_files()))
            os.chmod(fn, 0o644)
            self.assertIn(fn, list(list_winapp_files()))
        finally:
            bleachbit.personal_cleaners_dir = pcd

    @common.skipIfWindows
    def test_list_winapp_files_skips_world_writable_dir(self):
        """A winapp2.ini in a world-writable directory is ignored"""
        pcd = bleachbit.personal_cleaners_dir
        wwdir = self.mkdtemp(prefix='bleachbit-winapp-wwdir')
        bleachbit.personal_cleaners_dir = wwdir
        try:
            fn = os.path.join(wwdir, 'winapp2.ini')
            self.write_file(fn, text='[test]\n')
            os.chmod(fn, 0o644)
            os.chmod(wwdir, 0o777)
            self.assertNotIn(fn, list(list_winapp_files()))
            os.chmod(wwdir, 0o755)
            self.assertIn(fn, list(list_winapp_files()))
        finally:
            os.chmod(wwdir, 0o755)
            bleachbit.personal_cleaners_dir = pcd

    @common.skipUnlessWindows
    def test_filekey_with_path_including_systemdrive(self):
        """Test FileKey with path including SystemDrive"""
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')
        # lexists(r'C:filename') returns False
        # FindFilesW(r'C:filename') throws an exception
        # So assert that we use r'C:\filename'.
        # We need this test because winapp2.ini supports such paths and
        # giving r'C:filename' as argument to lexists or FindFilesW
        # causes a hard to detect issue, which has been detected because
        # lexists('C:filename') returns True when called through PyCharm.
        # Also test_remote is supposed to run cleaners that contain
        # such paths but it doesn't assert if those cleaners actually
        # clean their targets.

        filename = os.path.join('C:\\', 'deleteme.txt')
        try:
            common.touch_file(filename)
        except PermissionError:
            self.skipTest('Permission denied: run as administrator')

        with mock.patch('os.path.lexists') as mock_lexists:
            cleaner = self.ini2cleaner('FileKey1=%SystemDrive%|deleteme.txt')
            self.run_all(cleaner, True)
            mock_lexists.assert_any_call(r'C:\deleteme.txt')

        self.assertNotExists(filename)

    @common.skipUnlessWindows
    def test_removeself(self):
        """Test for the removeself option"""

        # indexes for test
        # position 0: FileKey statement
        # position 1: whether the file `dir_c\subdir\foo.log` should exist
        #              after operation is complete
        # position 2: path of top-folder which should have been deleted
        d = self.tempdir
        tests = (
            # Refer to directory directly (i.e., without a glob).
            (rf'FileKey1={d}\dir_c|*.*|REMOVESELF',
             False, rf'{d}\dir_c'),
            # Refer to file that exists. This is invalid, so nothing happens.
            (rf'FileKey1={d}\dir_c\submarine_sandwich.log|*.*|REMOVESELF',
             True, ''),
            (rf'FileKey1={d}\dir_c\submarine*|*.*|REMOVESELF',
             True, ''),
            # Refer to path that does not exist, so nothing happens.
            (rf'FileKey1={d}\dir_c\doesnotexist.log|*.*|REMOVESELF',
             True, ''),
            # Refer by glob to both a file and directory (which both start with `sub`).
            # This should affect only the directory.
            (rf'FileKey1={d}\dir_a\sub*|*.*|REMOVESELF',
             True, rf'{d}\dir_a\subdir'),
            # glob in middle of directory path with whole directory entry
            (rf'FileKey1={d}\*c\subdir|*.*|REMOVESELF',
             False, rf'{d}\dir_c\subdir'),
            (rf'FileKey1={d}\*doesnotexist\subdir|*.*|REMOVESELF',
             True, ''),
            # glob at end of path
            (rf'FileKey1={d}\dir_c\sub*|*.*|REMOVESELF',
             False, rf'{d}\dir_c\subdir'),
            (rf'FileKey1={d}\dir_c\doesnotexist*|*.*|REMOVESELF',
             True, '')
        )

        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')

        for filekey, c_log_expected, top_log_expected in tests:
            for letter in ('a', 'b', 'c'):
                # Make three directories, each with a `foo.log` file.
                fn = os.path.join(self.tempdir, 'dir_' +
                                  letter, 'subdir', 'foo.log')
                common.touch_file(fn)

            # In dir_a, place a file one level up from `foo.log`.
            # Notice the glob `dir_a\sub*` will match both a directory and file.
            fn2 = os.path.join(self.tempdir, 'dir_a', 'submarine_sandwich.log')
            common.touch_file(fn2)

            cleaner = self.ini2cleaner(filekey)
            self.assertExists(fn, filekey)
            self.assertExists(fn2, filekey)
            if top_log_expected:
                self.assertExists(top_log_expected, filekey)
            self.run_all(cleaner, True)
            if c_log_expected:
                self.assertExists(fn, filekey)
            else:
                self.assertNotExists(fn, filekey)
            if top_log_expected != '':
                self.assertNotExists(top_log_expected, filekey)
            self.assertExists(fn2, filekey)
            if top_log_expected:
                self.assertNotExists(top_log_expected, filekey)

    def test_section2option(self):
        """Test for section2option()"""
        tests = (('  FOO2  ', 'foo2'),
                 ('A - B (C)', 'a_b_c'))
        for test in tests:
            self.assertEqual(section2option(test[0]), test[1])

    def test_colliding_section_names(self):
        """Sections that normalize to the same option id stay separate"""
        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2-optionid')
        with open(self.ini_fn, 'w', encoding='utf-8') as ini:
            for i, section in enumerate(('Notepad *', 'Notepad++ *',
                                         'Notepad 2 *')):
                ini.write(f'[{section}]\nLangSecRef=3021\nWarning=warning{i}\n'
                          f'FileKey1=%Temp%|bleachbit-test-{i}.tmp\n')

        cleaner = next(Winapp(self.ini_fn).get_cleaners())
        # a suffix never takes the id of a section, as 'Notepad 2 *' here
        option_ids = ('notepad', 'notepad__2', 'notepad_2')
        self.assertEqual(sorted(zip(option_ids, ('Notepad', 'Notepad++',
                                                 'Notepad 2'))),
                         sorted(cleaner.get_options()))
        for i, option_id in enumerate(option_ids):
            self.assertEqual(f'warning{i}', cleaner.get_warning(option_id))
            actions = [a for (o, a) in cleaner.actions if o == option_id]
            self.assertEqual(1, len(actions))
            self.assertTrue(
                actions[0].paths[0].endswith(f'bleachbit-test-{i}.tmp'))

        # the id stays the same when the other section is not detected
        missing = os.path.join(self.mkdtemp(), 'does_not_exist')
        with open(self.ini_fn, 'w', encoding='utf-8') as ini:
            ini.write(f'[Notepad *]\nLangSecRef=3021\nDetectFile={missing}\n'
                      'FileKey1=%Temp%|bleachbit-test-0.tmp\n'
                      '[Notepad++ *]\nLangSecRef=3021\n'
                      'FileKey1=%Temp%|bleachbit-test-1.tmp\n')
        cleaner = next(Winapp(self.ini_fn).get_cleaners())
        self.assertEqual([('notepad__2', 'Notepad++')],
                         list(cleaner.get_options()))

    def test_fnmatch_translate(self):
        """Test that fnmatch_translate strips the end anchor and matches patterns"""
        regex = fnmatch_translate('*.log')
        self.assertFalse(regex.endswith(r'\z'))
        self.assertFalse(regex.endswith(r'\Z'))
        self.assertFalse(regex.endswith(r'\Z(?ms)'))
        self.assertFalse(regex.endswith(r'$'))
        self.assertNotEqual(regex, '*.log')

        cases = (
            ('*.log', 'deleteme.log', True),
            ('*.log', 'deleteme.bak', False),
            ('deleteme.*', 'deleteme.log', True),
            ('deleteme.*', 'deleteme.bak', True),
            ('deleteme.*', 'other.log', False),
            (r'C:\dir', r'C:\dir\file.txt', True),
            (r'C:\dir', r'C:\other\file.txt', False),
        )
        for pattern, text, expected in cases:
            msg = f'pattern={pattern!r} string={text!r}'
            self.assertEqual(
                bool(re.search(fnmatch_translate(pattern), text)),
                expected,
                msg)

    def test_fnmatch_translate_rejects_excessive_wildcards(self):
        """A pattern with too many wildcards is rejected (ReDoS defense)"""
        fnmatch_translate('*a*a*a*a*a*a*a*a*a*a')  # 10 wildcards: allowed
        self.assertRaises(
            ValueError, fnmatch_translate, '*a*a*a*a*a*a*a*a*a*a*')  # 11: rejected

    def test_section_not_found(self):
        """Test a section that is found"""
        tmp_ini_fn = self.write_file(
            'section_not_found.ini',
            r"""[Initech TPS Reports *]
LangSecRef=3023
DetectFile=%CommonAppData%\Initech\TPSReports
FileKey1=%CommonAppData%\Initech\TPSReports\Logs|*.*""",
            mode='w')

        with mock.patch.object(Winapp, 'detect', return_value=True) as mock_detect:
            winapps = Winapp(tmp_ini_fn)
            cleaner_list = list(winapps.get_cleaners())
            self.assertEqual(len(cleaner_list), 1)
            self.assertEqual(cleaner_list[0].get_name(), 'Multimedia')
            this_option = list(cleaner_list[0].get_options())[0]
            self.assertEqual(this_option[0], 'initech_tps_reports')
            self.assertEqual(this_option[1], 'Initech TPS Reports')
            mock_detect.assert_called()

    def test_many_patterns(self):
        """Test a cleaner like Steam Installers and related performance improvement

        https://github.com/bleachbit/bleachbit/issues/325
        """

        # set up environment
        file_count = 1000
        dir_count = 10
        print(f'Making {file_count} files in each of {dir_count} directories.')
        for i_d in range(1, dir_count + 1):
            sub_dir = self.mkdir(os.path.join(self.tempdir, f'dir{i_d}'))
            for i_f in range(1, file_count + 1):
                self.write_file(os.path.join(sub_dir, f'file{i_f}'))

        self.ini_fn = self.mkstemp(suffix='.ini', prefix='winapp2')

        searches = ';'.join(
            [f'*.{letter}' for letter in string.ascii_letters[0:26]])
        cleaner = self.ini2cleaner(
            f'FileKey1={self.tempdir}|{searches}|RECURSE')

        # preview
        t0 = time.time()
        self.run_all(cleaner, False)
        t1 = time.time()
        print(f'Elapsed time in preview: {t1 - t0:.4f} seconds ')

        # delete
        self.run_all(cleaner, True)