tests/TestOptions.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 Options
"""
# These tests reach into internals on purpose.
# pylint: disable=protected-access
import errno
import os
import sys
import threading
from unittest import mock
from tests.common import pytest
from tests import common
import bleachbit.Options
from bleachbit import IS_WINDOWS
from bleachbit.Options import (
_option_index, _option_sort_key, _section_sort_key)
from bleachbit.Log import is_debugging_enabled_via_cli
class FakeTimer:
"""Fake timer to replace threading.Timer"""
instances = []
def __init__(self, interval, function, args=(), kwargs=None):
"""Init the fake timer"""
self.interval = interval
self.function = function
self.args = args
self.kwargs = kwargs or {}
self.daemon = False
self.started = False
self.cancelled = False
self.__class__.instances.append(self)
def cancel(self):
"""Cancel the fake timer."""
self.cancelled = True
def fire(self):
"""Fire the fake timer."""
if not self.cancelled:
self.function(*self.args, **self.kwargs)
def start(self):
"""Start the fake timer."""
self.started = True
class OptionsTestCase(common.BleachbitTestCase):
"""Test case for class Options"""
def _write_seed_options_file(self):
"""Write a seed options file for testing"""
with open(bleachbit.options_file, 'w', encoding='utf-8-sig') as handle:
handle.write(f'''[bleachbit]
version={bleachbit.APP_VERSION}
shred=False
check_online_updates=True
[hashpath]
[list/shred_drives]
''')
def test_option_index(self):
"""Unit test for _option_index"""
self.assertEqual(_option_index('0'), 0)
self.assertEqual(_option_index('3'), 3)
self.assertEqual(_option_index('1_type'), 1)
self.assertEqual(_option_index('2_path'), 2)
def _write_private_options_file(self, contents):
"""Point options_file to a file of this test only and seed it
This keeps the seed out of the options file shared by the whole
test case.
"""
filename = os.path.join(self.tempdir, f'{self.id()}.ini')
patcher = mock.patch('bleachbit.options_file', filename)
patcher.start()
self.addCleanup(patcher.stop)
with open(filename, 'w', encoding='utf-8-sig') as handle:
handle.write(contents)
return filename
@staticmethod
def _section_keys(contents, section):
"""Return the keys of one section of an ini file"""
keys = []
in_section = False
for line in contents.splitlines():
if line.startswith('['):
in_section = line == f'[{section}]'
elif in_section and '=' in line:
keys.append(line.split('=')[0].strip())
return keys
def test_option_sort_key(self):
"""Unit test for _option_sort_key"""
self.assertEqual(
sorted(['10_path', '2_path', '1_path'], key=_option_sort_key),
['1_path', '2_path', '10_path'])
self.assertEqual(
sorted(['shred', 'debug', 'Auto'], key=_option_sort_key),
['Auto', 'debug', 'shred'])
# numeric keys come before named keys
self.assertEqual(
sorted(['debug', '1'], key=_option_sort_key), ['1', 'debug'])
def test_sorted_file(self):
"""Test that the file is written with sections and keys sorted"""
filename = self._write_private_options_file('''[tree]
system.tmp = True
firefox.cache = True
[bleachbit]
shred = False
auto_hide = True
''')
o = bleachbit.Options.Options()
o.set_list('sort_test', ['a'] * 11)
o.close()
with open(filename, 'r', encoding='utf-8-sig') as handle:
contents = handle.read()
sections = [line[1:-1]
for line in contents.splitlines() if line.startswith('[')]
self.assertIn('bleachbit', sections)
self.assertIn('tree', sections)
self.assertEqual(sections, sorted(sections, key=_section_sort_key))
for section in sections:
keys = self._section_keys(contents, section)
self.assertEqual(keys, sorted(keys, key=_option_sort_key), section)
# a list must not be written as 1, 10, 2
self.assertEqual(self._section_keys(contents, 'list/sort_test'),
[str(i) for i in range(11)])
def test_defaults_are_written(self):
"""Test that untouched preferences are written to the file"""
filename = self._write_private_options_file(
'[bleachbit]\nshred = True\n')
o = bleachbit.Options.Options()
o.close()
with open(filename, 'r', encoding='utf-8-sig') as handle:
contents = handle.read()
keys = self._section_keys(contents, 'bleachbit')
for bkey in bleachbit.Options.boolean_keys:
self.assertIn(bkey, keys)
# an existing value must not be replaced by the default
self.assertIn('shred = True', contents)
o2 = bleachbit.Options.Options()
try:
self.assertTrue(o2.get('shred'))
for bkey in bleachbit.Options.boolean_keys:
self.assertIsInstance(o2.get(bkey), bool)
finally:
o2.close()
def test_Options(self):
"""Unit test for class Options"""
o = bleachbit.Options.options
value = o.get("check_online_updates")
# toggle a boolean
o.toggle('check_online_updates')
self.assertEqual(not value, o.get("check_online_updates"))
# restore original boolean
o.set("check_online_updates", value)
self.assertEqual(value, o.get("check_online_updates"))
# test delayed commit
shred = o.get("shred")
o.set("shred", not shred)
self.assertEqual(not shred, o.get("shred"))
o.restore() # abandon uncommitted changes
self.assertEqual(shred, o.get("shred"))
o.set("shred", shred)
o.close() # commit
self.assertEqual(shred, o.get("shred"))
# try a list
list_values = ['a', 'b', 'c']
o.set_list("list_test", list_values)
self.assertEqual(list_values, o.get_list("list_test"))
# these should always be set
for bkey in bleachbit.Options.boolean_keys:
self.assertIsInstance(o.get(bkey), bool)
# language
value = o.get_language('en')
self.assertIsInstance(value, bool)
o.set_language('en', True)
self.assertTrue(o.get_language('en'))
o.set_language('en', False)
self.assertFalse(o.get_language('en'))
o.set_language('en', value)
# tree
o.set_tree("parent", "child", True)
self.assertTrue(o.get_tree("parent", "child"))
o.set_tree("parent", "child", False)
self.assertFalse(o.get_tree("parent", "child"))
o.config.remove_option("tree", "parent.child")
self.assertFalse(o.get_tree("parent", "child"))
def test_keep_list(self):
"""Test for get_whitelist_paths() / set_whitelist_paths()"""
o = bleachbit.Options.options
# FIXME: finish renaming whitelist to keep list
self.assertIsInstance(o.get_whitelist_paths(), list)
keep_list = [('file', '/home/foo'), ('folder', '/home'),
('file', '/home/unicode/кодирование')]
old_keep_list = o.get_whitelist_paths()
o.config.remove_section('whitelist/paths')
self.assertIsInstance(o.get_whitelist_paths(), list)
self.assertEqual(o.get_whitelist_paths(), [])
o.set_whitelist_paths(keep_list)
self.assertIsInstance(o.get_whitelist_paths(), list)
self.assertEqual(set(keep_list), set(o.get_whitelist_paths()))
o.set_whitelist_paths(old_keep_list)
o.close()
self.assertEqual(set(old_keep_list), set(o.get_whitelist_paths()))
def test_init_configuration(self):
"""Test for init_configuration()"""
if os.path.exists(bleachbit.options_file):
os.remove(bleachbit.options_file)
self.assertNotExists(bleachbit.options_file)
bleachbit.Options.init_configuration()
self.assertExists(bleachbit.options_file)
def test_open_config_write_refuses_symlink(self):
"""The config write must not follow a symlink to another file"""
filename = self.write_file('cfg_target', b'keepme')
with mock.patch('bleachbit.FileUtilities.os.path.islink',
side_effect=lambda p: p == filename):
with self.assertRaises(OSError):
# nested with() so a regression that returns a handle
# instead of raising does not leak it
with bleachbit.Options._open_config_write(filename):
pass
with open(filename, 'rb') as f:
self.assertEqual(f.read(), b'keepme')
def test_is_corrupt(self):
"""Test is_corrupt()"""
def _test_is_corrupt(contents, expect_is_corrupt):
with open(bleachbit.options_file, 'w', encoding='utf-8-sig') as f:
f.write(contents)
o = bleachbit.Options.Options()
try:
self.assertEqual(o.is_corrupt(), expect_is_corrupt)
finally:
# cancel any real flush timer restore() may have scheduled
o.cancel_pending_flush()
# test blank
_test_is_corrupt('', False)
_test_is_corrupt('[bleachbit]\n', False)
# test valid but non-standard boolean
_test_is_corrupt('[bleachbit]\nshred=f\n', False)
# test invalid boolean
# https://github.com/bleachbit/bleachbit/issues/560#issuecomment-497361700
_test_is_corrupt("[bleachbit]\nshred=['True']\n", True)
os.remove(bleachbit.options_file)
def test_purge(self):
"""Test purging"""
# By default ConfigParser stores keys (the filenames) as lowercase.
# This needs special consideration when combined with purging.
o1 = bleachbit.Options.Options()
pathname = self.write_file('foo.xml')
myhash = '0ABCD'
o1.set_hashpath(pathname, myhash)
o1.close()
self.assertEqual(myhash, o1.get_hashpath(pathname))
if IS_WINDOWS:
# check case sensitivity
self.assertEqual(myhash, o1.get_hashpath(pathname.upper()))
# reopen
o2 = bleachbit.Options.Options()
# write something, which triggers the purge
o2.set('dummypath', 'dummyvalue', 'hashpath')
o2.commit()
# verify the path was not purged
self.assertTrue(os.path.exists(pathname))
self.assertEqual(myhash, o2.get_hashpath(pathname))
# delete the path
os.remove(pathname)
# close and reopen
o2.close()
o3 = bleachbit.Options.Options()
# write something, which triggers the purge
o3.set('dummypath', 'dummyvalue', 'hashpath')
o3.commit()
# verify the path was purged
self.assertIsNone(o3.get_hashpath(pathname))
o3.close()
def test_abbreviations(self):
"""Test non-standard, abbreviated booleans T and F"""
# set values
o = bleachbit.Options.options
if not o.config.has_section('test'):
o.config.add_section('test')
o.config.set('test', 'test_t_upper', 'T')
o.config.set('test', 'test_f_upper', 'F')
o.config.set('test', 'test_t_lower', 't')
o.config.set('test', 'test_f_lower', 'f')
# read
self.assertEqual(o.config.getboolean('test', 'test_t_upper'), True)
self.assertEqual(o.config.getboolean('test', 'test_t_lower'), True)
self.assertEqual(o.config.getboolean('test', 'test_f_upper'), False)
self.assertEqual(o.config.getboolean('test', 'test_f_lower'), False)
def test_percent(self):
"""Test that the percent sign can be used without quoting the string"""
# https://github.com/bleachbit/bleachbit/issues/205
opt = bleachbit.Options.options.config
if not opt.has_section('test'):
opt.add_section('test')
opt.set('test', 'filename', '/var/log/samba/log.%m')
# read
self.assertEqual(opt.get('test', 'filename'), '/var/log/samba/log.%m')
def test_error_disk_full(self):
"""Test graceful degradation when disk is full"""
disk_full_error = OSError('No space left on device')
disk_full_error.errno = errno.ENOSPC
o = bleachbit.Options.Options()
try:
with mock.patch('builtins.open', side_effect=disk_full_error):
with self.assertLogs(level='ERROR') as log_context:
o.set('test_key', 'test_value')
o.commit()
self.assertIn('Disk was full', log_context.output[0])
self.assertIn(bleachbit.options_file, log_context.output[0])
self.assertEqual(o.get('test_key'), 'test_value')
finally:
# forced commit() failure above never clears dirty/cancels the timer
o.cancel_pending_flush()
def test_error_permission(self):
"""Test graceful degradation with permission errors"""
permission_error = PermissionError('Permission denied')
permission_error.errno = errno.EACCES
o = bleachbit.Options.Options()
try:
with mock.patch('builtins.open', side_effect=permission_error):
with self.assertLogs(level='ERROR') as log_context:
o.set('test_key', 'test_value')
o.commit()
self.assertIn('Permission denied', log_context.output[0])
self.assertIn(bleachbit.options_file, log_context.output[0])
self.assertEqual(o.get('test_key'), 'test_value')
finally:
o.cancel_pending_flush()
def test_warning(self):
"""Test warning preferences"""
o = bleachbit.Options.options
self.assertFalse(o.get_warning_preference('test_key'))
o.remember_warning_preference('test_key')
self.assertTrue(o.get_warning_preference('test_key'))
o.close()
def test_warning_keys_with_colons(self):
"""Test restoring warning preferences with colons in keys"""
with open(bleachbit.options_file, 'w', encoding='utf-8-sig') as handle:
handle.write('''[bleachbit]
[warnings]
cleaner:google_chrome:passwords = True
cleaner:microsoft_edge:passwords = True
''')
o = None
try:
if sys.version_info >= (3, 10):
with self.assertNoLogs('bleachbit.Options', level='ERROR'):
o = bleachbit.Options.Options()
else:
o = bleachbit.Options.Options()
self.assertTrue(o.get_warning_preference(
'cleaner:google_chrome:passwords'))
self.assertTrue(o.get_warning_preference(
'cleaner:microsoft_edge:passwords'))
finally:
if o is not None:
o.close()
def test_corrupt_warning_preferences_are_migrated(self):
"""Test migration of warning preferences corrupted by colon parsing"""
with open(bleachbit.options_file, 'w', encoding='utf-8-sig') as handle:
handle.write('''[bleachbit]
[warnings]
cleaner = google_chrome:passwords = True
protected_path = /tmp = True
''')
o = bleachbit.Options.Options()
try:
self.assertTrue(o.get_warning_preference(
'cleaner:google_chrome:passwords'))
self.assertTrue(o.get_warning_preference('protected_path:/tmp'))
self.assertFalse(o.config.has_option('warnings', 'cleaner'))
self.assertFalse(o.config.has_option('warnings', 'protected_path'))
o.commit()
finally:
o.close()
with open(bleachbit.options_file, 'r', encoding='utf-8-sig') as handle:
contents = handle.read()
self.assertIn('cleaner:google_chrome:passwords = True', contents)
self.assertIn('protected_path:/tmp = True', contents)
self.assertNotIn('cleaner = google_chrome:passwords = True', contents)
self.assertNotIn('protected_path = /tmp = True', contents)
def test_overrides(self):
"""Test CLI override functionality"""
o = bleachbit.Options.options
debug_default = is_debugging_enabled_via_cli()
# Check defaults which are assumed in later tests.
self.assertFalse(o.get('shred'))
self.assertEqual(o.get('debug'), debug_default)
self.assertTrue(o.get('delete_confirmation'))
# Test basic override
o.set_override('shred', True)
self.assertTrue(o.get('shred'))
# Test that override takes precedence over get
o.set('shred', False)
self.assertTrue(o.get('shred')) # override should still return True
# Test multiple overrides
o.set_override('debug', True)
o.set_override('delete_confirmation', False)
self.assertTrue(o.get('debug'))
self.assertFalse(o.get('delete_confirmation'))
# Test that flush doesn't write overridden values
# First set persistent values different from overrides
o.set('shred', False)
o.set('debug', False)
o.set('delete_confirmation', True)
o.commit()
# Verify overrides still work after flush
self.assertTrue(o.get('shred'))
self.assertTrue(o.get('debug'))
self.assertFalse(o.get('delete_confirmation'))
# Create new instance to verify persistent values were written correctly
o2 = bleachbit.Options.Options()
self.assertFalse(o2.get('shred'))
self.assertEqual(o2.get('debug'), debug_default)
self.assertTrue(o2.get('delete_confirmation'))
o2.close()
def test_override_does_not_persist(self):
"""Test that overrides don't persist after process restart"""
o = bleachbit.Options.options
# Set an override
o.set_override('shred', True)
self.assertTrue(o.get('shred'))
# Create new instance (simulating process restart)
o2 = bleachbit.Options.Options()
# Override should not exist in new instance
self.assertNotIn(('bleachbit', 'shred'), o2.overrides)
o2.close()
# Clean up
o.reset_overrides()
def test_override_with_sections(self):
"""Test overrides work with different sections"""
o = bleachbit.Options.options
# Test override in non-default section
if not o.config.has_section('test_section'):
o.config.add_section('test_section')
o.config.set('test_section', 'test_key', 'original_value')
o.set_override('test_key', 'override_value', section='test_section')
self.assertEqual(
o.get('test_key', section='test_section'), 'override_value')
# Verify flush doesn't write override
o.commit()
o2 = bleachbit.Options.Options()
self.assertEqual(
o2.get('test_key', section='test_section'), 'original_value')
o2.close()
# Clean up
o.reset_overrides()
if o.config.has_section('test_section'):
o.config.remove_section('test_section')
def test_flush_mechanisms_timer_and_close(self):
"""Test that changes flush correctly via timer fire or explicit close()."""
with mock.patch('bleachbit.Options.threading.Timer', FakeTimer):
for commit_method in ['timer', 'close']:
with self.subTest(commit=commit_method):
self._write_seed_options_file()
o = bleachbit.Options.Options()
def assert_pending_changes_uncommitted(instance):
self.assertFalse(instance.get('shred'))
self.assertIsNone(instance.get_list('list_test'))
self.assertEqual([], instance.get_whitelist_paths())
self.assertEqual([], instance.get_custom_paths())
self.assertFalse(instance.get_language('zz'))
self.assertFalse(instance.get_tree('system', 'cache'))
self.assertFalse(
instance.get_warning_preference('warn_key'))
# Ensure initial state has no pending changes
assert_pending_changes_uncommitted(o)
# Guard against premature flush
with mock.patch('builtins.open', side_effect=AssertionError('unexpected flush')):
o.set('shred', True)
o.set_list('list_test', ['a', 'b'])
o.set_whitelist_paths([('file', '/tmp/a')])
o.set_custom_paths([('folder', '/tmp/b')])
o.set_language('zz', True)
o.set_tree('system', 'cache', True)
o.remember_warning_preference('warn_key')
pending_timer = FakeTimer.instances[-1]
self.assertTrue(pending_timer.started)
# Verify uncommitted changes are invisible to new instance
o2 = bleachbit.Options.Options()
assert_pending_changes_uncommitted(o2)
o2.close()
# Commit via the specific mechanism being tested
if commit_method == 'timer':
pending_timer.fire()
else:
o.close()
# Verify all changes persisted
o3 = bleachbit.Options.Options()
self.assertTrue(o3.get('shred'))
self.assertEqual(['a', 'b'], o3.get_list('list_test'))
self.assertEqual([('file', '/tmp/a')],
o3.get_whitelist_paths())
self.assertEqual([('folder', '/tmp/b')],
o3.get_custom_paths())
self.assertTrue(o3.get_language('zz'))
self.assertTrue(o3.get_tree('system', 'cache'))
self.assertTrue(o3.get_warning_preference('warn_key'))
o3.close()
if commit_method == 'timer':
o.close()
def test_mutators_flush_atomically_with_lock(self):
"""Test that mutating self.config and scheduling the flush is atomic"""
self._write_seed_options_file()
mutators = (
('set', lambda o: o.set('shred', 'RACE_PROBE')),
('set_list', lambda o: o.set_list('race_list', ['x'])),
('set_tree', lambda o: o.set_tree(
'race_parent', 'race_child', True)),
('remember_warning_preference',
lambda o: o.remember_warning_preference('race_key')),
)
with mock.patch('bleachbit.Options.threading.Timer', FakeTimer):
for name, mutate in mutators:
with self.subTest(mutator=name):
o = bleachbit.Options.Options()
entered = threading.Event()
finish = threading.Event()
real_schedule_flush = o._Options__schedule_flush
# blocks mid-flush so a concurrent lock probe can check who holds the lock
def blocking_schedule_flush():
# The closure runs inside the iteration that defines these names.
# pylint: disable=cell-var-from-loop
entered.set()
finish.wait(timeout=5)
real_schedule_flush()
with mock.patch.object(
o, '_Options__schedule_flush',
side_effect=blocking_schedule_flush):
t = threading.Thread(
target=mutate, args=(o,))
t.start()
self.assertTrue(
entered.wait(timeout=5),
f'{name}() never reached __schedule_flush')
# A `with` would block; this probes who holds the
# lock and must give up after the timeout.
# pylint: disable=consider-using-with
acquired = o._flush_lock.acquire(timeout=0.2)
if acquired:
o._flush_lock.release()
finish.set()
t.join(timeout=5)
self.assertFalse(
acquired,
f'{name}() released _flush_lock between '
'mutating self.config and scheduling the '
'flush - a concurrent background flush could '
'observe and persist the change before '
'restore() gets a chance to discard it')
# cancel_pending_flush(), not close(): probe values must never hit disk
o.cancel_pending_flush()
def test_restore_does_not_expose_partial_configuration(self):
"""Test restore() keeps setters out until all sections are rebuilt."""
self._write_seed_options_file()
o = bleachbit.Options.Options()
entered_read = threading.Event()
continue_restore = threading.Event()
setter_finished = threading.Event()
restore_errors = []
setter_errors = []
original_read_file = o.config.read_file
def blocking_read_file(*args, **kwargs):
entered_read.set()
continue_restore.wait(timeout=5)
return original_read_file(*args, **kwargs)
def restore():
try:
o.restore()
except Exception as error:
restore_errors.append(error)
def set_option():
try:
o.set('race_probe', 'True')
except Exception as error:
setter_errors.append(error)
finally:
setter_finished.set()
restore_thread = threading.Thread(target=restore)
setter_thread = threading.Thread(target=set_option)
try:
with mock.patch.object(
o.config, 'read_file', side_effect=blocking_read_file):
restore_thread.start()
self.assertTrue(entered_read.wait(timeout=5))
setter_thread.start()
self.assertFalse(
setter_finished.wait(timeout=0.2),
'set() ran while restore() had cleared self.config')
continue_restore.set()
restore_thread.join(timeout=5)
setter_thread.join(timeout=5)
self.assertFalse(restore_thread.is_alive())
self.assertFalse(setter_thread.is_alive())
self.assertEqual([], restore_errors)
self.assertEqual([], setter_errors)
self.assertTrue(o.get('race_probe'))
finally:
continue_restore.set()
restore_thread.join(timeout=5)
setter_thread.join(timeout=5)
o.cancel_pending_flush()
o.close()
def test_close_unregisters_atexit_and_is_idempotent(self):
"""Test close() unregisters its atexit callback and only flushes once."""
self._write_seed_options_file()
with mock.patch('bleachbit.Options.atexit.unregister') as mock_unregister:
o = bleachbit.Options.Options()
with mock.patch.object(o, '_Options__flush') as mock_flush:
o.close()
o.close()
mock_flush.assert_called_once_with(force=False)
mock_unregister.assert_called_once()
@pytest.mark.no_xdist
def test_unicode_paths(self):
"""Test that paths with various Unicode characters"""
# On Linux, os.listdir() may return filenames with surrogate-escaped
# bytes (e.g. invalid UTF-8 byte sequences decoded with
# 'surrogateescape'). These contain unpaired surrogates (U+D800 to
# U+DFFF) which cannot be encoded as UTF-8, causing UnicodeEncodeError
# when ConfigParser writes the .ini file.
# https://github.com/bleachbit/bleachbit/issues/2082
test_cases = [
('surrogate', '/tmp/invalid_unicode_surrogate_\udce9'),
('cjk', '/tmp/中文日本語한국어'),
('european', '/tmp/àéïöüñçß'),
('cyrillic', '/tmp/кодирование'),
('arabic', '/tmp/العربية'),
('emoji', '/tmp/🎉🚀💻'),
('mixed', '/tmp/mixed_中文_àéï_\udce9'),
]
def verify_paths(o, keep_list, custom_list):
"""Helper to verify whitelist and custom paths"""
o.set_whitelist_paths(keep_list)
o.commit()
self.assertEqual(keep_list, o.get_whitelist_paths())
o.set_custom_paths(custom_list)
o.commit()
self.assertEqual(custom_list, o.get_custom_paths())
# Verify round-trip: new instance reads from disk
o2 = bleachbit.Options.Options()
self.assertEqual(keep_list, o2.get_whitelist_paths())
self.assertEqual(custom_list, o2.get_custom_paths())
o2.close()
o = bleachbit.Options.options
for name, path in test_cases:
with self.subTest(unicode_type=name):
keep_list = [('file', path)]
custom_list = [('folder', path)]
verify_paths(o, keep_list, custom_list)
o.close()