diff --git a/migrations/1788380505.sh b/migrations/1788380505.sh new file mode 100644 index 00000000000..9fb54e8b609 --- /dev/null +++ b/migrations/1788380505.sh @@ -0,0 +1,281 @@ +echo "Repair an existing SDDM Qt5 theme selection with missing greeter libraries" + +# This is a one-time repair, not a recurring theme-selection guard. +# An existing theme with SddmGreeterTheme/QtVersion=5 (the default) selects +# /usr/bin/sddm-greeter. If that executable lacks libraries, login stays black. +# Missing themes or executables already fall back safely in SDDM itself. +# Preserve working selections and replace only a confirmed broken Qt5 choice. + +sddm_conf="${OMARCHY_SDDM_CONF:-/etc/sddm.conf}" +sddm_conf_dir="${OMARCHY_SDDM_CONF_DIR:-/etc/sddm.conf.d}" +sys_conf_dir="${OMARCHY_SDDM_SYS_CONF_DIR:-/usr/lib/sddm/sddm.conf.d}" +theme_dir="${OMARCHY_SDDM_THEME_DIR:-/usr/share/sddm/themes}" +qt5_greeter="${OMARCHY_SDDM_QT5_GREETER:-/usr/bin/sddm-greeter}" +qt6_greeter="${OMARCHY_SDDM_QT6_GREETER:-/usr/bin/sddm-greeter-qt6}" +backup_dir="${OMARCHY_SDDM_BACKUP_DIR:-/var/lib/omarchy/sddm-backups}" +proc_root="${OMARCHY_SDDM_PROC_ROOT:-/proc}" + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +as_root python3 -I - "$sddm_conf" "$sddm_conf_dir" "$sys_conf_dir" "$theme_dir" "$qt5_greeter" "$qt6_greeter" "$backup_dir" "$proc_root" <<'PY' +import ctypes +import ctypes.util +import io +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import tempfile + +conf, conf_dir, sys_dir, themes, qt5, qt6, backups, proc_root = map(Path, sys.argv[1:]) +os.umask(0o077) +os.chdir('/') + + +def service_locale(): + def main_pid(): + result = subprocess.run(['systemctl', 'show', 'sddm.service', '--property=MainPID', '--value'], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=5) + return result.stdout.strip() if result.returncode == 0 else '' + try: + pid = main_pid() + if not re.fullmatch(r'[1-9][0-9]*', pid): + return None + fd = os.open(proc_root / pid, os.O_RDONLY | os.O_DIRECTORY) + try: + if os.readlink('exe', dir_fd=fd).removesuffix(' (deleted)') != '/usr/bin/sddm': + return None + environment = {} + with os.fdopen(os.open('environ', os.O_RDONLY, dir_fd=fd), 'rb') as source: + for entry in source.read().split(b'\0'): + key, _, value = entry.partition(b'=') + if key in (b'LC_ALL', b'LC_COLLATE', b'LANG'): + environment[key] = value + finally: + os.close(fd) + if main_pid() == pid: + return (environment.get(b'LC_ALL') or environment.get(b'LC_COLLATE') or environment.get(b'LANG') or b'C').decode('ascii').split('.')[0] + except (OSError, ValueError, subprocess.TimeoutExpired): + return None + + +# SDDM v0.21.0 ConfigReader.cpp loads vendor files, local files, then sddm.conf. +# Match Qt's ICU collation, not libc sort order; the last [Theme] value wins. +def directory_files(files): + language = service_locale() + if language is None: + raise RuntimeError('Cannot determine the running SDDM service locale for competing [Theme] settings; leaving SDDM unchanged') + if language in ('C', 'POSIX') or len(files) < 2: + return sorted(files, key=lambda p: p.name.encode('utf-16-be')) + library = ctypes.util.find_library('icui18n') + if not library: + raise RuntimeError('Cannot load Qt\'s ICU collator to resolve SDDM configuration order') + icu = ctypes.CDLL(library) + version = re.search(r'\.so\.(\d+)', library) + suffix = '_' + version[1] if version else '' + open_collator = getattr(icu, 'ucol_open' + suffix) + open_collator.argtypes, open_collator.restype = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int)], ctypes.c_void_p + sort_key = getattr(icu, 'ucol_getSortKey' + suffix) + sort_key.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_int] + close_collator = getattr(icu, 'ucol_close' + suffix) + close_collator.argtypes, close_collator.restype = [ctypes.c_void_p], None + set_attribute = getattr(icu, 'ucol_setAttribute' + suffix) + set_attribute.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.c_int)] + set_attribute.restype = None + error = ctypes.c_int(0) + collator = open_collator(language.encode(), ctypes.byref(error)) + if error.value > 0 or not collator: + raise RuntimeError(f'Cannot initialize the ICU collator for {language}') + def key(path): + name = path.name.encode('utf-16-le' if sys.byteorder == 'little' else 'utf-16-be') + length = sort_key(collator, name, len(name) // 2, None, 0) + buffer = ctypes.create_string_buffer(length) + sort_key(collator, name, len(name) // 2, buffer, length) + return buffer.raw + try: + NORMALIZATION, STRENGTH, NUMERIC, ALTERNATE = 4, 5, 7, 1 + ON, TERTIARY, OFF, NON_IGNORABLE = 17, 2, 16, 21 + for attribute, value in ((NORMALIZATION, ON), (STRENGTH, TERTIARY), (NUMERIC, OFF), (ALTERNATE, NON_IGNORABLE)): + set_attribute(collator, attribute, value, ctypes.byref(error)) + if error.value > 0: + raise RuntimeError('Cannot configure the ICU collator to match Qt') + keys = {path: key(path) for path in files} + if len(set(keys.values())) != len(keys): + raise RuntimeError('SDDM configuration filenames have ambiguous ICU ordering; leaving SDDM unchanged') + return sorted(files, key=keys.__getitem__) + finally: + close_collator(collator) + + +def configuration(): + groups = [[p for p in directory.iterdir() if not p.name.startswith('.') and p.is_file()] + if directory.exists() else [] for directory in (sys_dir, conf_dir)] + groups.append([conf] if conf.exists() else []) + values = {'Current': '', 'ThemeDir': str(themes)} + contents, assignments, resolved = {}, {}, {} + active = None + for path in (p for group in groups for p in group): + contents[path] = path.read_bytes() + assignments[path] = {} + section = 'General' + for number, raw in enumerate(io.StringIO(contents[path].decode('utf-8'))): + line = raw.split('#', 1)[0].strip() + if '=' in line: + key, value = (part.strip() for part in line.split('=', 1)) + if section == 'Theme' and key in values: + assignments[path][key] = (value, number) + elif line.startswith('[') and line.endswith(']'): + section = line[1:-1] + for group in reversed(groups): + ordered = None + for key in values.keys() - resolved.keys(): + choices = [p for p in group if key in assignments[p]] + if not choices: + continue + if len({assignments[p][key][0] for p in choices}) > 1: + if ordered is None: + ordered = directory_files(group) + choices = [next(p for p in reversed(ordered) if key in assignments[p])] + path = choices[0] + resolved[key], number = assignments[path][key] + if key == 'Current' and len(choices) == 1: + active = (path, number) + values.update(resolved) + return values, contents, active + + +def qt_version(theme): + path = theme / 'metadata.desktop' + if not path.exists(): + return 5 + section, version = '', '5' + for raw in re.split(r'\r\n|\r|\n', path.read_text(encoding='utf-8-sig')): + line = raw.strip() + if not line or line.startswith(('#', ';')): + continue + if '\\' in line: + raise RuntimeError(f'Cannot safely interpret escaped SDDM metadata: {path}') + quoted = False + for offset, character in enumerate(line): + if character == '"': + quoted = not quoted + elif character == ';' and not quoted: + line = line[:offset].strip() + break + if quoted: + raise RuntimeError(f'Cannot safely interpret multiline SDDM metadata: {path}') + if line.startswith('['): + if not re.fullmatch(r'\[[^\[\]]+\]', line): + raise RuntimeError(f'Cannot safely interpret malformed SDDM metadata section: {path}') + section = line[1:-1].strip() + if section == 'General': + section = '' + elif '=' in line: + key, value = (part.strip() for part in line.split('=', 1)) + key = re.sub(r'%U([\da-fA-F]{4})|%([\da-fA-F]{2})', + lambda match: chr(int(match[1] or match[2], 16)), '/'.join(filter(None, (section, key)))) + if key == 'SddmGreeterTheme/QtVersion': + version = value.replace('"', '').strip() + if version.startswith('@'): + if not (version.startswith('@String(') and version.endswith(')')): + raise RuntimeError(f'Cannot safely interpret typed SDDM QtVersion metadata: {path}') + version = version[8:-1].strip() + return int(version) if re.fullmatch(r'[+-]?[0-9]+', version) else 0 + + +# Consume ldd completely: grep -q can cause SIGPIPE and invert a pipefail test. +# Probe failures are errors, not evidence that the greeter is healthy. +def missing_libraries(binary): + result = subprocess.run(['ldd', str(binary)], capture_output=True, text=True, + env=dict(os.environ, LC_ALL='C')) + if result.returncode: + raise RuntimeError(f'ldd failed for {binary} ({result.returncode}): {result.stderr.strip()}') + return re.search(r'=>\s+not found(?:\s|$)', result.stdout) is not None + + +def trusted_path(path): + for part in (path, *path.parents): + if not part.exists() and not part.is_symlink(): + continue + info = part.lstat() + sticky_parent = part != path and stat.S_ISDIR(info.st_mode) and info.st_mode & stat.S_ISVTX + if stat.S_ISLNK(info.st_mode) or info.st_uid not in (0, os.geteuid()) or (info.st_mode & 0o022 and not sticky_parent): + raise RuntimeError(f'Refusing an untrusted SDDM repair path: {part}') + + +# Save recovery copies outside SDDM's loaded directories, without replacing any +# prior backup. Publish only the effective assignment through an atomic rename. +def repair(): + if not os.access(qt5, os.X_OK): + return + values, contents, active = configuration() + name = values['Current'] + selected = Path(values['ThemeDir']) / name + if not name or not selected.exists() or qt_version(selected) != 5: + return + if not missing_libraries(qt5): + return + replacement = themes / 'omarchy' + if not replacement.is_dir() or qt_version(replacement) != 6 or not os.access(qt6, os.X_OK) or missing_libraries(qt6): + raise RuntimeError('The packaged Omarchy Qt6 greeter is not a safe replacement; leaving SDDM unchanged') + replacement_name = 'omarchy' if Path(values['ThemeDir']) == themes else str(replacement.absolute()) + source, number = active if active else (None, None) + target = source if source and (source == conf or (source.parent == conf_dir and source.suffix == '.conf')) else conf + trusted_path(target) + trusted_path(backups) + if any(backups.resolve().is_relative_to(directory.resolve()) for directory in (sys_dir, conf_dir)): + raise RuntimeError('SDDM recovery backups must be outside its loaded configuration directories') + original = contents.get(target, b'') + info = target.lstat() if target.exists() else None + if info and (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1): + raise RuntimeError(f'Refusing to replace a non-regular or hard-linked SDDM configuration: {target}') + if target == source: + lines = list(io.StringIO(original.decode('utf-8'))) + line = lines[number] + prefix = re.match(r'\s*Current\s*=\s*', line).group() + end = len(line.split('#', 1)[0].rstrip()) + lines[number] = prefix + replacement_name + line[end:] + updated = ''.join(lines).encode('utf-8') + else: + updated = original + (b'\n' if original and not original.endswith(b'\n') else b'') + updated += f'[Theme]\nCurrent={replacement_name}\n'.encode('utf-8') + if configuration() != (values, contents, active): + raise RuntimeError('SDDM configuration changed during the repair; retry the migration') + if info: + backups.mkdir(mode=0o700, parents=True, exist_ok=True) + backup = Path(tempfile.mkdtemp(prefix='1788380505-', dir=backups)) / target.name + with backup.open('xb') as output: + output.write(original) + output.flush() + os.fsync(output.fileno()) + print(f'Saved SDDM recovery copy: {backup}', flush=True) + fd, temporary = tempfile.mkstemp(prefix='.omarchy-sddm-', dir=target.parent) + try: + with os.fdopen(fd, 'wb') as output: + output.write(updated) + if info: + os.fchown(output.fileno(), info.st_uid, info.st_gid) + os.fchmod(output.fileno(), stat.S_IMODE(info.st_mode) if info else 0o644) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + print(f"Reset SDDM theme from '{name}' to '{replacement_name}' because the Qt5 greeter has missing libraries.") + + +try: + repair() +except (OSError, ValueError, RuntimeError, AttributeError) as error: + print(f'SDDM theme repair failed: {error}', file=sys.stderr) + sys.exit(1) +PY diff --git a/test/shell.d/sddm-qt6-guard-test.sh b/test/shell.d/sddm-qt6-guard-test.sh new file mode 100644 index 00000000000..3dedc9fdb9c --- /dev/null +++ b/test/shell.d/sddm-qt6-guard-test.sh @@ -0,0 +1,605 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1788380505.sh" +[[ -f $migration ]] || fail "SDDM Qt6 guard migration exists" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +test_dir=$(cd -- "$test_dir" && pwd -P) + +mkdir -p "$test_dir/bin" "$test_dir/etc/sddm.conf.d" "$test_dir/usr/share/sddm/themes/"{omarchy,maya,custom6} "$test_dir/usr/bin" "$test_dir/proc/23456" +printf 'LC_ALL=C\0' >"$test_dir/proc/23456/environ" +ln -s /usr/bin/sddm "$test_dir/proc/23456/exe" + +cat >"$test_dir/bin/systemctl" <<'STUB' +#!/bin/bash +printf '23456\n' +STUB +chmod +x "$test_dir/bin/systemctl" + +cat >"$test_dir/bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +chmod +x "$test_dir/bin/sudo" + +cat >"$test_dir/bin/ldd" <<'STUB' +#!/bin/bash +# Fake ldd: prints missing-library lines when the binary name contains -broken. +if [[ ${1##*/} == *-broken* ]]; then + printf 'libQt5Core.so.5 => not found\nlibQt5Gui.so.5 => not found\n' +fi +STUB +chmod +x "$test_dir/bin/ldd" + +cat >"$test_dir/usr/share/sddm/themes/omarchy/metadata.desktop" <<'EOF' +[SddmGreeterTheme] +Name=Omarchy +QtVersion=6 +EOF + +cat >"$test_dir/usr/share/sddm/themes/maya/metadata.desktop" <<'EOF' +[SddmGreeterTheme] +Name=Maya +EOF + +cat >"$test_dir/usr/share/sddm/themes/custom6/metadata.desktop" <<'EOF' +[SddmGreeterTheme] +Name=Custom +QtVersion=6 +EOF + +touch "$test_dir/usr/bin/sddm-greeter-good" +touch "$test_dir/usr/bin/sddm-greeter-broken" +touch "$test_dir/usr/bin/sddm-greeter-qt6" +chmod +x "$test_dir/usr/bin/"sddm-greeter-* + +run_migration() { + HOME="$test_dir/home" \ + OMARCHY_SDDM_CONF="$test_dir/etc/sddm.conf" \ + OMARCHY_SDDM_CONF_DIR="$test_dir/etc/sddm.conf.d" \ + OMARCHY_SDDM_SYS_CONF_DIR="$test_dir/usr/lib/sddm/sddm.conf.d" \ + OMARCHY_SDDM_PROC_ROOT="$test_dir/proc" \ + OMARCHY_SDDM_BACKUP_DIR="$test_dir/backups" \ + OMARCHY_SDDM_THEME_DIR="$test_dir/usr/share/sddm/themes" \ + OMARCHY_SDDM_QT5_GREETER="$test_dir/usr/bin/$1" \ + OMARCHY_SDDM_QT6_GREETER="$test_dir/usr/bin/sddm-greeter-qt6" \ + PATH="$test_dir/bin:$PATH" \ + bash -euo pipefail "$migration" +} + +# Scenario 1: the active theme already declares QtVersion=6 — no change. +{ + reset_state() { + rm -rf "$test_dir/etc" "$test_dir/home" + mkdir -p "$test_dir/etc/sddm.conf.d" "$test_dir/home" + } + reset_state + printf '[Theme]\nCurrent=omarchy\n' >"$test_dir/etc/sddm.conf.d/10-theme.conf" + run_migration sddm-greeter-broken >/dev/null + grep -Fx 'Current=omarchy' "$test_dir/etc/sddm.conf.d/10-theme.conf" >/dev/null || + fail "safe Qt6 theme is left unchanged" +} + +# Scenario 2: an existing Qt5 theme is active with missing greeter libraries — reset. +{ + reset_state + printf '[Theme]\nCurrent=omarchy\n' >"$test_dir/etc/sddm.conf.d/10-theme.conf" + printf '[Theme]\nCurrent=maya\n' >"$test_dir/etc/sddm.conf.d/99-user-theme.conf" + run_migration sddm-greeter-broken >/dev/null + grep -Fx 'Current=omarchy' "$test_dir/etc/sddm.conf.d/99-user-theme.conf" >/dev/null || + fail "unsafe non-Qt6 theme is reset to omarchy" + grep -Fx 'Current=omarchy' "$test_dir/etc/sddm.conf.d/10-theme.conf" >/dev/null || + fail "packaged theme file stays valid" +} + +# Scenario 3: a Qt5 theme is active but its greeter links successfully — leave it. +{ + reset_state + printf '[Theme]\nCurrent=maya\n' >"$test_dir/etc/sddm.conf.d/10-theme.conf" + run_migration sddm-greeter-good >/dev/null + grep -Fx 'Current=maya' "$test_dir/etc/sddm.conf.d/10-theme.conf" >/dev/null || + fail "non-Qt6 theme is left alone when Qt5 greeter works" +} + +# Scenario 4: a custom theme declares QtVersion=6 even though the Qt5 greeter is broken. +{ + reset_state + printf '[Theme]\nCurrent=custom6\n' >"$test_dir/etc/sddm.conf.d/10-theme.conf" + run_migration sddm-greeter-broken >/dev/null + grep -Fx 'Current=custom6' "$test_dir/etc/sddm.conf.d/10-theme.conf" >/dev/null || + fail "custom Qt6 theme is left unchanged" +} + +# Scenario 5: no Current= is configured anywhere — nothing to guard. +{ + reset_state + run_migration sddm-greeter-broken >/dev/null + [[ ! -e $test_dir/etc/sddm.conf.d/10-theme.conf ]] || + fail "guard does not create a theme file when none exists" +} + +pass "SDDM Qt6 theme guard resets unsafe themes and preserves safe ones" + +python3 - "$migration" <<'PY' +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +migration = sys.argv.pop() + + +class SddmRepairTest(unittest.TestCase): + def setUp(self): + self.scratch = tempfile.TemporaryDirectory() + self.addCleanup(self.scratch.cleanup) + self.root = Path(self.scratch.name).resolve() + for directory in ('etc/sddm.conf.d', 'vendor', 'bin', 'home', 'backups'): + (self.root / directory).mkdir(parents=True) + self.env = dict(os.environ, HOME=str(self.root / 'home'), LC_ALL='C', + OMARCHY_SDDM_CONF=str(self.root / 'etc/sddm.conf'), + OMARCHY_SDDM_CONF_DIR=str(self.root / 'etc/sddm.conf.d'), + OMARCHY_SDDM_SYS_CONF_DIR=str(self.root / 'vendor'), + OMARCHY_SDDM_PROC_ROOT=str(self.root / 'proc'), + OMARCHY_SDDM_THEME_DIR=str(self.root / 'themes'), + OMARCHY_SDDM_BACKUP_DIR=str(self.root / 'backups'), + OMARCHY_SDDM_QT5_GREETER=str(self.root / 'bin/greeter5'), + OMARCHY_SDDM_QT6_GREETER=str(self.root / 'bin/greeter6'), + PATH=str(self.root / 'bin') + ':' + os.environ['PATH']) + for name in ('greeter5', 'greeter6'): + self.put('bin/' + name, '') + (self.root / 'bin' / name).chmod(0o755) + self.put('bin/ldd', '\n'.join(( + '#!/bin/bash', + 'if [[ $1 == *greeter5 ]]; then', + ' case ${SDDM_TEST_LDD:-broken} in', + " good) printf 'libQt5Core.so.5 => /usr/lib/libQt5Core.so.5\\n' ;;", + " error) echo 'ldd: failed to inspect greeter' >&2; exit 42 ;;", + " changed) printf '# administrator edit\\n' >>\"$OMARCHY_SDDM_CONF_DIR/10-theme.conf\"; printf 'libQt5Core.so.5 => not found\\n' ;;", + " large) printf 'libQt5Core.so.5 => not found\\n'; for ((i=0; i<10000; i++)); do printf 'libother.so => /usr/lib/libother.so\\n'; done ;;", + " *) printf 'libQt5Core.so.5 => not found\\n' ;;", + ' esac', + "elif [[ ${SDDM_TEST_QT6_BROKEN:-0} == 1 ]]; then", + " printf 'libQt6Core.so.6 => not found\\n'", + 'fi', + ''))) + (self.root / 'bin/ldd').chmod(0o755) + self.put('bin/sudo', '#!/bin/bash\nexec "$@"\n') + (self.root / 'bin/sudo').chmod(0o755) + self.put('bin/systemctl', '\n'.join(( + '#!/bin/bash', + '[[ $* == "show sddm.service --property=MainPID --value" ]] || exit 2', + 'touch "$OMARCHY_SDDM_PROC_ROOT/queried"', + 'printf "%s\\n" "${SDDM_TEST_MAIN_PID:-23456}"', + 'exit "${SDDM_TEST_SYSTEMCTL_STATUS:-0}"', + ''))).chmod(0o755) + self.put('proc/23456/environ', 'LC_ALL=C\0') + (self.root / 'proc/23456/exe').symlink_to('/usr/bin/sddm') + for theme, metadata in {'omarchy': 'QtVersion=6', 'maya': 'Name=Maya', 'custom6': 'QtVersion=6'}.items(): + self.put('themes/' + theme + '/metadata.desktop', '[SddmGreeterTheme]\n' + metadata + '\n') + self.dropin = 'etc/sddm.conf.d/10-theme.conf' + + def put(self, path, text): + dest = self.root / path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(text.encode()) + return dest + + def get(self, path): + return (self.root / path).read_bytes().decode() + + def run_repair(self, success=True): + result = subprocess.run(['bash', '-euo', 'pipefail', migration], env=self.env, cwd=self.root / 'home', capture_output=True, text=True) + if success: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + else: + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + return result + + def snapshot(self): + return {str(p.relative_to(self.root)): p.read_bytes() for directory in ('etc', 'vendor', 'backups') + for p in (self.root / directory).rglob('*') if p.is_file()} + + def assert_unchanged(self, success=True): + before = self.snapshot() + self.run_repair(success) + self.assertEqual(self.snapshot(), before) + + def backups(self): + return [p for p in (self.root / 'backups').rglob('*') if p.is_file()] + + def test_etc_conf_has_highest_precedence(self): + self.put(self.dropin, '[Theme]\nCurrent=omarchy\n') + self.put('etc/sddm.conf', '[Theme]\nCurrent=maya\n') + self.run_repair() + self.assertEqual(self.get('etc/sddm.conf'), '[Theme]\nCurrent=omarchy\n') + self.assertEqual(self.get(self.dropin), '[Theme]\nCurrent=omarchy\n') + + @unittest.skipUnless(sys.platform.startswith('linux'), 'Linux ICU sorting') + def test_locale_aware_directory_order_matches_qt_icu(self): + for environment in ('LANG=en_US.UTF-8\0', 'LANG=C\0LC_COLLATE=en_US.UTF-8\0', + 'LANG=C\0LC_COLLATE=C\0LC_ALL=en_US.UTF-8\0'): + with self.subTest(environment=environment): + self.put('proc/23456/environ', environment + 'PRIVATE=do-not-print-this\0') + self.put('etc/sddm.conf.d/a.conf', '[Theme]\nCurrent=maya\n') + self.put('etc/sddm.conf.d/a_.conf', '[Theme]\nCurrent=custom6\n') + result = self.run_repair() + self.assertNotIn('do-not-print-this', result.stdout + result.stderr) + self.assertEqual(self.get('etc/sddm.conf.d/a.conf'), '[Theme]\nCurrent=omarchy\n') + self.assertEqual(self.get('etc/sddm.conf.d/a_.conf'), '[Theme]\nCurrent=custom6\n') + + @unittest.skipUnless(sys.platform.startswith('linux'), 'Linux ICU sorting') + def test_equal_icu_keys_are_not_given_a_guessed_order(self): + self.put('proc/23456/environ', 'LC_ALL=en_US.UTF-8\0') + for i in range(20): + self.put('etc/sddm.conf.d/a' + '\u200d' * i + '.conf', '[Theme]\nCurrent=' + ('maya' if i else 'custom6') + '\n') + before = self.snapshot() + result = self.run_repair(success=False) + self.assertIn('ambiguous', result.stderr) + self.assertEqual(self.snapshot(), before) + + def test_service_c_locale_overrides_updater_locale(self): + self.env['LC_ALL'] = 'en_US.UTF-8' + self.put('etc/sddm.conf.d/a.conf', '[Theme]\nCurrent=maya\n') + self.put('etc/sddm.conf.d/a_.conf', '[Theme]\nCurrent=custom6\n') + for environment in ('', 'LANG=\0LC_ALL=\0', 'LANG=en_US.UTF-8\0LC_COLLATE=C\0', + 'LANG=en_US.UTF-8\0LC_COLLATE=en_US.UTF-8\0LC_ALL=C\0'): + with self.subTest(environment=environment): + self.put('proc/23456/environ', environment) + self.assert_unchanged() + + def test_unavailable_service_locale_does_not_guess_competing_settings(self): + self.put('etc/sddm.conf.d/a.conf', '[Theme]\nCurrent=maya\n') + self.put('etc/sddm.conf.d/a_.conf', '[Theme]\nCurrent=custom6\n') + for pid in ('0', 'not-a-pid', '99999', '../23456'): + with self.subTest(pid=pid): + self.env['SDDM_TEST_MAIN_PID'] = pid + before = self.snapshot() + result = self.run_repair(success=False) + self.assertIn('service locale', result.stderr) + self.assertEqual(self.snapshot(), before) + self.env['SDDM_TEST_MAIN_PID'] = '23456' + self.env['SDDM_TEST_SYSTEMCTL_STATUS'] = '1' + self.assert_unchanged(success=False) + self.env['SDDM_TEST_SYSTEMCTL_STATUS'] = '0' + (self.root / 'proc/23456/environ').unlink() + self.assert_unchanged(success=False) + + def test_service_locale_is_not_taken_from_another_executable(self): + self.put('etc/sddm.conf.d/a.conf', '[Theme]\nCurrent=maya\n') + self.put('etc/sddm.conf.d/a_.conf', '[Theme]\nCurrent=custom6\n') + executable = self.root / 'proc/23456/exe' + executable.unlink() + executable.symlink_to('/usr/bin/user-session') + self.assert_unchanged(success=False) + executable.unlink() + executable.symlink_to('/usr/bin/sddm (deleted)') + self.assert_unchanged() + + def test_service_pid_change_during_locale_read_stays_pending(self): + self.put('bin/systemctl', '\n'.join(( + '#!/bin/bash', + 'if [[ -e $OMARCHY_SDDM_PROC_ROOT/queried ]]; then', + " printf '0\\n'", + 'else', + ' touch "$OMARCHY_SDDM_PROC_ROOT/queried"', + " printf '23456\\n'", + 'fi', + ''))).chmod(0o755) + self.put('etc/sddm.conf.d/a.conf', '[Theme]\nCurrent=maya\n') + self.put('etc/sddm.conf.d/a_.conf', '[Theme]\nCurrent=custom6\n') + self.assert_unchanged(success=False) + + def test_stopped_service_allows_unambiguous_settings_and_higher_overrides(self): + self.env['SDDM_TEST_MAIN_PID'] = '0' + self.env['LC_ALL'] = 'not-a-locale' + self.put('vendor/default.conf', '[Theme]\nCurrent=maya\n') + self.put('etc/sddm.conf.d/a.conf', '[Theme]\nCurrent=custom6\n') + self.put('etc/sddm.conf.d/a_.conf', '[Theme]\nCurrent=custom6\n[Users]\nHideUsers=private\n') + self.assert_unchanged() + self.put('etc/sddm.conf.d/a.conf', '[Theme]\nCurrent=maya\n') + self.put('etc/sddm.conf', '[Theme]\nCurrent=custom6\n') + self.assert_unchanged() + self.put('etc/sddm.conf', '[Theme]\nCurrent=maya\n') + self.run_repair() + self.assertEqual(self.get('etc/sddm.conf'), '[Theme]\nCurrent=omarchy\n') + self.assertFalse((self.root / 'proc/queried').exists()) + + def test_stopped_service_same_broken_choice_gets_an_override(self): + self.env['SDDM_TEST_MAIN_PID'] = '0' + for name in ('a.conf', 'a_.conf'): + self.put('etc/sddm.conf.d/' + name, '[Theme]\nCurrent=maya\n') + self.run_repair() + self.assertEqual(self.get('etc/sddm.conf'), '[Theme]\nCurrent=omarchy\n') + for name in ('a.conf', 'a_.conf'): + self.assertEqual(self.get('etc/sddm.conf.d/' + name), '[Theme]\nCurrent=maya\n') + + def test_stopped_service_does_not_guess_theme_directory(self): + self.env['SDDM_TEST_MAIN_PID'] = '0' + self.put(self.dropin, '[Theme]\nCurrent=custom6\n') + self.put('vendor/a.conf', '[Theme]\nThemeDir=/missing\n') + self.put('vendor/a_.conf', '[Theme]\nThemeDir=' + str(self.root / 'themes') + '\n') + self.assert_unchanged(success=False) + self.put('etc/sddm.conf', '[Theme]\nThemeDir=' + str(self.root / 'themes') + '\n') + self.assert_unchanged() + + def test_missing_qt5_executable_needs_no_service_locale(self): + self.env['SDDM_TEST_MAIN_PID'] = '0' + self.put('etc/sddm.conf.d/a.conf', '[Theme]\nCurrent=maya\n') + self.put('etc/sddm.conf.d/a_.conf', '[Theme]\nCurrent=custom6\n') + (self.root / 'bin/greeter5').unlink() + self.assert_unchanged() + + def test_metadata_escaped_group_and_quoted_semicolon_follow_qsettings(self): + for metadata in ('[%53ddmGreeterTheme]\nQtVersion=6\n', + '[SddmGreeterTheme]\nQtVersion="5;not a number"\n'): + with self.subTest(metadata=metadata): + self.put('themes/custom6/metadata.desktop', metadata) + self.put(self.dropin, '[Theme]\nCurrent=custom6\n') + self.assert_unchanged() + + def test_only_newline_delimits_sddm_config_records(self): + self.put(self.dropin, '[Theme]\nCurrent=custom6\vCurrent=maya\n') + self.assert_unchanged() + + def test_safe_high_priority_config_does_not_modify_inactive_choices(self): + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + self.put('etc/sddm.conf', '[Theme]\nCurrent=custom6\n') + self.assert_unchanged() + + def test_all_visible_directory_files_participate(self): + for suffix in ('.pacsave', '.pacnew', '.bak', '~', '.txt'): + with self.subTest(suffix=suffix): + self.put(self.dropin, '[Theme]\nCurrent=omarchy\n') + path = self.dropin + suffix + self.put(path, '[Theme]\nCurrent=maya\n') + self.run_repair() + self.assertEqual(self.get(path), '[Theme]\nCurrent=maya\n') + self.assertEqual(self.get('etc/sddm.conf'), '[Theme]\nCurrent=omarchy\n') + (self.root / path).unlink() + (self.root / 'etc/sddm.conf').unlink() + + def test_hidden_files_and_directories_are_not_loaded(self): + self.put(self.dropin, '[Theme]\nCurrent=custom6\n') + self.put('etc/sddm.conf.d/.hidden', '[Theme]\nCurrent=maya\n') + self.put('etc/sddm.conf.d/99-directory/file.conf', '[Theme]\nCurrent=maya\n') + self.assert_unchanged() + + def test_vendor_theme_gets_local_override_not_package_edit(self): + self.put('vendor/default.conf', '[Theme]\nCurrent=maya\n') + self.put('etc/sddm.conf', '[Users]\nHideUsers=private') + self.run_repair() + self.assertEqual(self.get('vendor/default.conf'), '[Theme]\nCurrent=maya\n') + self.assertEqual(self.get('etc/sddm.conf'), '[Users]\nHideUsers=private\n[Theme]\nCurrent=omarchy\n') + + def test_local_settings_override_vendor_files(self): + self.put('vendor/default.conf', '[Theme]\nCurrent=maya\n') + self.put(self.dropin, '[Theme]\nCurrent=custom6\n') + self.assert_unchanged() + + def test_current_syntax_comments_eof_and_exact_edit(self): + for line in ('Current=maya', ' Current = maya\n', 'Current=maya # chosen\n', 'Current=maya\r\n'): + with self.subTest(line=line): + self.put(self.dropin, '[Theme]\n' + line) + self.run_repair() + self.assertEqual(self.get(self.dropin), '[Theme]\n' + line.replace('maya', 'omarchy')) + + def test_only_winning_theme_current_is_replaced(self): + original = '[Theme]\nCurrent=custom6\n[General]\nCurrent=irrelevant\n[Theme]\nCurrent=maya\n[Users]\nCurrent=omarchy\n' + self.put(self.dropin, original) + self.run_repair() + self.assertEqual(self.get(self.dropin), original.replace('Current=maya', 'Current=omarchy')) + + def test_safe_comments_and_irrelevant_keys_do_not_trigger_edits(self): + self.put(self.dropin, '[Theme]\nCurrent=custom6 # chosen\n[General]\nCurrent=maya\n') + self.assert_unchanged() + + def test_safe_fallbacks_and_non_qt5_metadata_are_unchanged(self): + for theme in ('', 'nosuchtheme'): + with self.subTest(theme=theme): + self.put(self.dropin, '[Theme]\nCurrent=' + theme + '\n') + self.assert_unchanged() + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + for version in ('6', '7', 'invalid', '"6"', '+6', '06'): + with self.subTest(version=version): + self.put('themes/maya/metadata.desktop', '[SddmGreeterTheme]\nQtVersion=' + version + '\n') + self.assert_unchanged() + self.put('themes/maya/metadata.desktop', '[SddmGreeterTheme]\nName=Maya\n') + (self.root / 'bin/greeter5').chmod(0o644) + self.assert_unchanged() + (self.root / 'bin/greeter5').unlink() + self.assert_unchanged() + + def test_missing_metadata_in_existing_theme_is_qt5(self): + (self.root / 'themes/maya/metadata.desktop').unlink() + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + self.run_repair() + self.assertEqual(self.get(self.dropin), '[Theme]\nCurrent=omarchy\n') + + def test_metadata_key_is_group_scoped_case_sensitive_and_last_wins(self): + for metadata in ('[SddmGreeterTheme]\nName=Maya\n[Other]\nQtVersion=6\n', + '[SddmGreeterTheme]\nqtversion=6\n', + '[DEFAULT]\nQtVersion=6\n[SddmGreeterTheme]\nName=Maya\n', + '[SddmGreeterTheme]\nQtVersion=6\nQtVersion=5\n'): + with self.subTest(metadata=metadata): + self.put('themes/maya/metadata.desktop', metadata) + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + self.run_repair() + self.assertEqual(self.get(self.dropin), '[Theme]\nCurrent=omarchy\n') + self.put('themes/custom6/metadata.desktop', '[SddmGreeterTheme]\nQtVersion=6\n[Other]\nQtVersion=5\n') + self.put(self.dropin, '[Theme]\nCurrent=custom6\n') + self.assert_unchanged() + + def test_effective_theme_dir_and_absolute_theme_names(self): + alternate = str(self.root / 'alternate') + self.put('alternate/outside6/metadata.desktop', '[SddmGreeterTheme]\nQtVersion=6\n') + self.put('alternate/omarchy/metadata.desktop', '[SddmGreeterTheme]\nQtVersion=5\n') + self.put('vendor/default.conf', '[Theme]\nThemeDir=' + alternate + '\n') + self.put(self.dropin, '[Theme]\nCurrent=outside6\n') + self.assert_unchanged() + self.put(self.dropin, '[Theme]\nCurrent=' + alternate + '/outside6\n') + self.assert_unchanged() + self.put(self.dropin, '[Theme]\nCurrent=omarchy\n') + self.run_repair() + self.assertEqual(self.get(self.dropin), '[Theme]\nCurrent=' + str(self.root / 'themes/omarchy') + '\n') + self.assertEqual(self.get('vendor/default.conf'), '[Theme]\nThemeDir=' + alternate + '\n') + + def test_relative_theme_dir_uses_the_system_service_working_directory(self): + relative = str(self.root / 'themes').lstrip('/') + original = '[Theme]\nThemeDir=' + relative + '\nCurrent=maya\n' + self.put(self.dropin, original) + self.run_repair() + self.assertEqual(self.get(self.dropin), original.replace('Current=maya', 'Current=' + str(self.root / 'themes/omarchy'))) + + def test_theme_dir_highest_precedence_is_used(self): + self.put('vendor/default.conf', '[Theme]\nThemeDir=/missing\n') + self.put(self.dropin, '[Theme]\nCurrent=custom6\n') + self.put('etc/sddm.conf', '[Theme]\nThemeDir=' + str(self.root / 'themes') + '\n') + self.assert_unchanged() + + def test_backup_is_retained_outside_loaded_dirs_with_original_bytes_and_mode(self): + original = '[Theme]\n Current = maya # selected\n[Users]\nHideUsers=private\n' + source = self.put(self.dropin, original) + source.chmod(0o640) + self.put(self.dropin + '.bak', '# previous administrator backup\n[Users]\nHideUsers=other\n') + self.put('backups/10-theme.conf', 'older recovery copy\n') + result = self.run_repair() + self.assertEqual(self.get(self.dropin + '.bak'), '# previous administrator backup\n[Users]\nHideUsers=other\n') + self.assertEqual(self.get('backups/10-theme.conf'), 'older recovery copy\n') + copies = [p for p in self.backups() if p.read_text() == original] + self.assertEqual(len(copies), 1) + self.assertIn(str(copies[0]), result.stdout) + self.assertEqual(copies[0].stat().st_mode & 0o777, 0o600) + self.assertEqual(source.stat().st_mode & 0o777, 0o640) + self.assert_unchanged() + self.put(self.dropin, original) + self.run_repair() + self.assertEqual(len([p for p in self.backups() if p.read_text() == original]), 2) + + def test_backup_failure_leaves_configuration_untouched(self): + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + self.put('not-a-directory', 'blocked') + self.env['OMARCHY_SDDM_BACKUP_DIR'] = str(self.root / 'not-a-directory/backup') + self.assert_unchanged(success=False) + + def test_backup_inside_loaded_directory_is_rejected(self): + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + for directory in ('etc/sddm.conf.d', 'etc/sddm.conf.d/recovery', 'vendor/recovery'): + self.env['OMARCHY_SDDM_BACKUP_DIR'] = str(self.root / directory) + self.assert_unchanged(success=False) + + def test_symlink_and_writable_config_are_not_replaced(self): + source = self.put('outside.conf', '[Theme]\nCurrent=maya\n') + link = self.root / self.dropin + link.symlink_to(source) + self.assert_unchanged(success=False) + self.assertTrue(link.is_symlink()) + link.unlink() + self.put(self.dropin, '[Theme]\nCurrent=maya\n').chmod(0o666) + self.assert_unchanged(success=False) + link.chmod(0o644) + os.link(link, self.root / 'hardlink.conf') + self.assert_unchanged(success=False) + + def test_unsafe_parent_and_backup_symlink_are_rejected(self): + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + parent = self.root / 'etc/sddm.conf.d' + parent.chmod(0o777) + self.assert_unchanged(success=False) + parent.chmod(0o755) + (self.root / 'backups').rmdir() + (self.root / 'backups').symlink_to(self.root / 'home', target_is_directory=True) + self.assert_unchanged(success=False) + + def test_ldd_failures_are_pending_and_large_output_is_fully_consumed(self): + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + self.env['SDDM_TEST_LDD'] = 'good' + self.assert_unchanged() + self.env['SDDM_TEST_LDD'] = 'error' + result = self.run_repair(success=False) + self.assertIn('ldd', result.stderr) + self.assertEqual(self.get(self.dropin), '[Theme]\nCurrent=maya\n') + self.env['SDDM_TEST_LDD'] = 'large' + self.run_repair() + self.assertEqual(self.get(self.dropin), '[Theme]\nCurrent=omarchy\n') + + def test_changed_configuration_is_not_overwritten(self): + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + self.env['SDDM_TEST_LDD'] = 'changed' + self.run_repair(success=False) + self.assertEqual(self.get(self.dropin), '[Theme]\nCurrent=maya\n# administrator edit\n') + self.assertEqual(self.backups(), []) + + def test_unsupported_metadata_is_not_guessed(self): + self.put(self.dropin, '[Theme]\nCurrent=custom6\n') + for value in ('\\\\x36', '"6\n"', '@ByteArray(5)', '@Variant(5)', '@String(5'): + with self.subTest(value=value): + self.put('themes/custom6/metadata.desktop', '[SddmGreeterTheme]\nQtVersion=' + value + '\n') + self.assert_unchanged(success=False) + + def test_malformed_metadata_sections_never_trigger_repair(self): + self.put(self.dropin, '[Theme]\nCurrent=custom6\n') + for metadata in ('[SddmGreeterTheme\nQtVersion=6\n', + '[SddmGreeterTheme]\nQtVersion=6\n[Other\nQtVersion=5\n', + '[SddmGreeterTheme]]\nQtVersion=6\n'): + with self.subTest(metadata=metadata): + self.put('themes/custom6/metadata.desktop', metadata) + self.assert_unchanged(success=False) + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + self.put('themes/omarchy/metadata.desktop', '[SddmGreeterTheme\nQtVersion=6\n') + self.assert_unchanged(success=False) + + def test_qsettings_string_encoded_qt_versions(self): + self.put('themes/omarchy/metadata.desktop', '[SddmGreeterTheme]\nQtVersion=@String(6)\n') + for version in ('@String(5)', '"@String(5)"', '@String(+5)', '@String(05)'): + with self.subTest(version=version): + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + self.put('themes/maya/metadata.desktop', '[SddmGreeterTheme]\nQtVersion=' + version + '\n') + self.run_repair() + self.assertEqual(self.get(self.dropin), '[Theme]\nCurrent=omarchy\n') + self.put(self.dropin, '[Theme]\nCurrent=custom6\n') + self.put('themes/custom6/metadata.desktop', '[SddmGreeterTheme]\nQtVersion=@String(6)\n') + self.assert_unchanged() + + def test_migration_failure_stays_pending_then_completes_only_once(self): + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + self.put('runtime/migrations/1788380505.sh', Path(migration).read_text()) + self.put('runtime/migrations/9999999999.sh', 'touch "$HOME/later-ran"\n') + self.env['OMARCHY_PATH'] = str(self.root / 'runtime') + self.env['OMARCHY_MIGRATION_STATE'] = str(self.root / 'home/markers') + runner = str(Path(os.environ['ROOT']) / 'bin/omarchy-migrate') + self.env['SDDM_TEST_LDD'] = 'error' + result = subprocess.run(['bash', runner], env=self.env, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse((self.root / 'home/markers/1788380505.sh').exists()) + self.assertFalse((self.root / 'home/later-ran').exists()) + self.env['SDDM_TEST_LDD'] = 'broken' + result = subprocess.run(['bash', runner], env=self.env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue((self.root / 'home/markers/1788380505.sh').exists()) + self.assertTrue((self.root / 'home/later-ran').exists()) + self.assertEqual(self.get(self.dropin), '[Theme]\nCurrent=omarchy\n') + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + before = self.snapshot() + result = subprocess.run(['bash', runner], env=self.env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.snapshot(), before) + + def test_replacement_must_be_available_and_qt6(self): + self.put(self.dropin, '[Theme]\nCurrent=maya\n') + self.env['SDDM_TEST_QT6_BROKEN'] = '1' + self.assert_unchanged(success=False) + self.env['SDDM_TEST_QT6_BROKEN'] = '0' + self.put('themes/omarchy/metadata.desktop', '[SddmGreeterTheme]\nQtVersion=5\n') + self.assert_unchanged(success=False) + + +unittest.main(verbosity=2) +PY