diff options
| -rwxr-xr-x | archsetup | 79 | ||||
| -rw-r--r-- | tests/installer-steps/test_switch_udev_hook.py | 51 | ||||
| -rw-r--r-- | tests/installer-steps/test_wireless_regdom.py | 178 |
3 files changed, 298 insertions, 10 deletions
@@ -1706,6 +1706,52 @@ configure_randomness() { run_task "starting rngd service" systemctl start rngd } +# The ISO 3166-1 alpha-2 country in a locale, or "" when it carries none. +# $1 is the locale string. +# +# This was "${locale:3:2}", a fixed offset that is right only for a two-letter +# language. validate_config accepts ^[a-z]{2,3}(_[A-Z]{2})?, glibc ships 75 +# three-letter languages, and ber_DZ.UTF-8 yielded "_D" while C yielded "". A +# garbage region matches no line in the regdom file, so the domain was never set +# and nothing said so. Match the _CC group instead of counting characters. +locale_country() { + local locale="$1" + [[ "$locale" =~ ^[a-z]{2,3}_([A-Z]{2})([.@]|$) ]] || return 0 + printf '%s\n' "${BASH_REMATCH[1]}" +} + +# Uncomment this machine's WIRELESS_REGDOM line. $1 is the locale, $2 the conf +# path (defaulting to the system file so the step runs against fixtures). +# +# Verifies the substitution landed. sed exits 0 when it matches nothing, so the +# `|| error_warn` on the old one-liner could never fire: a locale with no +# country, or a country absent from the file, left WiFi on the conservative "00" +# domain in silence. Every other sed -i in this installer shares that stance; +# this is the one where the silence has a cost worth a warning. +set_wireless_regdom() { + local locale="$1" conf="${2:-/etc/conf.d/wireless-regdom}" + local region + region="$(locale_country "$locale")" + if [ -z "$region" ]; then + error_warn "deriving a wireless regulatory domain from locale '$locale'" "1" + return 1 + fi + action="configuring wireless regulatory domain ($region)" && display "task" "$action" + if grep -q "^WIRELESS_REGDOM=\"${region}\"" "$conf" 2>/dev/null; then + return 0 + fi + backup_system_file "$conf" + sed -i "s|^#WIRELESS_REGDOM=\"${region}\"|WIRELESS_REGDOM=\"${region}\"|" "$conf" 2>> "$logfile" || { + error_warn "$action" "$?" + return 1 + } + grep -q "^WIRELESS_REGDOM=\"${region}\"" "$conf" 2>/dev/null || { + error_warn "$action (no '$region' entry in $conf)" "1" + return 1 + } +} + + configure_networking() { # Networking @@ -1728,16 +1774,7 @@ wifi.cloned-mac-address=random ethernet.cloned-mac-address=stable EOF - # Configure wireless regulatory domain (enables full WiFi capabilities for region) - # Derive region code from locale (e.g., en_US.UTF-8 → US, de_DE.UTF-8 → DE) - # Locale format is ll_CC.ENCODING — the country code at positions 3-4 maps to - # ISO 3166-1 alpha-2, which matches the wireless-regdom config format - current_lang="${LANG:-en_US.UTF-8}" - wireless_region="${current_lang:3:2}" # extract country code (positions 3-4) - action="configuring wireless regulatory domain ($wireless_region)" && display "task" "$action" - backup_system_file /etc/conf.d/wireless-regdom - sed -i "s|^#WIRELESS_REGDOM=\"${wireless_region}\"|WIRELESS_REGDOM=\"${wireless_region}\"|" /etc/conf.d/wireless-regdom 2>> "$logfile" || \ - error_warn "$action" "$?" + set_wireless_regdom "${LANG:-en_US.UTF-8}" # Encrypted DNS (DNS over TLS) @@ -3248,6 +3285,28 @@ switch_udev_hook_to_systemd() { error_warn "$action" "$?" return 1 } + + # keymap and consolefont are busybox-only run_hook scripts too, so the + # systemd init installs them and never runs them. sd-vconsole is the + # equivalent systemd hook and ships with mkinitcpio. Left alone, the early + # console keeps the default font and keymap until systemd-vconsole-setup + # runs in the real root -- cosmetic on a machine that just boots, and not + # cosmetic on one asking for a passphrase with a non-US layout. + # + # Both collapse into a single sd-vconsole, and an sd-vconsole already + # present is not duplicated. + if grep -qE '^HOOKS=.*[([:space:]](keymap|consolefont)[[:space:])]' "$conf"; then + action="replacing keymap/consolefont with sd-vconsole" && display "task" "$action" + # The dedupe is a loop, not a single substitution: the two hooks are + # often separated by other entries (keymap block consolefont), so an + # adjacent-only collapse leaves two sd-vconsole behind. + sed -i '/^HOOKS=/ { + s/[[:space:]]\+\(keymap\|consolefont\)\b/ sd-vconsole/g + :dedup + s/\(sd-vconsole.*\)[[:space:]]\+sd-vconsole/\1/ + t dedup + }' "$conf" 2>> "$logfile" || error_warn "$action" "$?" + fi } configure_initramfs_hook() { diff --git a/tests/installer-steps/test_switch_udev_hook.py b/tests/installer-steps/test_switch_udev_hook.py index 54969e6..f6d1dc2 100644 --- a/tests/installer-steps/test_switch_udev_hook.py +++ b/tests/installer-steps/test_switch_udev_hook.py @@ -108,6 +108,57 @@ class SwitchUdevHookToSystemd(unittest.TestCase): self.assertEqual(rc_of(r), 1) self.assertNotIn("BACKUP:", r.stdout, "a declined swap must not touch the file") + def test_keymap_and_consolefont_become_sd_vconsole(self): + """Both are busybox-only run_hook scripts, so a systemd initramfs + installs them and never executes them. sd-vconsole is the systemd + equivalent, and it ships with mkinitcpio. Without this the early console + keeps the default font and keymap until systemd-vconsole-setup runs in + the real root -- cosmetic on a machine that just boots, and not cosmetic + at all on one that asks for a passphrase with a non-US layout.""" + r = run("HOOKS=(base udev keymap consolefont block filesystems fsck)\n") + conf = r.stdout.split("CONF:[")[1] + self.assertIn("sd-vconsole", conf) + self.assertNotIn("keymap", conf) + self.assertNotIn("consolefont", conf) + self.assertEqual(rc_of(r), 0) + + def test_keymap_alone_becomes_sd_vconsole(self): + r = run("HOOKS=(base udev keymap block filesystems)\n") + conf = r.stdout.split("CONF:[")[1] + self.assertIn("sd-vconsole", conf) + self.assertNotIn("keymap", conf) + + def test_consolefont_alone_becomes_sd_vconsole(self): + r = run("HOOKS=(base udev consolefont block filesystems)\n") + conf = r.stdout.split("CONF:[")[1] + self.assertIn("sd-vconsole", conf) + self.assertNotIn("consolefont", conf) + + def test_the_two_collapse_into_one_sd_vconsole(self): + r = run("HOOKS=(base udev keymap block consolefont filesystems)\n") + conf = r.stdout.split("CONF:[")[1].split("]")[0] + self.assertEqual(conf.count("sd-vconsole"), 1) + + def test_an_existing_sd_vconsole_is_not_duplicated(self): + r = run("HOOKS=(base udev sd-vconsole keymap block filesystems)\n") + conf = r.stdout.split("CONF:[")[1].split("]")[0] + self.assertEqual(conf.count("sd-vconsole"), 1) + self.assertNotIn("keymap", conf) + + def test_a_conf_with_neither_hook_is_unchanged_apart_from_the_swap(self): + r = run("HOOKS=(base udev block filesystems fsck)\n") + conf = r.stdout.split("CONF:[")[1] + self.assertNotIn("sd-vconsole", conf) + self.assertIn("systemd", conf) + + def test_a_refused_swap_leaves_keymap_alone(self): + # No swap means busybox init still runs, and keymap still works there. + r = run("HOOKS=(base udev keymap encrypt filesystems)\n") + conf = r.stdout.split("CONF:[")[1] + self.assertIn("keymap", conf) + self.assertNotIn("sd-vconsole", conf) + self.assertEqual(rc_of(r), 1) + def test_tab_separated_encrypt_hook_blocks_the_swap(self): # The token class was [( ] / [ )], which is a literal space and does # not include a tab. mkinitcpio's HOOKS is a bash array literal, and diff --git a/tests/installer-steps/test_wireless_regdom.py b/tests/installer-steps/test_wireless_regdom.py new file mode 100644 index 0000000..cf12eb1 --- /dev/null +++ b/tests/installer-steps/test_wireless_regdom.py @@ -0,0 +1,178 @@ +"""Tests for locale_country and set_wireless_regdom in the archsetup installer. + +The regulatory domain was derived by fixed offset: "${current_lang:3:2}", with a +comment reading "positions 3-4". That is right only for a two-letter language +code. validate_config accepts ^[a-z]{2,3}(_[A-Z]{2})?, glibc ships 75 +three-letter languages, and ber_DZ.UTF-8 yields "_D" while C yields "". + +A garbage region matches no line in /etc/conf.d/wireless-regdom, sed exits 0, +and the || error_warn never fires, so the domain is silently never set and WiFi +falls back to the conservative "00" domain. The fix derives the country from the +_CC group by pattern and verifies the substitution actually landed. + +These tests exercise the REAL function bodies, extracted from the `archsetup` +script at run time, against a fixture conf file. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_wireless_regdom +""" + +import os +import shutil +import subprocess +import tempfile +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + +# A realistic slice of the wireless-regdb-owned file: every entry commented. +CONF = "".join(f'#WIRELESS_REGDOM="{cc}"\n' for cc in + ("AT", "AU", "BR", "CA", "DE", "DZ", "ES", "FR", "GB", "JP", + "PE", "US")) + + +class LocaleCountry(unittest.TestCase): + """The pure derivation.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="regdom-") + self.addCleanup(shutil.rmtree, self.tmp, True) + self.wrapper = os.path.join(self.tmp, "run.sh") + with open(self.wrapper, "w") as f: + f.write( + "#!/bin/bash\n" + 'ARCHSETUP="$1"; shift\n' + "source <(sed -n '/^locale_country() {/,/^}/p' \"$ARCHSETUP\")\n" + 'locale_country "$1"\n' + ) + os.chmod(self.wrapper, 0o755) + + def country(self, locale): + r = subprocess.run(["bash", self.wrapper, ARCHSETUP, locale], + capture_output=True, text=True) + return r.stdout.strip() + + # ---------------------------------------------------------- normal ---- + def test_two_letter_language(self): + self.assertEqual(self.country("en_US.UTF-8"), "US") + self.assertEqual(self.country("de_DE.UTF-8"), "DE") + + def test_three_letter_language(self): + # The bug: offset 3-4 of "ber_DZ.UTF-8" is "_D". + self.assertEqual(self.country("ber_DZ.UTF-8"), "DZ") + self.assertEqual(self.country("ayc_PE.UTF-8"), "PE") + + # -------------------------------------------------------- boundary ---- + def test_locale_without_an_encoding(self): + self.assertEqual(self.country("fr_CA"), "CA") + + def test_locale_with_a_modifier(self): + self.assertEqual(self.country("ca_ES@valencia"), "ES") + + def test_no_country_in_the_locale(self): + self.assertEqual(self.country("C"), "") + self.assertEqual(self.country("POSIX"), "") + self.assertEqual(self.country("eo"), "") + + def test_empty_locale(self): + self.assertEqual(self.country(""), "") + + # ----------------------------------------------------------- error ---- + def test_a_lowercase_country_is_not_accepted(self): + # ISO 3166-1 alpha-2 is uppercase; the regdom file keys on that. + self.assertEqual(self.country("en_us.UTF-8"), "") + + def test_a_three_letter_country_is_not_accepted(self): + self.assertEqual(self.country("en_USA.UTF-8"), "") + + +class SetWirelessRegdom(unittest.TestCase): + """The step: uncomment the matching line, and say so when it can't.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="regdom-step-") + self.addCleanup(shutil.rmtree, self.tmp, True) + self.conf = os.path.join(self.tmp, "wireless-regdom") + with open(self.conf, "w") as f: + f.write(CONF) + self.wrapper = os.path.join(self.tmp, "run.sh") + with open(self.wrapper, "w") as f: + f.write( + "#!/bin/bash\n" + 'ARCHSETUP="$1"; shift\n' + "source <(sed -n " + "'/^locale_country() {/,/^}/p;" + "/^set_wireless_regdom() {/,/^}/p' \"$ARCHSETUP\")\n" + 'display() { :; }\n' + 'backup_system_file() { echo "BACKUP:$1"; }\n' + 'error_warn() { echo "WARN:$1"; return 1; }\n' + 'set_wireless_regdom "$1" "$2"\n' + 'echo "RETURNED=$?"\n' + ) + os.chmod(self.wrapper, 0o755) + + def run_step(self, locale): + env = dict(os.environ) + env["logfile"] = os.path.join(self.tmp, "log") + return subprocess.run( + ["bash", self.wrapper, ARCHSETUP, locale, self.conf], + capture_output=True, text=True, env=env) + + def active(self): + with open(self.conf) as f: + return [l.strip() for l in f if l.startswith("WIRELESS_REGDOM=")] + + # ---------------------------------------------------------- normal ---- + def test_a_two_letter_locale_sets_its_domain(self): + self.run_step("en_US.UTF-8") + self.assertEqual(self.active(), ['WIRELESS_REGDOM="US"']) + + def test_a_three_letter_locale_sets_its_domain(self): + self.run_step("ber_DZ.UTF-8") + self.assertEqual(self.active(), ['WIRELESS_REGDOM="DZ"']) + + def test_only_the_matching_line_is_uncommented(self): + self.run_step("de_DE.UTF-8") + self.assertEqual(len(self.active()), 1) + + # -------------------------------------------------------- boundary ---- + def test_a_locale_with_no_country_warns_and_names_the_locale(self): + # The trailing verify catches this case too, so a bare "did it warn?" + # assertion passes with the early guard deleted and proves nothing. What + # the guard buys is a message that names the locale, instead of one + # reporting that country "" is missing from the file. + r = self.run_step("C") + self.assertIn("WARN:", r.stdout) + self.assertIn("'C'", r.stdout) + self.assertEqual(self.active(), []) + + def test_a_locale_with_no_country_does_not_back_up_the_conf(self): + # Nothing is going to be written, so nothing should be backed up. + r = self.run_step("POSIX") + self.assertNotIn("BACKUP", r.stdout) + + def test_a_country_absent_from_the_file_warns(self): + # The sibling gap: sed exits 0 when it matches nothing, so without an + # explicit check a distro reshuffling this file fails silently. + r = self.run_step("en_ZZ.UTF-8") + self.assertIn("WARN:", r.stdout) + self.assertEqual(self.active(), []) + + def test_an_already_uncommented_domain_is_left_alone(self): + with open(self.conf, "w") as f: + f.write('WIRELESS_REGDOM="US"\n' + CONF) + r = self.run_step("en_US.UTF-8") + self.assertNotIn("WARN:", r.stdout) + self.assertEqual(self.active(), ['WIRELESS_REGDOM="US"']) + + # ----------------------------------------------------------- error ---- + def test_a_missing_conf_file_warns(self): + r = self.run_step("en_US.UTF-8") + os.remove(self.conf) + r = self.run_step("en_US.UTF-8") + self.assertIn("WARN:", r.stdout) + + +if __name__ == "__main__": + unittest.main() |
