From 3e07f25fc3f1ec7a285296afcfb27eb4b69b2ac5 Mon Sep 17 00:00:00 2001 From: surim0n Date: Mon, 7 Sep 2026 12:40:00 -0400 Subject: [PATCH 1/2] Guard SDDM theme selection against Qt5 greeter incompatibility. Fresh Omarchy 4.x has no Qt5 packages. SDDM falls back to the Qt5 greeter (/usr/bin/sddm-greeter) for any theme whose metadata.desktop does not declare QtVersion=6. On a 4.x install that leaves the login screen permanently black. Add a migration that, on update, detects when the active SDDM theme is not Qt6 and the Qt5 greeter cannot run (missing shared libraries), then resets Current= back to the packaged omarchy theme. - migrations/1788380505.sh: the guard - test/shell.d/sddm-qt6-guard-test.sh: coverage for safe, unsafe, runnable-Qt5, custom-Qt6, and no-theme cases Fixes #10302. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- migrations/1788380505.sh | 61 +++++++++++++++ test/shell.d/sddm-qt6-guard-test.sh | 112 ++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 migrations/1788380505.sh create mode 100644 test/shell.d/sddm-qt6-guard-test.sh diff --git a/migrations/1788380505.sh b/migrations/1788380505.sh new file mode 100644 index 00000000000..5714d5d525e --- /dev/null +++ b/migrations/1788380505.sh @@ -0,0 +1,61 @@ +echo "Guard the SDDM login theme against a Qt5 greeter that cannot run it" + +# Fresh Omarchy 4.x has no Qt5 packages. SDDM falls back to the Qt5 greeter +# (/usr/bin/sddm-greeter) for any theme whose metadata.desktop does not declare +# QtVersion=6. On a 4.x install that leaves the login screen permanently black. +# If the configured theme is not Qt6 and the Qt5 greeter cannot run, reset it +# to the packaged omarchy theme, which does declare QtVersion=6. + +sddm_conf="${OMARCHY_SDDM_CONF:-/etc/sddm.conf}" +sddm_conf_dir="${OMARCHY_SDDM_CONF_DIR:-/etc/sddm.conf.d}" +theme_dir="${OMARCHY_SDDM_THEME_DIR:-/usr/share/sddm/themes}" +qt5_greeter="${OMARCHY_SDDM_QT5_GREETER:-/usr/bin/sddm-greeter}" + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +# sddm.conf is loaded first, then sddm.conf.d/*.conf in lexicographic order. +# The last Current= wins. +configs=() +[[ -f $sddm_conf ]] && configs+=("$sddm_conf") +for conf in "$sddm_conf_dir"/*.conf; do + [[ -f $conf ]] || continue + configs+=("$conf") +done + +active_theme="" +active_file="" +if (( ${#configs[@]} > 0 )); then + for conf in "${configs[@]}"; do + while IFS= read -r line; do + [[ $line == Current=* ]] || continue + active_theme=${line#Current=} + active_file=$conf + done < "$conf" + done +fi + +[[ -n $active_theme ]] || exit 0 + +metadata="$theme_dir/$active_theme/metadata.desktop" +if [[ -f $metadata ]]; then + qt_version=$(awk -F= 'BEGIN{IGNORECASE=1} /^[[:space:]]*QtVersion[[:space:]]*=/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2}' "$metadata" 2>/dev/null | tail -1) + [[ ${qt_version:-} == "6" ]] && exit 0 +fi + +# If the Qt5 greeter is present and its libraries are all satisfied, a +# non-Qt6 theme is still runnable, so leave the user's choice alone. +if [[ -x $qt5_greeter ]] && ! ldd "$qt5_greeter" 2>/dev/null | grep -q 'not found'; then + exit 0 +fi + +# The configured theme is not Qt6 and the Qt5 fallback cannot run. Reset the +# active Current= line to omarchy so login does not stay black. +as_root sed -i.bak 's/^Current=.*/Current=omarchy/' "$active_file" +as_root rm -f "$active_file.bak" +echo "Reset SDDM theme from '$active_theme' to 'omarchy' because the Qt5 greeter cannot run it." 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..29d520c3003 --- /dev/null +++ b/test/shell.d/sddm-qt6-guard-test.sh @@ -0,0 +1,112 @@ +#!/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 + +mkdir -p "$test_dir/bin" "$test_dir/etc/sddm.conf.d" "$test_dir/usr/share/sddm/themes/"{omarchy,maya,custom6} "$test_dir/usr/bin" + +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" +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_THEME_DIR="$test_dir/usr/share/sddm/themes" \ + OMARCHY_SDDM_QT5_GREETER="$test_dir/usr/bin/$1" \ + 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: a non-Qt6 theme is active and the Qt5 greeter cannot run — 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 non-Qt6 theme is active but the Qt5 greeter is runnable — 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" From 8fa0aecb77ebfc3cc99d2beccf38c4290d6a751c Mon Sep 17 00:00:00 2001 From: surim0n Date: Tue, 8 Sep 2026 00:12:24 -0400 Subject: [PATCH 2/2] Preserve effective SDDM configuration during the Qt5 repair Resolve service-locale-aware configuration precedence, respect theme metadata and safe fallbacks, and retain recovery copies outside the loaded directories. Refuse ambiguous inputs instead of guessing. Cover the repair, malformed inputs, backups and migration lifecycle with behavioral regressions. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- migrations/1788380505.sh | 312 ++++++++++++++--- test/shell.d/sddm-qt6-guard-test.sh | 499 +++++++++++++++++++++++++++- 2 files changed, 762 insertions(+), 49 deletions(-) diff --git a/migrations/1788380505.sh b/migrations/1788380505.sh index 5714d5d525e..9fb54e8b609 100644 --- a/migrations/1788380505.sh +++ b/migrations/1788380505.sh @@ -1,15 +1,19 @@ -echo "Guard the SDDM login theme against a Qt5 greeter that cannot run it" +echo "Repair an existing SDDM Qt5 theme selection with missing greeter libraries" -# Fresh Omarchy 4.x has no Qt5 packages. SDDM falls back to the Qt5 greeter -# (/usr/bin/sddm-greeter) for any theme whose metadata.desktop does not declare -# QtVersion=6. On a 4.x install that leaves the login screen permanently black. -# If the configured theme is not Qt6 and the Qt5 greeter cannot run, reset it -# to the packaged omarchy theme, which does declare QtVersion=6. +# 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 @@ -19,43 +23,259 @@ as_root() { fi } -# sddm.conf is loaded first, then sddm.conf.d/*.conf in lexicographic order. -# The last Current= wins. -configs=() -[[ -f $sddm_conf ]] && configs+=("$sddm_conf") -for conf in "$sddm_conf_dir"/*.conf; do - [[ -f $conf ]] || continue - configs+=("$conf") -done - -active_theme="" -active_file="" -if (( ${#configs[@]} > 0 )); then - for conf in "${configs[@]}"; do - while IFS= read -r line; do - [[ $line == Current=* ]] || continue - active_theme=${line#Current=} - active_file=$conf - done < "$conf" - done -fi - -[[ -n $active_theme ]] || exit 0 - -metadata="$theme_dir/$active_theme/metadata.desktop" -if [[ -f $metadata ]]; then - qt_version=$(awk -F= 'BEGIN{IGNORECASE=1} /^[[:space:]]*QtVersion[[:space:]]*=/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2}' "$metadata" 2>/dev/null | tail -1) - [[ ${qt_version:-} == "6" ]] && exit 0 -fi - -# If the Qt5 greeter is present and its libraries are all satisfied, a -# non-Qt6 theme is still runnable, so leave the user's choice alone. -if [[ -x $qt5_greeter ]] && ! ldd "$qt5_greeter" 2>/dev/null | grep -q 'not found'; then - exit 0 -fi - -# The configured theme is not Qt6 and the Qt5 fallback cannot run. Reset the -# active Current= line to omarchy so login does not stay black. -as_root sed -i.bak 's/^Current=.*/Current=omarchy/' "$active_file" -as_root rm -f "$active_file.bak" -echo "Reset SDDM theme from '$active_theme' to 'omarchy' because the Qt5 greeter cannot run it." +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 index 29d520c3003..3dedc9fdb9c 100644 --- a/test/shell.d/sddm-qt6-guard-test.sh +++ b/test/shell.d/sddm-qt6-guard-test.sh @@ -9,8 +9,17 @@ migration="$ROOT/migrations/1788380505.sh" 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" +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 @@ -46,14 +55,19 @@ 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" } @@ -71,7 +85,7 @@ run_migration() { fail "safe Qt6 theme is left unchanged" } -# Scenario 2: a non-Qt6 theme is active and the Qt5 greeter cannot run — reset. +# 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" @@ -83,7 +97,7 @@ run_migration() { fail "packaged theme file stays valid" } -# Scenario 3: a non-Qt6 theme is active but the Qt5 greeter is runnable — leave it. +# 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" @@ -110,3 +124,482 @@ run_migration() { } 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