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 c831f34..2cde500 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -215,6 +215,13 @@ 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. +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. + To check font availability without starting Darling or Riot: ```sh diff --git a/flake.nix b/flake.nix index de2c1af..ef8a7b5 100644 --- a/flake.nix +++ b/flake.nix @@ -17,7 +17,16 @@ 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" + # 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 + + '') + ]; }; # Python environment with dependencies required by tooling @@ -163,8 +172,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 +276,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 +303,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..4562268 --- /dev/null +++ b/tests/test_nix_fonts.py @@ -0,0 +1,100 @@ +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: + """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") + 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): + """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) + + 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.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") + 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..49e232f 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() @@ -453,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}.packaging', (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: