# 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 CleanerML
"""

# standard imports
import os
import shutil
import sys
from unittest import mock

# first party imports
import bleachbit
from tests import common
from bleachbit import Cleaner
from bleachbit.CleanerML import (
    CleanerML,
    boolstr_to_bool,
    create_pot,
    default_vars,
    list_cleanerml_files,
    load_cleaners,
    pot_fragment)


class CleanerMLTestCase(common.BleachbitTestCase):
    """Test cases for CleanerML"""

    def run_all(self, xmlcleaner, really_delete):
        """Helper function to execute all options in a cleaner"""
        for (option_id, __name) in xmlcleaner.cleaner.get_options():
            for cmd in xmlcleaner.cleaner.get_commands(option_id):
                for result in cmd.execute(really_delete):
                    common.validate_result(self, result, really_delete)

    def _get_xmlcleaner(self):
        """Helper for CleanerML*()"""
        xmlcleaner = CleanerML("doc/example_cleaner.xml")
        self.assertIsInstance(xmlcleaner, CleanerML)
        self.assertIsInstance(xmlcleaner.cleaner, Cleaner.Cleaner)
        return xmlcleaner

    def test_CleanerML(self):
        """Unit test for class CleanerML"""
        xmlcleaner = self._get_xmlcleaner()
        # preview
        self.run_all(xmlcleaner, False)

    @common.skipUnlessDestructive
    def test_CleanerML_destructive(self):
        """Unit test the destructive parts of class CleanerML"""
        xmlcleaner = self._get_xmlcleaner()
        # really delete
        self.run_all(xmlcleaner, True)

    def test_boolstr_to_bool(self):
        """Unit test for boolstr_to_bool()"""
        tests = [('True', True),
                 ('False', False)]

        for (arg, output) in tests:
            self.assertEqual(boolstr_to_bool(arg), output)
            self.assertEqual(boolstr_to_bool(arg.lower()), output)
            self.assertEqual(boolstr_to_bool(arg.upper()), output)

    def test_create_pot(self):
        """Unit test for create_pot()"""
        os.chdir('po')
        try:
            create_pot()
        finally:
            os.chdir('..')

    def test_default_vars_windows_system(self):
        """Unit test WindowsSystem in default_vars()"""
        env = {
            'WinDir': r'C:\Windows',
            'ProgramFiles': r'C:\Program Files (x86)',
            'ProgramW6432': r'C:\Program Files',
        }
        with mock.patch('bleachbit.CleanerML.IS_WINDOWS', True), \
                mock.patch.dict(os.environ, env, clear=True), \
                mock.patch('bleachbit.Windows.ARCH_BITS', 32):
            variables = default_vars()
        self.assertEqual(
            [r'C:\Windows\Sysnative', r'C:\Windows\SysWOW64'],
            variables['WindowsSystem'])

    def test_list_cleanerml_files(self):
        """Unit test for list_cleanerml_files()"""
        for pathname in list_cleanerml_files():
            self.assertExists(pathname)

    @common.skipIfWindows
    def test_list_cleanerml_files_vanished(self):
        """list_cleanerml_files() skips a cleaner deleted after listdir()

        The world-writable check is POSIX only, so this race does not exist
        on Windows.
        """
        dirname = self.mkdtemp(prefix='bleachbit-cleanerml-vanished')
        real_fn = os.path.join(dirname, 'real.xml')
        self.write_file(real_fn, contents=b'<cleaner id="test"/>')
        os.chmod(real_fn, 0o600)
        ghost_fn = os.path.join(dirname, 'ghost.xml')
        with mock.patch('bleachbit.CleanerML.listdir',
                        return_value=iter([ghost_fn, real_fn])):
            self.assertEqual(list(list_cleanerml_files()), [real_fn])

    def test_load_cleaners(self):
        """Unit test for load_cleaners()"""
        # normal
        list(load_cleaners())

        # should catch exception with invalid XML
        pcd = bleachbit.personal_cleaners_dir
        bleachbit.personal_cleaners_dir = self.mkdtemp(
            prefix='bleachbit-cleanerml-load')
        self.write_file(os.path.join(bleachbit.personal_cleaners_dir, 'invalid.xml'),
                        contents=b'<xml><broken>')
        list(load_cleaners())
        shutil.rmtree(bleachbit.personal_cleaners_dir)
        bleachbit.personal_cleaners_dir = pcd

    def test_load_cleaners_invalid_utf8(self):
        """Unit test for load_cleaners() with invalid UTF-8 encoding"""
        pcd = bleachbit.personal_cleaners_dir
        bleachbit.personal_cleaners_dir = self.mkdtemp(
            prefix='bleachbit-cleanerml-utf8')
        self.write_file(os.path.join(bleachbit.personal_cleaners_dir, 'broken_encoding.xml'),
                        contents=b'<cleaner id="poison">\n\xff\xfe\xfd Broken\n')
        list(load_cleaners())
        shutil.rmtree(bleachbit.personal_cleaners_dir)
        bleachbit.personal_cleaners_dir = pcd

    def test_untrusted_process_action(self):
        """A process action is ignored for an untrusted cleaner"""
        xml_str = (
            '<?xml version="1.0" encoding="UTF-8"?>\n'
            '<cleaner id="test_untrusted">\n'
            '  <label>Test</label>\n'
            '  <description>Test</description>\n'
            '  <option id="opt">\n'
            '    <label>Opt</label>\n'
            '    <description>Opt</description>\n'
            '    <action command="delete" search="file" path="test-does-not-exist"/>\n'
            '    <action command="process" cmd="calc.exe"/>\n'
            '  </option>\n'
            '</cleaner>\n')
        fn = os.path.join(self.mkdtemp(prefix='bleachbit-cleanerml-trust'),
                          'planted.xml')
        self.write_file(fn, text=xml_str)

        def action_classes(cleaner):
            return [a.__class__.__name__ for (_option_id, a) in cleaner.actions]

        self.assertIn('Process', action_classes(CleanerML(fn, trusted=True).cleaner))

        # The delete action stays; only the process action is dropped
        untrusted = action_classes(CleanerML(fn, trusted=False).cleaner)
        self.assertNotIn('Process', untrusted)
        self.assertIn('Delete', untrusted)

    def test_untrusted_winreg_action(self):
        """A winreg action is ignored for an untrusted cleaner"""
        xml_str = (
            '<?xml version="1.0" encoding="UTF-8"?>\n'
            '<cleaner id="test_untrusted_winreg">\n'
            '  <label>Test</label>\n'
            '  <description>Test</description>\n'
            '  <option id="opt">\n'
            '    <label>Opt</label>\n'
            '    <description>Opt</description>\n'
            '    <action command="delete" search="file" path="test-does-not-exist"/>\n'
            '    <action command="winreg" path="HKCU\\Software\\BleachBitTest"/>\n'
            '  </option>\n'
            '</cleaner>\n')
        fn = os.path.join(self.mkdtemp(prefix='bleachbit-cleanerml-trust'),
                          'planted_winreg.xml')
        self.write_file(fn, text=xml_str)

        def action_classes(cleaner):
            return [a.__class__.__name__ for (_option_id, a) in cleaner.actions]

        self.assertIn('Winreg', action_classes(CleanerML(fn, trusted=True).cleaner))

        # The delete action stays; only the winreg action is dropped
        untrusted = action_classes(CleanerML(fn, trusted=False).cleaner)
        self.assertNotIn('Winreg', untrusted)
        self.assertIn('Delete', untrusted)

    def test_untrusted_actions_warn_once_per_file(self):
        """Ignored actions are summarized in one warning, not one per action"""
        actions = '\n'.join(
            f'    <action command="process" cmd="calc{i}.exe"/>'
            for i in range(10))
        xml_str = (
            '<?xml version="1.0" encoding="UTF-8"?>\n'
            '<cleaner id="test_untrusted_many">\n'
            '  <label>Test</label>\n'
            '  <description>Test</description>\n'
            '  <option id="opt">\n'
            '    <label>Opt</label>\n'
            '    <description>Opt</description>\n'
            f'{actions}\n'
            '    <action command="winreg" path="HKCU\\Software\\BleachBitTest"/>\n'
            '  </option>\n'
            '</cleaner>\n')
        fn = os.path.join(self.mkdtemp(prefix='bleachbit-cleanerml-trust-log'),
                          'planted_many.xml')
        self.write_file(fn, text=xml_str)

        with self.assertLogs('bleachbit.CleanerML', level='WARNING') as cm:
            CleanerML(fn, trusted=False)
        self.assertEqual(len(cm.output), 1)
        self.assertIn("'process', 'winreg'", cm.output[0])

    def test_is_trusted_cleaner(self):
        """Only files under the system cleaners dir are trusted"""
        from bleachbit.CleanerML import is_trusted_cleaner
        system_dir = bleachbit.system_cleaners_dir
        self.assertTrue(is_trusted_cleaner(
            os.path.join(system_dir, 'example.xml')))
        self.assertFalse(is_trusted_cleaner(
            os.path.join(bleachbit.personal_cleaners_dir, 'example.xml')))

    def test_rejects_dtd(self):
        """A CleanerML file with a DTD is rejected (entity-expansion defense)"""
        xml_str = (
            '<?xml version="1.0"?>\n'
            '<!DOCTYPE cleaner [ <!ENTITY x "y"> ]>\n'
            '<cleaner id="dtd_test">\n'
            '  <label>Test</label>\n'
            '  <description>Test</description>\n'
            '</cleaner>\n')
        fn = os.path.join(self.mkdtemp(prefix='bleachbit-cleanerml-dtd'),
                          'dtd.xml')
        self.write_file(fn, text=xml_str)
        xmlcleaner = CleanerML(fn)
        self.assertFalse(xmlcleaner.cleaner.is_usable())

    def test_nvalid_utf8(self):
        """Test CleanerML() with invalid UTF-8 encoding

        It should fail gracefully.
        """
        fn = os.path.join(self.mkdtemp(prefix='bleachbit-cleanerml-utf8'),
                          'broken.xml')
        self.write_file(fn, contents=b'<cleaner id="poison">\n\xff\xfe\xfd\n')
        xmlcleaner = CleanerML(fn)
        self.assertIsInstance(xmlcleaner, CleanerML)
        self.assertFalse(xmlcleaner.cleaner.is_usable())

    def test_utf8_non_ascii(self):
        """Test CleanerML() with UTF-8 non-ASCII text

        It should load successfully.
        """
        xml_str = """<?xml version="1.0" encoding="UTF-8"?>
<cleaner id="test_utf8">
    <label>Test</label>
    <!-- 中文注释 -->
    <option id="opt">
        <label>测试标签</label>
        <description>测试描述</description>
        <action search="file" command="delete" path="C:\\中文路径\\file.txt"/>
    </option>
</cleaner>
"""
        fn = os.path.join(self.mkdtemp(prefix='bleachbit-cleanerml-utf8'),
                          'utf8.xml')
        self.write_file(fn, contents=xml_str.encode('utf-8'))
        xmlcleaner = CleanerML(fn)
        self.assertIsInstance(xmlcleaner, CleanerML)
        self.assertTrue(xmlcleaner.cleaner.is_usable())
        self.assertEqual('测试标签', xmlcleaner.cleaner.options['opt'][0])
        self.assertEqual('测试描述',
                         xmlcleaner.cleaner.options['opt'][1])
        commands = list(xmlcleaner.cleaner.get_commands('opt'))
        self.assertEqual(0, len(commands))

    def test_os_match(self):
        """Unit test for os_match"""
        xmlcleaner = CleanerML("doc/example_cleaner.xml")

        # blank always matches
        self.assertTrue(xmlcleaner.os_match(""))

        # as Linux
        self.assertFalse(xmlcleaner.os_match('windows', 'linux'))
        self.assertTrue(xmlcleaner.os_match('linux', 'linux'))
        self.assertTrue(xmlcleaner.os_match('unix', 'linux'))

        # as Windows
        self.assertFalse(xmlcleaner.os_match('linux', 'win32'))
        self.assertFalse(xmlcleaner.os_match('unix', 'win32'))
        self.assertTrue(xmlcleaner.os_match('windows', 'win32'))

        # as macOS (canonical name)
        self.assertTrue(xmlcleaner.os_match('macos', 'darwin'))
        self.assertTrue(xmlcleaner.os_match('bsd', 'darwin'))
        self.assertTrue(xmlcleaner.os_match('unix', 'darwin'))
        self.assertFalse(xmlcleaner.os_match('linux', 'darwin'))
        self.assertFalse(xmlcleaner.os_match('windows', 'darwin'))

        # "darwin" is accepted as a deprecated alias for "macos"
        with self.assertLogs('bleachbit.General', level='WARNING'):
            self.assertTrue(xmlcleaner.os_match('darwin', 'darwin'))

        # as unknown operating system
        with self.assertRaises(RuntimeError):
            xmlcleaner.os_match('linux', 'hal9000')

    def test_option_os_filter(self):
        """Unit test for <option os="..."> filtering

        An option with an os attribute that does not match the current
        platform should not be registered on the cleaner.
        """
        xml_str = f"""<?xml version="1.0" encoding="UTF-8"?>
<cleaner id="test_option_os">
    <label>Test</label>
    <option id="always">
        <label>Always</label>
        <description>Delete the files</description>
        <action search="file" command="delete" path="{self.tempdir}/always.log"/>
    </option>
    <option id="windows_only" os="windows">
        <label>Windows only</label>
        <description>Delete the files</description>
        <action search="file" command="delete" path="{self.tempdir}/windows.log"/>
    </option>
    <option id="linux_only" os="linux">
        <label>Linux only</label>
        <description>Delete the files</description>
        <action search="file" command="delete" path="{self.tempdir}/linux.log"/>
    </option>
    <option id="mac_only" os="macos">
        <label>macOS only</label>
        <description>Delete the files</description>
        <action search="file" command="delete" path="{self.tempdir}/mac.log"/>
    </option>
</cleaner>
"""
        cml_path = os.path.join(self.tempdir, 'test_option_os.xml')
        self.write_file(cml_path, xml_str.encode(sys.getdefaultencoding()))

        xmlc = CleanerML(cml_path)
        # The unfiltered option is always present.
        self.assertIn('always', xmlc.cleaner.options)
        # The remaining options are conditionally available.
        if bleachbit.IS_LINUX:
            self.assertNotIn('mac_only', xmlc.cleaner.options)
            self.assertNotIn('windows_only', xmlc.cleaner.options)
            self.assertIn('linux_only', xmlc.cleaner.options)
        elif bleachbit.IS_WINDOWS:
            self.assertIn('windows_only', xmlc.cleaner.options)
            self.assertNotIn('linux_only', xmlc.cleaner.options)
            self.assertNotIn('mac_only', xmlc.cleaner.options)
        elif bleachbit.IS_MAC:
            self.assertNotIn('windows_only', xmlc.cleaner.options)
            self.assertNotIn('linux_only', xmlc.cleaner.options)
            self.assertIn('mac_only', xmlc.cleaner.options)

    def test_pot_fragment(self):
        """Unit test for pot_fragment()"""
        self.assertIsString(pot_fragment("Foo", 'bar.xml'))

    def test_var(self):
        """Test the <var> element"""
        xml_str = r"""
<cleaner id="testvar">
    <label>cleaner label</label>
    <description>cleaner description</description>
    <var name="basepath">
        <value>%%LocalAppData%%\FooDoesNotExist</value>
        <value>~/.config/FooDoesNotExist</value>
        <value>{tempdir}/a</value>
        <value>{tempdir}/b</value>
    </var>
    <option id="option1">
        <label>option1 label</label>
        <description>option1 description</description>
        <action search="file" command="delete" path="$$basepath$$/test.log" />
    </option>
</cleaner>
""".format(**{'tempdir': self.tempdir})
        # write XML cleaner
        cml_path = os.path.join(self.tempdir, 'test.xml')
        self.write_file(cml_path, xml_str.encode(sys.getdefaultencoding()))

        # create two canaries
        test_log_path_a = os.path.join(self.tempdir, 'a', 'test.log')
        test_log_path_b = os.path.join(self.tempdir, 'b', 'test.log')
        common.touch_file(test_log_path_a)
        common.touch_file(test_log_path_b)
        self.assertExists(test_log_path_a)
        self.assertExists(test_log_path_b)

        # parse XML to XML cleaner instance
        xmlc = CleanerML(cml_path)
        self.assertIsInstance(xmlc, CleanerML)
        self.assertIsInstance(xmlc.cleaner, Cleaner.Cleaner)
        self.assertTrue(xmlc.cleaner.is_usable())

        # run preview
        self.run_all(xmlc, False)
        self.assertExists(test_log_path_a)
        self.assertExists(test_log_path_b)

        # really delete
        self.run_all(xmlc, True)
        self.assertNotExists(test_log_path_a)
        self.assertNotExists(test_log_path_b)
