From 6a96869db860991fdf83cffddcc9ec0564ce7a7b Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Tue, 20 Oct 2026 13:31:02 +0200 Subject: [PATCH 1/3] fix(nix): harden fontconfig compatibility across host setups --- docs/NIX.md | 6 +++ flake.nix | 34 +++++++++++--- tests/test_nix_fonts.py | 95 +++++++++++++++++++++++++++++++++++++++ tests/test_nix_repairs.py | 2 +- 4 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 tests/test_nix_fonts.py diff --git a/docs/NIX.md b/docs/NIX.md index c831f34..05910c8 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -215,6 +215,12 @@ the host's `/usr/share/fonts`, which the FHS environment replaces. Fonts in your user font directories remain available. This also prevents an icon-only user font inventory from becoming the only fallback for ordinary text. +Inherited `FONTCONFIG_PATH` and `FONTCONFIG_SYSROOT` overrides are cleared inside +the FHS environment so foreign include paths or an invalid sysroot cannot hide +the packaged fonts. Custom XDG font/configuration directories and legacy +`~/.fonts` remain supported, including paths with spaces. The host configuration +and user font files are not modified. + To check font availability without starting Darling or Riot: ```sh diff --git a/flake.nix b/flake.nix index de2c1af..f071941 100644 --- a/flake.nix +++ b/flake.nix @@ -17,7 +17,14 @@ darlingFonts = [ pkgs.liberation_ttf pkgs.dejavu_fonts ]; darlingFontsConf = pkgs.makeFontsConf { fontDirectories = darlingFonts; - includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" ]; + includes = [ + "${pkgs.fontconfig.out}/etc/fonts/conf.d" + (pkgs.writeText "darling-user-fonts.conf" '' + + ~/.fonts + + '') + ]; }; # Python environment with dependencies required by tooling @@ -163,8 +170,8 @@ export DPREFIX="''${DPREFIX:-$HOME/.darling}" export LC_ALL=C.UTF-8 export LANG=C.UTF-8 + unset FONTCONFIG_PATH FONTCONFIG_SYSROOT export FONTCONFIG_FILE=${darlingFontsConf} - export FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts export DARLING_RUNNER_DIR=${darlingRunner}/bin export PATH="$DARLING_RUNNER_DIR:/usr/local/bin:''${PATH:-}" export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:/usr/lib64/pkgconfig:/usr/share/pkgconfig:''${PKG_CONFIG_PATH:-}" @@ -267,10 +274,10 @@ DESKTOP_EOF fonts = pkgs.runCommand "darling-lol-font-tests" { nativeBuildInputs = [ pkgs.fontconfig ]; } '' - export HOME="$TMPDIR/home" XDG_CONFIG_HOME="$TMPDIR/config" - export XDG_DATA_HOME="$TMPDIR/data" XDG_CACHE_HOME="$TMPDIR/cache" + export HOME="$TMPDIR/home with spaces" XDG_CONFIG_HOME="$TMPDIR/config with spaces" + export XDG_DATA_HOME="$TMPDIR/data with spaces" XDG_CACHE_HOME="$TMPDIR/cache with spaces" + unset FONTCONFIG_PATH FONTCONFIG_SYSROOT export FONTCONFIG_FILE=${darlingFontsConf} - export FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" # Check real font selection with no host or user text fonts available. check_font() { @@ -294,6 +301,23 @@ DESKTOP_EOF cp -R ${pkgs.font-awesome}/share/fonts "$XDG_DATA_HOME/fonts" fc-list --format '%{file}\n' | grep -F "$XDG_DATA_HOME/fonts/" check_aliases + + # Retain custom XDG and legacy fonts as well as user alias rules. + chmod u+w "$XDG_DATA_HOME/fonts" + mkdir -p "$HOME/.fonts" "$XDG_CONFIG_HOME/fontconfig" + cp ${pkgs.liberation_ttf}/share/fonts/truetype/LiberationSans-Regular.ttf "$XDG_DATA_HOME/fonts/user-text.ttf" + cp ${pkgs.liberation_ttf}/share/fonts/truetype/LiberationSerif-Regular.ttf "$HOME/.fonts/legacy-text.ttf" + fc-list --format '%{file}\n' | grep -Fx "$XDG_DATA_HOME/fonts/user-text.ttf" + fc-list --format '%{file}\n' | grep -Fx "$HOME/.fonts/legacy-text.ttf" + cat > "$XDG_CONFIG_HOME/fontconfig/fonts.conf" <<'USERCONF_EOF' + + + DarlingCompatibilityAlias + Liberation Serif + + + USERCONF_EOF + check_font DarlingCompatibilityAlias 'Liberation Serif' touch "$out" ''; diff --git a/tests/test_nix_fonts.py b/tests/test_nix_fonts.py new file mode 100644 index 0000000..0f084f1 --- /dev/null +++ b/tests/test_nix_fonts.py @@ -0,0 +1,95 @@ +import json +import os +import re +import subprocess +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +def extract_fhs_profile(flake_text: str) -> str: + match = re.search(r"profile\s*=\s*''(.*?)'';", flake_text, re.DOTALL) + if not match: + raise ValueError("Could not find profile in flake.nix") + raw_profile = match.group(1) + # Nix multi-line strings: (?~/.fonts", flake_text) + + # FHS profile unsets foreign/sysroot fontconfig vars before exporting package FONTCONFIG_FILE + self.assertIn("unset FONTCONFIG_PATH FONTCONFIG_SYSROOT", flake_text) + self.assertIn("export FONTCONFIG_FILE=${darlingFontsConf}", flake_text) + + unset_pos = flake_text.index("unset FONTCONFIG_PATH FONTCONFIG_SYSROOT") + export_pos = flake_text.index("export FONTCONFIG_FILE=${darlingFontsConf}") + self.assertLess(unset_pos, export_pos, "unset must happen before export FONTCONFIG_FILE") + + def test_fhs_profile_hostile_environment_sanitization(self): + flake_text = (REPO_ROOT / "flake.nix").read_text(encoding="utf-8") + profile_script = extract_fhs_profile(flake_text) + + hostile_envs = [ + { + "FONTCONFIG_SYSROOT": "/not/a/real/host/sysroot", + "FONTCONFIG_PATH": "/foreign/host/etc/fonts", + }, + { + "FONTCONFIG_SYSROOT": "", + "FONTCONFIG_PATH": "/foreign/host/etc/fonts", + }, + ] + + for hostile_font_env in hostile_envs: + with self.subTest(hostile_font_env=hostile_font_env): + test_env = dict(hostile_font_env) + test_env.update({ + "HOME": "/tmp/mock-home", + "XDG_DATA_HOME": "/tmp/mock-xdg data with spaces", + "XDG_CONFIG_HOME": "/tmp/mock-xdg config with spaces", + "UNRELATED_USER_VAR": "preserve_this_value", + "PATH": os.environ.get("PATH", "/bin:/usr/bin"), + }) + + dump_cmd = ( + f"{profile_script}\n" + f'"{sys.executable}" -c \'import os, json; print(json.dumps(dict(os.environ)))\'' + ) + + proc = subprocess.run( + ["bash", "-c", dump_cmd], + env=test_env, + capture_output=True, + text=True, + check=True, + ) + + resulting_env = json.loads(proc.stdout) + + self.assertNotIn("FONTCONFIG_SYSROOT", resulting_env) + self.assertNotIn("FONTCONFIG_PATH", resulting_env) + self.assertTrue( + resulting_env.get("FONTCONFIG_FILE", "").startswith("/nix/store/"), + f"Unexpected FONTCONFIG_FILE: {resulting_env.get('FONTCONFIG_FILE')}", + ) + self.assertEqual(resulting_env.get("XDG_DATA_HOME"), "/tmp/mock-xdg data with spaces") + self.assertEqual(resulting_env.get("XDG_CONFIG_HOME"), "/tmp/mock-xdg config with spaces") + self.assertEqual(resulting_env.get("UNRELATED_USER_VAR"), "preserve_this_value") + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index df02a09..6db36e9 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -453,7 +453,7 @@ class SessionTests(unittest.TestCase): self.assertIn('terminal', result.stderr.lower()) self.assertFalse((root / 'build').exists()) self.assertIn('Terminal=true', (ROOT / 'flake.nix').read_text()) - self.assertIn('checks.${system}.packaging', (ROOT / 'flake.nix').read_text()) + self.assertIn('checks.${system}', (ROOT / 'flake.nix').read_text()) def test_rootless_update_refuses_live_marker_without_build_or_stop(self): with tempfile.TemporaryDirectory(prefix='nix-update-idle-') as directory: From 699b96ffc8f34287b58a543512ee803f58244c7a Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Tue, 20 Oct 2026 13:43:22 +0200 Subject: [PATCH 2/3] docs(tests): document fontconfig regression helpers --- tests/test_nix_fonts.py | 5 +++++ tests/test_nix_repairs.py | 1 + 2 files changed, 6 insertions(+) diff --git a/tests/test_nix_fonts.py b/tests/test_nix_fonts.py index 0f084f1..ac0e5ae 100644 --- a/tests/test_nix_fonts.py +++ b/tests/test_nix_fonts.py @@ -9,6 +9,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent def extract_fhs_profile(flake_text: str) -> str: + """Extract the FHS shell profile, substituting inert paths for Nix values.""" match = re.search(r"profile\s*=\s*''(.*?)'';", flake_text, re.DOTALL) if not match: raise ValueError("Could not find profile in flake.nix") @@ -19,7 +20,10 @@ def extract_fhs_profile(flake_text: str) -> str: return profile_script class NixFontsConfigTest(unittest.TestCase): + """Check packaged font configuration and isolation from host overrides.""" + def test_flake_font_configuration(self): + """Require bundled text fonts, user font support, and ordered sanitization.""" flake_text = (REPO_ROOT / "flake.nix").read_text(encoding="utf-8") # Flake must bundle both font families in darlingFontsConf @@ -41,6 +45,7 @@ class NixFontsConfigTest(unittest.TestCase): self.assertLess(unset_pos, export_pos, "unset must happen before export FONTCONFIG_FILE") def test_fhs_profile_hostile_environment_sanitization(self): + """Clear hostile fontconfig overrides while preserving user XDG settings.""" flake_text = (REPO_ROOT / "flake.nix").read_text(encoding="utf-8") profile_script = extract_fhs_profile(flake_text) diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index 6db36e9..09ebb58 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -440,6 +440,7 @@ class SessionTests(unittest.TestCase): self.assertFalse((root / 'other-prefix').exists()) def test_first_run_eof_is_actionable_and_desktop_requests_terminal(self): + """Report missing terminal input without setup and package a terminal launcher.""" with tempfile.TemporaryDirectory(prefix='nix-session-test-') as directory: root = Path(directory) (root / 'scripts').mkdir() From bdbf1e9a37da44d9c52abc3b99b4062ff9e99d1c Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:57:05 +0200 Subject: [PATCH 3/3] test(nix): run font compatibility regressions in CI --- .github/workflows/source-check.yml | 2 ++ docs/NIX.md | 7 ++++--- flake.nix | 2 ++ tests/test_nix_fonts.py | 6 +++--- tests/test_nix_repairs.py | 3 ++- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index c068e01..3e91493 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -21,6 +21,8 @@ jobs: - run: python tools/riot-launcher-offline-check.py - run: python tools/riot-runtime-cleanup-offline-check.py - run: python tools/riot-cdn-dns-offline-check.py + - run: python -m unittest discover -s tests -p 'test_nix_fonts.py' + - run: python -m unittest discover -s tests -p 'test_nix_repairs.py' - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' - run: python -m unittest discover -s tests -p 'test_desktop_run.py' - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' diff --git a/docs/NIX.md b/docs/NIX.md index 05910c8..2cde500 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -215,9 +215,10 @@ the host's `/usr/share/fonts`, which the FHS environment replaces. Fonts in your user font directories remain available. This also prevents an icon-only user font inventory from becoming the only fallback for ordinary text. -Inherited `FONTCONFIG_PATH` and `FONTCONFIG_SYSROOT` overrides are cleared inside -the FHS environment so foreign include paths or an invalid sysroot cannot hide -the packaged fonts. Custom XDG font/configuration directories and legacy +The FHS environment selects the packaged fontconfig configuration and clears +inherited `FONTCONFIG_PATH` and `FONTCONFIG_SYSROOT` overrides. Clearing the +sysroot prevents it from redirecting the packaged configuration and font paths. +Custom XDG font/configuration directories and legacy `~/.fonts` remain supported, including paths with spaces. The host configuration and user font files are not modified. diff --git a/flake.nix b/flake.nix index f071941..ef8a7b5 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,8 @@ fontDirectories = darlingFonts; includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" + # makeFontsConf adds XDG font directories, but neither it nor + # conf.d/50-user.conf adds the legacy ~/.fonts directory. (pkgs.writeText "darling-user-fonts.conf" '' ~/.fonts diff --git a/tests/test_nix_fonts.py b/tests/test_nix_fonts.py index ac0e5ae..4562268 100644 --- a/tests/test_nix_fonts.py +++ b/tests/test_nix_fonts.py @@ -88,9 +88,9 @@ class NixFontsConfigTest(unittest.TestCase): self.assertNotIn("FONTCONFIG_SYSROOT", resulting_env) self.assertNotIn("FONTCONFIG_PATH", resulting_env) - self.assertTrue( - resulting_env.get("FONTCONFIG_FILE", "").startswith("/nix/store/"), - f"Unexpected FONTCONFIG_FILE: {resulting_env.get('FONTCONFIG_FILE')}", + self.assertEqual( + resulting_env.get("FONTCONFIG_FILE"), + "/nix/store/mock-darlingFontsConf", ) self.assertEqual(resulting_env.get("XDG_DATA_HOME"), "/tmp/mock-xdg data with spaces") self.assertEqual(resulting_env.get("XDG_CONFIG_HOME"), "/tmp/mock-xdg config with spaces") diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index 09ebb58..49e232f 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -454,7 +454,8 @@ class SessionTests(unittest.TestCase): self.assertIn('terminal', result.stderr.lower()) self.assertFalse((root / 'build').exists()) self.assertIn('Terminal=true', (ROOT / 'flake.nix').read_text()) - self.assertIn('checks.${system}', (ROOT / 'flake.nix').read_text()) + self.assertIn('packaging = pkgs.runCommand "darling-lol-packaging-tests"', + (ROOT / 'flake.nix').read_text()) def test_rootless_update_refuses_live_marker_without_build_or_stop(self): with tempfile.TemporaryDirectory(prefix='nix-update-idle-') as directory: