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: