aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-24 12:57:29 -0500
committerCraig Jennings <c@cjennings.net>2026-07-24 12:57:29 -0500
commit249bb93b29ef471a6529d2d5bb96df87e1ac1a8a (patch)
tree055130bd142df402c9ea45e0323fc0064370c9f3
parentc63a0626d3564c324f57cbbbb9eac340690dd830 (diff)
downloadarchsetup-249bb93b29ef471a6529d2d5bb96df87e1ac1a8a.tar.gz
archsetup-249bb93b29ef471a6529d2d5bb96df87e1ac1a8a.zip
fix(installer): derive the wireless regdom by pattern and retire two inert hooks
The regulatory domain came from a fixed offset, "${current_lang:3:2}", which is right only for a two-letter language code. validate_config accepts three-letter languages and glibc ships 75 of them, so ber_DZ.UTF-8 yielded "_D" and C yielded "". A garbage region matches no line in the regdom file, sed exits 0, and the || error_warn could never fire, so WiFi sat on the conservative "00" domain in silence. locale_country matches the _CC group instead of counting characters, and set_wireless_regdom verifies the substitution actually landed. That second half is the part worth keeping: every sed -i in this installer shares the stance that a no-match is indistinguishable from success, and this is the one where the silence costs something. switch_udev_hook_to_systemd now rewrites keymap and consolefont to sd-vconsole when it performs the swap. Both are busybox-only run_hook scripts, so a systemd initramfs installs them and never runs them, and this machine's own HOOKS line carries all three today. Cosmetic on a box that just boots, and not cosmetic on one asking for a passphrase with a non-US layout, which is exactly what sd-vconsole restores. The two collapse into one entry and an existing sd-vconsole is not duplicated. Before building it I answered the question the task left open. This machine is KEYMAP=us with no encrypt hook, so no prompt, but the function runs on LUKS machines where there is one, and sd-vconsole ships with mkinitcpio. 364 unit tests, exit 0. Adds tests/installer-steps/test_wireless_regdom.py (16) and 7 sd-vconsole cases. Every guard proven by deleting it, including one whose first version passed with the guard gone because a later check caught the same case.
-rwxr-xr-xarchsetup79
-rw-r--r--tests/installer-steps/test_switch_udev_hook.py51
-rw-r--r--tests/installer-steps/test_wireless_regdom.py178
3 files changed, 298 insertions, 10 deletions
diff --git a/archsetup b/archsetup
index 545edf4..98740f8 100755
--- a/archsetup
+++ b/archsetup
@@ -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()