From eaaa8fd02da094e69130b0df3196e6d5c7cdfb10 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 11:55:24 -0400 Subject: [PATCH 01/35] feat(mappings): add app_to_hm for home-manager program modules --- src/mac2nix/mappings/app_to_hm.py | 133 ++++++++++++++++++++++++++++++ tests/mappings/test_app_to_hm.py | 68 +++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/mac2nix/mappings/app_to_hm.py create mode 100644 tests/mappings/test_app_to_hm.py diff --git a/src/mac2nix/mappings/app_to_hm.py b/src/mac2nix/mappings/app_to_hm.py new file mode 100644 index 0000000..7ba37cf --- /dev/null +++ b/src/mac2nix/mappings/app_to_hm.py @@ -0,0 +1,133 @@ +"""Application/tool to home-manager program module mapping.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class HMModuleInfo: + """Metadata describing a home-manager ``programs.*`` module for a detected app or tool.""" + + module_path: str + config_paths: list[str] = field(default_factory=list) + darwin_config_paths: list[str] = field(default_factory=list) + extensions_cmd: str | None = None + inject_only: bool = False + + +APP_TO_HM_MODULE: dict[str, HMModuleInfo] = { + # Version Control / SSH / GPG + "git": HMModuleInfo( + module_path="programs.git", + config_paths=["~/.config/git/config", "~/.config/git/ignore", "~/.config/git/attributes"], + ), + "ssh": HMModuleInfo(module_path="programs.ssh", config_paths=["~/.ssh/config"]), + "gpg": HMModuleInfo( + module_path="programs.gpg", + config_paths=["~/.gnupg/gpg.conf", "~/.gnupg/scdaemon.conf", "~/.gnupg/dirmngr.conf"], + ), + "gh": HMModuleInfo( + module_path="programs.gh", + config_paths=["~/.config/gh/config.yml"], + extensions_cmd="gh extension list", + ), + # Shells + "fish": HMModuleInfo( + module_path="programs.fish", + config_paths=["~/.config/fish/config.fish", "~/.config/fish/functions/", "~/.config/fish/completions/"], + ), + "zsh": HMModuleInfo(module_path="programs.zsh", config_paths=["~/.zshrc", "~/.zshenv", "~/.zprofile"]), + "bash": HMModuleInfo(module_path="programs.bash", config_paths=["~/.bashrc", "~/.bash_profile", "~/.profile"]), + # Terminal multiplexers / emulators + "tmux": HMModuleInfo(module_path="programs.tmux", config_paths=["~/.config/tmux/tmux.conf"]), + "alacritty": HMModuleInfo(module_path="programs.alacritty", config_paths=["~/.config/alacritty/alacritty.toml"]), + "kitty": HMModuleInfo(module_path="programs.kitty", config_paths=["~/.config/kitty/kitty.conf"]), + "ghostty": HMModuleInfo(module_path="programs.ghostty", config_paths=["~/.config/ghostty/config"]), + "wezterm": HMModuleInfo(module_path="programs.wezterm", config_paths=["~/.config/wezterm/wezterm.lua"]), + # Editors + "neovim": HMModuleInfo( + module_path="programs.neovim", + config_paths=["~/.config/nvim/init.lua", "~/.local/share/nvim/site/pack/hm/"], + ), + "vim": HMModuleInfo(module_path="programs.vim"), + "vscode": HMModuleInfo( + module_path="programs.vscode", + config_paths=["~/.config/Code/User/settings.json"], + darwin_config_paths=["~/Library/Application Support/Code/User/settings.json"], + extensions_cmd="code --list-extensions", + ), + "helix": HMModuleInfo( + module_path="programs.helix", + config_paths=["~/.config/helix/config.toml", "~/.config/helix/languages.toml"], + ), + "zed": HMModuleInfo(module_path="programs.zed-editor", config_paths=["~/.config/zed/settings.json"]), + # Browsers + "firefox": HMModuleInfo( + module_path="programs.firefox", + config_paths=["~/.mozilla/firefox/"], + darwin_config_paths=["~/Library/Application Support/Firefox/"], + ), + # Prompt / shell integration CLI tools + "starship": HMModuleInfo(module_path="programs.starship", config_paths=["~/.config/starship.toml"]), + "direnv": HMModuleInfo(module_path="programs.direnv", config_paths=["~/.config/direnv/direnv.toml"]), + "fzf": HMModuleInfo(module_path="programs.fzf", inject_only=True), + "bat": HMModuleInfo(module_path="programs.bat", config_paths=["~/.config/bat/config"]), + "eza": HMModuleInfo(module_path="programs.eza", config_paths=["~/.config/eza/theme.yml"]), + "fd": HMModuleInfo(module_path="programs.fd", config_paths=["~/.config/fd/ignore"]), + "ripgrep": HMModuleInfo(module_path="programs.ripgrep", config_paths=["~/.config/ripgrep/ripgreprc"]), + "jq": HMModuleInfo(module_path="programs.jq", inject_only=True), + "htop": HMModuleInfo(module_path="programs.htop", config_paths=["~/.config/htop/htoprc"]), + "btop": HMModuleInfo(module_path="programs.btop", config_paths=["~/.config/btop/btop.conf"]), + "zoxide": HMModuleInfo(module_path="programs.zoxide", inject_only=True), + "atuin": HMModuleInfo(module_path="programs.atuin", config_paths=["~/.config/atuin/config.toml"]), + "readline": HMModuleInfo(module_path="programs.readline", config_paths=["~/.inputrc"]), + "yt-dlp": HMModuleInfo(module_path="programs.yt-dlp", config_paths=["~/.config/yt-dlp/config"]), + "mpv": HMModuleInfo( + module_path="programs.mpv", config_paths=["~/.config/mpv/mpv.conf", "~/.config/mpv/input.conf"] + ), + "mcfly": HMModuleInfo(module_path="programs.mcfly", inject_only=True), + "autojump": HMModuleInfo(module_path="programs.autojump", inject_only=True), + "pazi": HMModuleInfo(module_path="programs.pazi", inject_only=True), + "carapace": HMModuleInfo(module_path="programs.carapace", inject_only=True), + "keychain": HMModuleInfo(module_path="programs.keychain", inject_only=True), + "pay-respects": HMModuleInfo(module_path="programs.pay-respects", inject_only=True), + "nnn": HMModuleInfo(module_path="programs.nnn", inject_only=True), + # DevOps / Cloud + "k9s": HMModuleInfo( + module_path="programs.k9s", + config_paths=["~/.config/k9s/config.yaml"], + darwin_config_paths=["~/Library/Application Support/k9s/"], + ), + "lazygit": HMModuleInfo( + module_path="programs.lazygit", + config_paths=["~/.config/lazygit/config.yml"], + darwin_config_paths=["~/Library/Application Support/lazygit/"], + ), + "docker": HMModuleInfo(module_path="programs.docker-cli", config_paths=["~/.docker/config.json"]), + "aws": HMModuleInfo(module_path="programs.awscli", config_paths=["~/.aws/config", "~/.aws/credentials"]), + # Programming languages / build tools + "go": HMModuleInfo( + module_path="programs.go", + config_paths=["~/.config/go/env"], + darwin_config_paths=["~/Library/Application Support/go/env"], + ), + "java": HMModuleInfo(module_path="programs.java", inject_only=True), + "cargo": HMModuleInfo(module_path="programs.cargo", config_paths=["~/.cargo/config.toml"]), + "npm": HMModuleInfo(module_path="programs.npm", config_paths=["~/.npmrc"]), + "pyenv": HMModuleInfo(module_path="programs.pyenv", inject_only=True), + "rbenv": HMModuleInfo(module_path="programs.rbenv", config_paths=["~/.rbenv/plugins/"]), + "mise": HMModuleInfo(module_path="programs.mise", config_paths=["~/.config/mise/config.toml"]), + # macOS-only window/status managers + "rectangle": HMModuleInfo( + module_path="programs.rectangle", + darwin_config_paths=["~/Library/Preferences/com.knollsoft.Rectangle.plist"], + ), + "aerospace": HMModuleInfo(module_path="programs.aerospace", config_paths=["~/.config/aerospace/aerospace.toml"]), + "sketchybar": HMModuleInfo(module_path="programs.sketchybar", config_paths=["~/.config/sketchybar/sketchybarrc"]), +} + + +def get_hm_module(app_name: str) -> HMModuleInfo | None: + """Look up the home-manager module info for an app/tool name, or None if unmapped.""" + return APP_TO_HM_MODULE.get(app_name.lower()) diff --git a/tests/mappings/test_app_to_hm.py b/tests/mappings/test_app_to_hm.py new file mode 100644 index 0000000..7b2878f --- /dev/null +++ b/tests/mappings/test_app_to_hm.py @@ -0,0 +1,68 @@ +"""Tests for app_to_hm mapping.""" + +from mac2nix.mappings.app_to_hm import APP_TO_HM_MODULE, HMModuleInfo, get_hm_module + + +class TestAppToHm: + def test_git_module(self) -> None: + info = APP_TO_HM_MODULE["git"] + assert info.module_path == "programs.git" + assert "~/.config/git/config" in info.config_paths + + def test_ssh_module(self) -> None: + info = APP_TO_HM_MODULE["ssh"] + assert info.module_path == "programs.ssh" + assert info.config_paths == ["~/.ssh/config"] + + def test_fish_module(self) -> None: + info = APP_TO_HM_MODULE["fish"] + assert info.module_path == "programs.fish" + assert "~/.config/fish/config.fish" in info.config_paths + + def test_zsh_module(self) -> None: + info = APP_TO_HM_MODULE["zsh"] + assert info.module_path == "programs.zsh" + assert "~/.zshrc" in info.config_paths + + def test_darwin_aware_vscode_module(self) -> None: + info = APP_TO_HM_MODULE["vscode"] + assert info.module_path == "programs.vscode" + assert info.config_paths == ["~/.config/Code/User/settings.json"] + assert info.darwin_config_paths == ["~/Library/Application Support/Code/User/settings.json"] + assert info.extensions_cmd == "code --list-extensions" + + def test_darwin_aware_k9s_lazygit_go_modules(self) -> None: + assert APP_TO_HM_MODULE["k9s"].darwin_config_paths == ["~/Library/Application Support/k9s/"] + assert APP_TO_HM_MODULE["lazygit"].darwin_config_paths == ["~/Library/Application Support/lazygit/"] + assert APP_TO_HM_MODULE["go"].darwin_config_paths == ["~/Library/Application Support/go/env"] + + def test_darwin_only_rectangle_module(self) -> None: + info = APP_TO_HM_MODULE["rectangle"] + assert info.module_path == "programs.rectangle" + assert info.config_paths == [] + assert info.darwin_config_paths == ["~/Library/Preferences/com.knollsoft.Rectangle.plist"] + + def test_inject_only_shell_integration_modules(self) -> None: + for name in ("fzf", "zoxide", "jq", "pyenv"): + info = APP_TO_HM_MODULE[name] + assert info.inject_only is True + assert info.config_paths == [] + + def test_non_inject_only_module_defaults_false(self) -> None: + assert APP_TO_HM_MODULE["git"].inject_only is False + + def test_get_hm_module_case_insensitive(self) -> None: + assert get_hm_module("Git") == APP_TO_HM_MODULE["git"] + assert get_hm_module("VSCODE") == APP_TO_HM_MODULE["vscode"] + assert get_hm_module("Fzf") == APP_TO_HM_MODULE["fzf"] + + def test_get_hm_module_unrecognized_returns_none(self) -> None: + assert get_hm_module("does-not-exist") is None + assert get_hm_module("") is None + + def test_hm_module_info_defaults(self) -> None: + info = HMModuleInfo(module_path="programs.example") + assert info.config_paths == [] + assert info.darwin_config_paths == [] + assert info.extensions_cmd is None + assert info.inject_only is False From a34ff9c64e1ad396e5698e20d758d423a6ae713d Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 11:55:33 -0400 Subject: [PATCH 02/35] feat(mappings): add dotfile_to_hm mapping --- src/mac2nix/mappings/dotfile_to_hm.py | 135 ++++++++++++++++++++++++++ tests/mappings/test_dotfile_to_hm.py | 75 ++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/mac2nix/mappings/dotfile_to_hm.py create mode 100644 tests/mappings/test_dotfile_to_hm.py diff --git a/src/mac2nix/mappings/dotfile_to_hm.py b/src/mac2nix/mappings/dotfile_to_hm.py new file mode 100644 index 0000000..ee55ffa --- /dev/null +++ b/src/mac2nix/mappings/dotfile_to_hm.py @@ -0,0 +1,135 @@ +"""Dotfile path to home-manager program module mapping.""" + +from __future__ import annotations + +from pathlib import Path + +DOTFILE_TO_HM: dict[str, str] = { + # Shell Config Files + "~/.bashrc": "programs.bash", + "~/.bash_profile": "programs.bash", + "~/.profile": "programs.bash", + "~/.bash_logout": "programs.bash", + "~/.config/fish/config.fish": "programs.fish", + "~/.config/fish/functions": "programs.fish", + "~/.config/fish/completions": "programs.fish", + "~/.config/fish/conf.d": "programs.fish", + "~/.zshrc": "programs.zsh", + "~/.zshenv": "programs.zsh", + "~/.zprofile": "programs.zsh", + "~/.zlogin": "programs.zsh", + "~/.zlogout": "programs.zsh", + "~/.inputrc": "programs.readline", + "~/.config/nushell/config.nu": "programs.nushell", + "~/.config/nushell/env.nu": "programs.nushell", + # Git / VCS + "~/.gitconfig": "programs.git", + "~/.config/git/config": "programs.git", + "~/.config/git/ignore": "programs.git", + "~/.config/git/attributes": "programs.git", + "~/.config/gh/config.yml": "programs.gh", + "~/.config/lazygit/config.yml": "programs.lazygit", + "~/Library/Application Support/lazygit/config.yml": "programs.lazygit", + "~/.hgrc": "programs.mercurial", + "~/.config/jj/config.toml": "programs.jujutsu", + # SSH / GPG / Security + "~/.ssh/config": "programs.ssh", + "~/.gnupg/gpg.conf": "programs.gpg", + "~/.gnupg/scdaemon.conf": "programs.gpg", + "~/.gnupg/dirmngr.conf": "programs.gpg", + "~/.password-store": "programs.password-store", + # Terminal Emulators + "~/.config/alacritty/alacritty.toml": "programs.alacritty", + "~/.config/kitty/kitty.conf": "programs.kitty", + "~/.config/kitty/diff.conf": "programs.kitty", + "~/.config/kitty/macos-launch-services-cmdline": "programs.kitty", + "~/.config/ghostty/config": "programs.ghostty", + "~/.config/ghostty/themes": "programs.ghostty", + "~/.config/wezterm/wezterm.lua": "programs.wezterm", + "~/.config/wezterm/colors": "programs.wezterm", + # Editors + "~/.config/nvim/init.lua": "programs.neovim", + "~/.config/nvim/init.vim": "programs.neovim", + "~/.config/nvim/coc-settings.json": "programs.neovim", + "~/.vimrc": "programs.vim", + "~/.config/helix/config.toml": "programs.helix", + "~/.config/helix/languages.toml": "programs.helix", + "~/Library/Application Support/Code/User/settings.json": "programs.vscode", + "~/Library/Application Support/Code/User/keybindings.json": "programs.vscode", + "~/.config/Code/User/settings.json": "programs.vscode", + "~/.emacs.d/init.el": "programs.emacs", + "~/.config/emacs/init.el": "programs.emacs", + "~/.config/kak/kakrc": "programs.kakoune", + "~/.config/zed/settings.json": "programs.zed-editor", + "~/.config/micro/settings.json": "programs.micro", + # CLI Tools + "~/.config/starship.toml": "programs.starship", + "~/.config/direnv/direnv.toml": "programs.direnv", + "~/.config/direnv/direnvrc": "programs.direnv", + "~/.config/bat/config": "programs.bat", + "~/.config/eza/theme.yml": "programs.eza", + "~/.config/fd/ignore": "programs.fd", + "~/.config/ripgrep/ripgreprc": "programs.ripgrep", + "~/.config/htop/htoprc": "programs.htop", + "~/.config/btop/btop.conf": "programs.btop", + "~/.config/atuin/config.toml": "programs.atuin", + "~/.config/tmux/tmux.conf": "programs.tmux", + "~/.tmux.conf": "programs.tmux", + "~/.config/zellij/config.kdl": "programs.zellij", + "~/.config/bottom/bottom.toml": "programs.bottom", + "~/.config/yt-dlp/config": "programs.yt-dlp", + "~/.config/broot/conf.toml": "programs.broot", + "~/.config/lf/lfrc": "programs.lf", + "~/.config/yazi/yazi.toml": "programs.yazi", + "~/.config/ranger/rc.conf": "programs.ranger", + "~/.screenrc": "programs.screen", + "~/.tmate.conf": "programs.tmate", + "~/.lesskey": "programs.less", + "~/.config/pandoc/defaults.yaml": "programs.pandoc", + # Programming Languages + "~/Library/Application Support/go/env": "programs.go", + "~/.config/go/env": "programs.go", + "~/.cargo/config.toml": "programs.cargo", + "~/.npmrc": "programs.npm", + "~/.config/npm/npmrc": "programs.npm", + "~/.config/mise/config.toml": "programs.mise", + "~/.config/pypoetry/config.toml": "programs.poetry", + "~/.config/uv/uv.toml": "programs.uv", + "~/.config/ruff/ruff.toml": "programs.ruff", + "~/.gradle/gradle.properties": "programs.gradle", + "~/.config/bun/bunfig.toml": "programs.bun", + # DevOps / Cloud + "~/Library/Application Support/k9s/config.yaml": "programs.k9s", + "~/.config/k9s/config.yaml": "programs.k9s", + "~/.docker/config.json": "programs.docker-cli", + "~/.config/docker/config.json": "programs.docker-cli", + "~/.aws/config": "programs.awscli", + "~/.aws/credentials": "programs.awscli", + "~/.config/borgmatic/config.yaml": "programs.borgmatic", + # Browsers + "~/Library/Application Support/Firefox": "programs.firefox", + "~/.mozilla/firefox": "programs.firefox", + "~/Library/Application Support/Chromium": "programs.chromium", + "~/.config/chromium": "programs.chromium", + "~/.config/qutebrowser/config.py": "programs.qutebrowser", + # Media + "~/.config/mpv/mpv.conf": "programs.mpv", + "~/.config/mpv/input.conf": "programs.mpv", + "~/.config/mpv/scripts": "programs.mpv", +} + + +def _normalize_path(dotfile_path: str | Path) -> str: + """Normalize a dotfile path to a ``~``-relative, slash-stripped string for lookup.""" + raw = str(dotfile_path).rstrip("/") + home = str(Path.home()) + if raw == home: + return "~" + if raw.startswith(home + "/"): + return "~" + raw[len(home) :] + return raw + + +def get_hm_program(dotfile_path: str | Path) -> str | None: + """Look up the home-manager program module for a dotfile path, or None if unmapped.""" + return DOTFILE_TO_HM.get(_normalize_path(dotfile_path)) diff --git a/tests/mappings/test_dotfile_to_hm.py b/tests/mappings/test_dotfile_to_hm.py new file mode 100644 index 0000000..730e900 --- /dev/null +++ b/tests/mappings/test_dotfile_to_hm.py @@ -0,0 +1,75 @@ +"""Tests for dotfile_to_hm mapping.""" + +from pathlib import Path +from unittest.mock import patch + +from mac2nix.mappings.dotfile_to_hm import DOTFILE_TO_HM, get_hm_program + + +class TestDotfileToHm: + def test_shell_category(self) -> None: + assert DOTFILE_TO_HM["~/.zshrc"] == "programs.zsh" + assert DOTFILE_TO_HM["~/.config/fish/config.fish"] == "programs.fish" + + def test_git_vcs_category(self) -> None: + assert DOTFILE_TO_HM["~/.gitconfig"] == "programs.git" + assert DOTFILE_TO_HM["~/.config/gh/config.yml"] == "programs.gh" + + def test_ssh_gpg_security_category(self) -> None: + assert DOTFILE_TO_HM["~/.ssh/config"] == "programs.ssh" + assert DOTFILE_TO_HM["~/.gnupg/gpg.conf"] == "programs.gpg" + + def test_terminal_emulators_category(self) -> None: + assert DOTFILE_TO_HM["~/.config/alacritty/alacritty.toml"] == "programs.alacritty" + assert DOTFILE_TO_HM["~/.config/kitty/kitty.conf"] == "programs.kitty" + + def test_editors_category(self) -> None: + assert DOTFILE_TO_HM["~/.config/nvim/init.lua"] == "programs.neovim" + assert DOTFILE_TO_HM["~/.vimrc"] == "programs.vim" + + def test_cli_tools_category(self) -> None: + assert DOTFILE_TO_HM["~/.config/starship.toml"] == "programs.starship" + assert DOTFILE_TO_HM["~/.config/tmux/tmux.conf"] == "programs.tmux" + + def test_programming_languages_category(self) -> None: + assert DOTFILE_TO_HM["~/.cargo/config.toml"] == "programs.cargo" + assert DOTFILE_TO_HM["~/.npmrc"] == "programs.npm" + + def test_devops_cloud_category(self) -> None: + assert DOTFILE_TO_HM["~/.docker/config.json"] == "programs.docker-cli" + assert DOTFILE_TO_HM["~/.aws/config"] == "programs.awscli" + + def test_browsers_category(self) -> None: + assert DOTFILE_TO_HM["~/.mozilla/firefox"] == "programs.firefox" + assert DOTFILE_TO_HM["~/.config/qutebrowser/config.py"] == "programs.qutebrowser" + + def test_media_category(self) -> None: + assert DOTFILE_TO_HM["~/.config/mpv/mpv.conf"] == "programs.mpv" + assert DOTFILE_TO_HM["~/.config/mpv/input.conf"] == "programs.mpv" + + def test_xdg_and_legacy_paths_resolve_to_same_module(self) -> None: + assert get_hm_program("~/.tmux.conf") == get_hm_program("~/.config/tmux/tmux.conf") + assert get_hm_program("~/.gitconfig") == get_hm_program("~/.config/git/config") + assert get_hm_program("~/.npmrc") == get_hm_program("~/.config/npm/npmrc") + + def test_home_relative_and_absolute_paths_resolve_identically(self, tmp_path: Path) -> None: + with patch("mac2nix.mappings.dotfile_to_hm.Path.home", return_value=tmp_path): + relative_result = get_hm_program("~/.zshrc") + absolute_result = get_hm_program(tmp_path / ".zshrc") + absolute_str_result = get_hm_program(str(tmp_path / ".zshrc")) + + assert relative_result == "programs.zsh" + assert absolute_result == "programs.zsh" + assert absolute_str_result == "programs.zsh" + + def test_macos_library_application_support_path(self) -> None: + assert get_hm_program("~/Library/Application Support/lazygit/config.yml") == "programs.lazygit" + assert get_hm_program("~/Library/Application Support/Code/User/settings.json") == "programs.vscode" + + def test_trailing_slash_is_stripped(self) -> None: + assert get_hm_program("~/.config/fish/functions/") == "programs.fish" + assert get_hm_program("~/.password-store/") == "programs.password-store" + + def test_unrecognized_path_returns_none(self) -> None: + assert get_hm_program("~/.config/does-not-exist/config.toml") is None + assert get_hm_program("/some/unrelated/path") is None From bdec45d3264c3d39073c99159456eb06bf14e929 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 11:55:49 -0400 Subject: [PATCH 03/35] feat(mappings): add app_config_registry for Application Support paths --- src/mac2nix/mappings/app_config_registry.py | 195 ++++++++++++++++++++ tests/mappings/test_app_config_registry.py | 64 +++++++ 2 files changed, 259 insertions(+) create mode 100644 src/mac2nix/mappings/app_config_registry.py create mode 100644 tests/mappings/test_app_config_registry.py diff --git a/src/mac2nix/mappings/app_config_registry.py b/src/mac2nix/mappings/app_config_registry.py new file mode 100644 index 0000000..e849d26 --- /dev/null +++ b/src/mac2nix/mappings/app_config_registry.py @@ -0,0 +1,195 @@ +"""Registry of well-known macOS app configuration file locations. + +Used by the four-tier classifier to determine whether an app's Application +Support (or equivalent) directory contents are further analyzable, or are +opaque binary/database blobs that should be flagged for manual review. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from mac2nix.models.files import ConfigFileType + + +@dataclass(frozen=True, slots=True) +class AppConfigInfo: + config_paths: list[str] + file_type: ConfigFileType + scannable: bool = True + notes: str | None = None + + +APP_CONFIG_REGISTRY: dict[str, AppConfigInfo] = { + "com.apple.Safari": AppConfigInfo( + config_paths=["~/Library/Safari/History.db"], + file_type=ConfigFileType.DATABASE, + scannable=False, + notes="Browsing history database; bookmarks stored separately in ~/Library/Safari/Bookmarks.plist", + ), + "com.apple.mail": AppConfigInfo( + config_paths=["~/Library/Mail"], + file_type=ConfigFileType.DATABASE, + scannable=False, + notes="Versioned V*/MailData/Envelope Index sqlite database; not directly editable config", + ), + "com.google.Chrome": AppConfigInfo( + config_paths=["~/Library/Application Support/Google/Chrome/Default/Preferences"], + file_type=ConfigFileType.JSON, + scannable=True, + notes="Large single-file JSON blob; consider extracting only user-relevant keys", + ), + "com.brave.Browser": AppConfigInfo( + config_paths=["~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Preferences"], + file_type=ConfigFileType.JSON, + scannable=True, + notes="Chromium-based; same structure as Chrome's Preferences file", + ), + "org.mozilla.firefox": AppConfigInfo( + config_paths=["~/Library/Application Support/Firefox/Profiles"], + file_type=ConfigFileType.CONF, + scannable=False, + notes="prefs.js uses JS call syntax (user_pref(...)), not simple key=value; " + "profile directory names require profiles.ini resolution", + ), + "org.mozilla.thunderbird": AppConfigInfo( + config_paths=["~/Library/Thunderbird/Profiles"], + file_type=ConfigFileType.CONF, + scannable=False, + notes="Same prefs.js/profiles.ini structure as Firefox", + ), + "com.microsoft.VSCode": AppConfigInfo( + config_paths=[ + "~/Library/Application Support/Code/User/settings.json", + "~/Library/Application Support/Code/User/keybindings.json", + ], + file_type=ConfigFileType.JSON, + scannable=True, + ), + "com.sublimetext.4": AppConfigInfo( + config_paths=["~/Library/Application Support/Sublime Text/Packages/User/Preferences.sublime-settings"], + file_type=ConfigFileType.JSON, + scannable=True, + notes=".sublime-settings extension but JSON-with-comments format", + ), + "com.jetbrains.intellij": AppConfigInfo( + config_paths=["~/Library/Application Support/JetBrains"], + file_type=ConfigFileType.XML, + scannable=True, + notes="Versioned per-release subdirectories (e.g. IntelliJIdea2026.1/options/*.xml)", + ), + "com.jetbrains.pycharm": AppConfigInfo( + config_paths=["~/Library/Application Support/JetBrains"], + file_type=ConfigFileType.XML, + scannable=True, + notes="Same JetBrains versioned-subdirectory scheme as IntelliJ IDEA", + ), + "com.jetbrains.WebStorm": AppConfigInfo( + config_paths=["~/Library/Application Support/JetBrains"], + file_type=ConfigFileType.XML, + scannable=True, + notes="Same JetBrains versioned-subdirectory scheme as IntelliJ IDEA", + ), + "com.github.GitHubClient": AppConfigInfo( + config_paths=["~/Library/Application Support/GitHub Desktop"], + file_type=ConfigFileType.JSON, + scannable=True, + notes="Electron app config; exact filename unverified, flagged for manual review", + ), + "com.spotify.client": AppConfigInfo( + config_paths=["~/Library/Application Support/Spotify/prefs"], + file_type=ConfigFileType.CONF, + scannable=True, + notes="key=value pairs despite no file extension", + ), + "com.1password.1password": AppConfigInfo( + config_paths=["~/Library/Group Containers/2BUA8C4S2C.com.1password/Library/Application Support/1Password/Data"], + file_type=ConfigFileType.DATABASE, + scannable=False, + notes="Encrypted vault data, not human-editable config", + ), + "com.bitwarden.desktop": AppConfigInfo( + config_paths=["~/Library/Application Support/Bitwarden/data.json"], + file_type=ConfigFileType.DATABASE, + scannable=False, + notes="Encrypted vault export despite .json extension", + ), + "com.tinyspeck.slackmacgap": AppConfigInfo( + config_paths=["~/Library/Application Support/Slack/storage"], + file_type=ConfigFileType.DATABASE, + scannable=False, + notes="LevelDB-backed local storage, not directly parseable", + ), + "com.hnc.Discord": AppConfigInfo( + config_paths=["~/Library/Application Support/discord/settings.json"], + file_type=ConfigFileType.JSON, + scannable=True, + notes="Message/user cache stored separately in LevelDB (not scannable)", + ), + "com.docker.docker": AppConfigInfo( + config_paths=["~/Library/Group Containers/group.com.docker/settings.json"], + file_type=ConfigFileType.JSON, + scannable=True, + ), + "md.obsidian": AppConfigInfo( + config_paths=["~/Library/Application Support/obsidian/obsidian.json"], + file_type=ConfigFileType.JSON, + scannable=True, + notes="Global app config; per-vault settings live in each vault's .obsidian/ folder, not under ~/Library", + ), + "com.raycast.macos": AppConfigInfo( + config_paths=["~/Library/Application Support/com.raycast.macos"], + file_type=ConfigFileType.DATABASE, + scannable=False, + notes="Settings and extension state stored in sqlite databases", + ), + "com.googlecode.iterm2": AppConfigInfo( + config_paths=["~/Library/Application Support/iTerm2/DynamicProfiles"], + file_type=ConfigFileType.JSON, + scannable=True, + notes="Dynamic profile JSON files; core preferences stored separately in " + "~/Library/Preferences/com.googlecode.iterm2.plist (see preferences scanner)", + ), + "org.videolan.vlc": AppConfigInfo( + config_paths=["~/Library/Preferences/org.videolan.vlc/vlcrc"], + file_type=ConfigFileType.CONF, + scannable=True, + notes="INI-style key=value config despite being under ~/Library/Preferences", + ), + "us.zoom.xos": AppConfigInfo( + config_paths=["~/Library/Application Support/zoom.us/data/zoomus.enc.db"], + file_type=ConfigFileType.DATABASE, + scannable=False, + notes="Encrypted local settings db; user-visible preferences duplicated in " + "~/Library/Preferences/us.zoom.xos.plist", + ), + "com.culturedcode.ThingsMac": AppConfigInfo( + config_paths=["~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac"], + file_type=ConfigFileType.DATABASE, + scannable=False, + notes="Things Database.thingsdatabase sqlite store", + ), + "com.postmanlabs.mac": AppConfigInfo( + config_paths=["~/Library/Application Support/Postman"], + file_type=ConfigFileType.DATABASE, + scannable=False, + notes="Mostly cloud-synced; local storage format unverified, flagged for manual review", + ), + "org.qbittorrent.qBittorrent": AppConfigInfo( + config_paths=["~/Library/Preferences/qBittorrent/qBittorrent.ini"], + file_type=ConfigFileType.CONF, + scannable=True, + ), + "com.microsoft.rdc.macos": AppConfigInfo( + config_paths=[ + "~/Library/Containers/com.microsoft.rdc.macos/Data/Library/Application Support/Microsoft Remote Desktop" + ], + file_type=ConfigFileType.DATABASE, + scannable=False, + notes="Connection list stored in a Core Data sqlite store, not directly editable", + ), +} + + +def get_app_config(bundle_id: str) -> AppConfigInfo | None: + return APP_CONFIG_REGISTRY.get(bundle_id) diff --git a/tests/mappings/test_app_config_registry.py b/tests/mappings/test_app_config_registry.py new file mode 100644 index 0000000..c8cbb46 --- /dev/null +++ b/tests/mappings/test_app_config_registry.py @@ -0,0 +1,64 @@ +"""Tests for the app_config_registry mapping.""" + +from mac2nix.mappings.app_config_registry import ( + APP_CONFIG_REGISTRY, + AppConfigInfo, + get_app_config, +) +from mac2nix.models.files import ConfigFileType + + +class TestGetAppConfig: + def test_known_bundle_id(self): + info = get_app_config("com.apple.Safari") + assert info is not None + assert info.file_type == ConfigFileType.DATABASE + assert info.scannable is False + + def test_unknown_bundle_id_returns_none(self): + assert get_app_config("com.example.NotARealApp") is None + + def test_case_sensitive_lookup(self): + assert get_app_config("com.apple.safari") is None + + +class TestAppConfigRegistry: + def test_registry_has_minimum_entries(self): + assert len(APP_CONFIG_REGISTRY) >= 20 + + def test_all_entries_are_app_config_info(self): + for info in APP_CONFIG_REGISTRY.values(): + assert isinstance(info, AppConfigInfo) + assert len(info.config_paths) > 0 + + def test_safari_history_is_database_and_not_scannable(self): + info = APP_CONFIG_REGISTRY["com.apple.Safari"] + assert info.file_type == ConfigFileType.DATABASE + assert info.scannable is False + assert info.config_paths == ["~/Library/Safari/History.db"] + + def test_vscode_settings_are_json_and_scannable(self): + info = APP_CONFIG_REGISTRY["com.microsoft.VSCode"] + assert info.file_type == ConfigFileType.JSON + assert info.scannable is True + assert "~/Library/Application Support/Code/User/settings.json" in info.config_paths + + def test_spotify_prefs_are_conf(self): + info = APP_CONFIG_REGISTRY["com.spotify.client"] + assert info.file_type == ConfigFileType.CONF + assert info.scannable is True + + def test_at_least_one_database_entry_not_scannable(self): + database_entries = [info for info in APP_CONFIG_REGISTRY.values() if info.file_type == ConfigFileType.DATABASE] + assert len(database_entries) >= 1 + assert all(not info.scannable for info in database_entries) + + def test_docker_settings_are_json(self): + info = APP_CONFIG_REGISTRY["com.docker.docker"] + assert info.file_type == ConfigFileType.JSON + assert info.config_paths == ["~/Library/Group Containers/group.com.docker/settings.json"] + + def test_notes_default_to_none(self): + info = AppConfigInfo(config_paths=["~/foo"], file_type=ConfigFileType.UNKNOWN) + assert info.notes is None + assert info.scannable is True From c898cd56f5455d5376619af268f090d44ed0b8f0 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 11:57:59 -0400 Subject: [PATCH 04/35] feat(mappings): add app_to_package with nix-native alternative hints --- src/mac2nix/mappings/app_to_package.py | 173 +++++++++++++++++++++++++ tests/mappings/test_app_to_package.py | 160 +++++++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 src/mac2nix/mappings/app_to_package.py create mode 100644 tests/mappings/test_app_to_package.py diff --git a/src/mac2nix/mappings/app_to_package.py b/src/mac2nix/mappings/app_to_package.py new file mode 100644 index 0000000..346c3e6 --- /dev/null +++ b/src/mac2nix/mappings/app_to_package.py @@ -0,0 +1,173 @@ +"""macOS application bundle to nix-darwin installation channel mapping.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +_VERSION_SUFFIX_RE = re.compile(r"\s+\d+(\.\d+)*$") + +_NAME_OVERRIDES: dict[str, str] = { + "zoom.us": "zoom", + "iterm": "iterm2", + "linear": "linear-linear", + "ice": "jordanbaird-ice", + "github desktop": "github", + "parallels desktop": "parallels", + "alttab": "alt-tab", + "hidden bar": "hiddenbar", +} + + +@dataclass(frozen=True, slots=True) +class AppClassification: + source: str + package_name: str + nix_alternative: str | None = None + + +def normalize_app_name(name: str) -> str: + """Normalize a raw app bundle name into the lookup key used by APP_TO_PACKAGE.""" + normalized = name.strip() + if normalized.lower().endswith(".app"): + normalized = normalized[: -len(".app")] + normalized = _VERSION_SUFFIX_RE.sub("", normalized).strip().lower() + if normalized in _NAME_OVERRIDES: + return _NAME_OVERRIDES[normalized] + return normalized.replace(" ", "-") + + +def classify_app(name: str) -> AppClassification | None: + """Classify an app bundle name into its nix-darwin installation channel, or None if unrecognized.""" + return APP_TO_PACKAGE.get(normalize_app_name(name)) + + +APP_TO_PACKAGE: dict[str, AppClassification] = { + # nixpkgs (Darwin-confirmed) + "firefox": AppClassification("nixpkgs", "firefox"), + "alacritty": AppClassification("nixpkgs", "alacritty"), + "kitty": AppClassification("nixpkgs", "kitty"), + "wezterm": AppClassification("nixpkgs", "wezterm"), + "vlc": AppClassification("nixpkgs", "vlc"), + "emacs": AppClassification("nixpkgs", "emacs"), + "iina": AppClassification("nixpkgs", "iina"), + "ghostty": AppClassification("nixpkgs", "ghostty"), + "obs": AppClassification("nixpkgs", "obs-studio"), + "handbrake": AppClassification("nixpkgs", "handbrake"), + "neovide": AppClassification("nixpkgs", "neovide"), + "qutebrowser": AppClassification("nixpkgs", "qutebrowser"), + "utm": AppClassification("nixpkgs", "utm"), + "karabiner-elements": AppClassification("nixpkgs", "karabiner-elements"), + "monitorcontrol": AppClassification("nixpkgs", "monitorcontrol"), + # cask (Homebrew cask only) + "google-chrome": AppClassification("cask", "google-chrome"), + "visual-studio-code": AppClassification("cask", "visual-studio-code"), + "cursor": AppClassification("cask", "cursor"), + "slack": AppClassification("cask", "slack"), + "discord": AppClassification("cask", "discord", nix_alternative="discord"), + "zoom": AppClassification("cask", "zoom"), + "microsoft-teams": AppClassification("cask", "microsoft-teams"), + "telegram": AppClassification("cask", "telegram"), + "1password": AppClassification("cask", "1password"), + "alfred": AppClassification("cask", "alfred"), + "raycast": AppClassification("cask", "raycast"), + "rectangle": AppClassification("cask", "rectangle"), + "bettertouchtool": AppClassification("cask", "bettertouchtool"), + "docker": AppClassification("cask", "docker"), + "postman": AppClassification("cask", "postman"), + "tableplus": AppClassification("cask", "tableplus"), + "github": AppClassification("cask", "github"), + "spotify": AppClassification("cask", "spotify"), + "dropbox": AppClassification("cask", "dropbox"), + "google-drive": AppClassification("cask", "google-drive"), + "onedrive": AppClassification("cask", "onedrive"), + "notion": AppClassification("cask", "notion"), + "obsidian": AppClassification("cask", "obsidian", nix_alternative="obsidian"), + "logseq": AppClassification("cask", "logseq"), + "arc": AppClassification("cask", "arc"), + "brave-browser": AppClassification("cask", "brave-browser"), + "microsoft-edge": AppClassification("cask", "microsoft-edge"), + "warp": AppClassification("cask", "warp"), + "iterm2": AppClassification("cask", "iterm2"), + "sublime-text": AppClassification("cask", "sublime-text"), + "tower": AppClassification("cask", "tower"), + "fork": AppClassification("cask", "fork"), + "appcleaner": AppClassification("cask", "appcleaner"), + "the-unarchiver": AppClassification("cask", "the-unarchiver"), + "bartender": AppClassification("cask", "bartender"), + "cleanmymac": AppClassification("cask", "cleanmymac"), + "stats": AppClassification("cask", "stats"), + "parallels": AppClassification("cask", "parallels"), + "vmware-fusion": AppClassification("cask", "vmware-fusion"), + "hyper": AppClassification("cask", "hyper"), + "jetbrains-toolbox": AppClassification("cask", "jetbrains-toolbox"), + "android-studio": AppClassification("cask", "android-studio"), + "signal": AppClassification("cask", "signal", nix_alternative="signal-desktop"), + "keepassxc": AppClassification("cask", "keepassxc", nix_alternative="keepassxc"), + "gitkraken": AppClassification("cask", "gitkraken"), + "sublime-merge": AppClassification("cask", "sublime-merge"), + "sourcetree": AppClassification("cask", "sourcetree"), + "proxyman": AppClassification("cask", "proxyman"), + "charles": AppClassification("cask", "charles"), + "dash": AppClassification("cask", "dash"), + "transmit": AppClassification("cask", "transmit"), + "hammerspoon": AppClassification("cask", "hammerspoon"), + "amethyst": AppClassification("cask", "amethyst"), + "bitwarden": AppClassification("cask", "bitwarden"), + "alt-tab": AppClassification("cask", "alt-tab"), + "hiddenbar": AppClassification("cask", "hiddenbar"), + "keepingyouawake": AppClassification("cask", "keepingyouawake"), + # appstore (Mac App Store exclusive) + "xcode": AppClassification("appstore", "Xcode"), + "bear": AppClassification("appstore", "Bear"), + "magnet": AppClassification("appstore", "Magnet"), + "fantastical": AppClassification("appstore", "Fantastical"), + "things": AppClassification("appstore", "Things"), + "pixelmator-pro": AppClassification("appstore", "Pixelmator Pro"), + "amphetamine": AppClassification("appstore", "Amphetamine"), + "pages": AppClassification("appstore", "Pages"), + "numbers": AppClassification("appstore", "Numbers"), + "keynote": AppClassification("appstore", "Keynote"), + # system (bundled with macOS, no installation channel) + "safari": AppClassification("system", "n/a"), + "mail": AppClassification("system", "n/a"), + "calendar": AppClassification("system", "n/a"), + "notes": AppClassification("system", "n/a"), + "reminders": AppClassification("system", "n/a"), + "maps": AppClassification("system", "n/a"), + "photos": AppClassification("system", "n/a"), + "messages": AppClassification("system", "n/a"), + "facetime": AppClassification("system", "n/a"), + "music": AppClassification("system", "n/a"), + "tv": AppClassification("system", "n/a"), + "podcasts": AppClassification("system", "n/a"), + "news": AppClassification("system", "n/a"), + "stocks": AppClassification("system", "n/a"), + "books": AppClassification("system", "n/a"), + "preview": AppClassification("system", "n/a"), + "terminal": AppClassification("system", "n/a"), + "activity-monitor": AppClassification("system", "n/a"), + "disk-utility": AppClassification("system", "n/a"), + "keychain-access": AppClassification("system", "n/a"), + "system-preferences": AppClassification("system", "n/a"), + "system-settings": AppClassification("system", "n/a"), + "finder": AppClassification("system", "n/a"), + "textedit": AppClassification("system", "n/a"), + "quicktime-player": AppClassification("system", "n/a"), + "screenshot": AppClassification("system", "n/a"), + "font-book": AppClassification("system", "n/a"), + "calculator": AppClassification("system", "n/a"), + "dictionary": AppClassification("system", "n/a"), + "chess": AppClassification("system", "n/a"), + "siri": AppClassification("system", "n/a"), + "home": AppClassification("system", "n/a"), + "shortcuts": AppClassification("system", "n/a"), + "freeform": AppClassification("system", "n/a"), + "weather": AppClassification("system", "n/a"), + "clock": AppClassification("system", "n/a"), + "contacts": AppClassification("system", "n/a"), + "find-my": AppClassification("system", "n/a"), + "automator": AppClassification("system", "n/a"), + "imovie": AppClassification("system", "n/a"), + "garageband": AppClassification("system", "n/a"), +} diff --git a/tests/mappings/test_app_to_package.py b/tests/mappings/test_app_to_package.py new file mode 100644 index 0000000..15b4f89 --- /dev/null +++ b/tests/mappings/test_app_to_package.py @@ -0,0 +1,160 @@ +"""Tests for app_to_package mapping.""" + +from mac2nix.mappings.app_to_package import APP_TO_PACKAGE, classify_app, normalize_app_name + + +class TestNormalizeAppName: + def test_strips_app_suffix_and_lowercases(self) -> None: + assert normalize_app_name("Firefox.app") == "firefox" + + def test_hyphenates_spaces(self) -> None: + assert normalize_app_name("Google Chrome.app") == "google-chrome" + + def test_zoom_us_bundle_gotcha(self) -> None: + assert normalize_app_name("zoom.us.app") == "zoom" + + def test_iterm_bundle_gotcha(self) -> None: + assert normalize_app_name("iTerm.app") == "iterm2" + + def test_brave_browser_gotcha(self) -> None: + assert normalize_app_name("Brave Browser.app") == "brave-browser" + + def test_github_desktop_gotcha(self) -> None: + assert normalize_app_name("GitHub Desktop.app") == "github" + + def test_linear_gotcha(self) -> None: + assert normalize_app_name("Linear.app") == "linear-linear" + + def test_ice_gotcha(self) -> None: + assert normalize_app_name("Ice.app") == "jordanbaird-ice" + + def test_parallels_desktop_gotcha(self) -> None: + assert normalize_app_name("Parallels Desktop.app") == "parallels" + + def test_alttab_gotcha(self) -> None: + assert normalize_app_name("AltTab.app") == "alt-tab" + + def test_hidden_bar_gotcha(self) -> None: + assert normalize_app_name("Hidden Bar.app") == "hiddenbar" + + def test_strips_trailing_version_number(self) -> None: + assert normalize_app_name("1Password 7.app") == "1password" + assert normalize_app_name("Transmit 5.app") == "transmit" + + def test_leading_digit_in_name_is_preserved(self) -> None: + assert normalize_app_name("1Password.app") == "1password" + + +class TestClassifyAppNixpkgs: + def test_firefox(self) -> None: + result = classify_app("Firefox.app") + assert result is not None + assert result.source == "nixpkgs" + assert result.package_name == "firefox" + + def test_alacritty(self) -> None: + result = classify_app("Alacritty.app") + assert result is not None + assert result.source == "nixpkgs" + + def test_karabiner_elements(self) -> None: + result = classify_app("Karabiner-Elements.app") + assert result is not None + assert result.source == "nixpkgs" + assert result.package_name == "karabiner-elements" + + +class TestClassifyAppCask: + def test_slack(self) -> None: + result = classify_app("Slack.app") + assert result is not None + assert result.source == "cask" + assert result.package_name == "slack" + assert result.nix_alternative is None + + def test_notion(self) -> None: + result = classify_app("Notion.app") + assert result is not None + assert result.source == "cask" + + def test_iterm_via_bundle_name(self) -> None: + result = classify_app("iTerm.app") + assert result is not None + assert result.source == "cask" + assert result.package_name == "iterm2" + + def test_discord_has_nix_alternative(self) -> None: + result = classify_app("Discord.app") + assert result is not None + assert result.source == "cask" + assert result.nix_alternative == "discord" + + def test_obsidian_has_nix_alternative(self) -> None: + result = classify_app("Obsidian.app") + assert result is not None + assert result.nix_alternative == "obsidian" + + def test_signal_has_nix_alternative(self) -> None: + result = classify_app("Signal.app") + assert result is not None + assert result.nix_alternative == "signal-desktop" + + +class TestClassifyAppAppstore: + def test_xcode(self) -> None: + result = classify_app("Xcode.app") + assert result is not None + assert result.source == "appstore" + assert result.package_name == "Xcode" + + def test_bear(self) -> None: + result = classify_app("Bear.app") + assert result is not None + assert result.source == "appstore" + + def test_pages(self) -> None: + result = classify_app("Pages.app") + assert result is not None + assert result.source == "appstore" + + +class TestClassifyAppSystem: + def test_safari(self) -> None: + result = classify_app("Safari.app") + assert result is not None + assert result.source == "system" + + def test_finder(self) -> None: + result = classify_app("Finder.app") + assert result is not None + assert result.source == "system" + + def test_terminal(self) -> None: + result = classify_app("Terminal.app") + assert result is not None + assert result.source == "system" + + +class TestClassifyAppCaseInsensitivity: + def test_uppercase_bundle_name(self) -> None: + assert classify_app("FIREFOX.APP") == classify_app("Firefox.app") + + def test_mixed_case_bundle_name(self) -> None: + assert classify_app("sLaCk.aPp") == classify_app("Slack.app") + + +class TestClassifyAppUnrecognized: + def test_unknown_app_returns_none(self) -> None: + assert classify_app("SomeRandomEnterpriseApp.app") is None + + def test_empty_string_returns_none(self) -> None: + assert classify_app("") is None + + +class TestAppToPackageCoverage: + def test_has_at_least_115_entries(self) -> None: + assert len(APP_TO_PACKAGE) >= 115 + + def test_all_sources_represented(self) -> None: + sources = {classification.source for classification in APP_TO_PACKAGE.values()} + assert sources == {"nixpkgs", "cask", "appstore", "system"} From ea6f20d84918119b7f346155f45487ae602e5a29 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 11:58:05 -0400 Subject: [PATCH 05/35] feat(mappings): add ephemeral state filter for scanned preferences --- src/mac2nix/mappings/ephemeral_filter.py | 198 ++++++++++++++++++++++ tests/mappings/test_ephemeral_filter.py | 202 +++++++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 src/mac2nix/mappings/ephemeral_filter.py create mode 100644 tests/mappings/test_ephemeral_filter.py diff --git a/src/mac2nix/mappings/ephemeral_filter.py b/src/mac2nix/mappings/ephemeral_filter.py new file mode 100644 index 0000000..2ae3481 --- /dev/null +++ b/src/mac2nix/mappings/ephemeral_filter.py @@ -0,0 +1,198 @@ +"""Heuristic filters for ephemeral, non-reproducible plist state. + +Ported from defaults2nix's noise-filtering heuristics, with fixes for its +substring-overmatch bugs. defaults2nix flags any key containing "time", +"at", or "when" as a timestamp, which wrongly filters real settings like +``autohide-time-modifier``, ``TimeFormat``, or ``Authenticate``. This module +matches timestamp-like keys by camelCase-aware suffix instead: a key is only +flagged when one of the known timestamp words is its trailing word (or pair +of words), not merely a substring anywhere in it. +""" + +from __future__ import annotations + +import re +from typing import Any + +_UI_STATE_KEY_PREFIXES: tuple[str, ...] = ( + "NSWindow Frame ", + "NSSplitView Subview Frames ", + "NSTableView Columns ", + "NSTableView Sort Ordering ", + "NSTableView Supports ", + "NSToolbar Configuration", + "ExtensionsToolbarConfiguration", +) + +_UI_STATE_KEY_EXACT: frozenset[str] = frozenset( + { + "NSNavPanelExpandedSize", + "NSNavPanelFileLastListMode", + "NSNavPanelFileListMode", + "NSPreferencesContentSize", + "TB Icon Size Mode", + "TB Size Mode", + "UserColumnSortPerTab", + "UserColumnsPerTab", + } +) + +_UI_STATE_KEY_CONTAINS: tuple[str, ...] = ( + "Column Width", + "image window frame", + "image window parent frame", + "CropRect", +) + +# CamelCase-tokenized (lowercased) suffixes that indicate a timestamp-ish key. +# Deliberately narrower than defaults2nix's substring list (no bare "time", +# "date", "updated", "at", "when" — those overmatch real settings). +_TIMESTAMP_KEY_SUFFIXES: frozenset[str] = frozenset( + { + "lastused", + "lastseen", + "lastaccess", + "lastconnected", + "lastlaunch", + "lastopen", + "lastvisit", + "checkedat", + "setat", + "startedat", + "endedat", + "timestamp", + "epoch", + "expiry", + "expires", + } +) + +_UNIX_TIMESTAMP_MIN = 946_684_800 # 2000-01-01 +_UNIX_TIMESTAMP_MAX = 2_208_988_800 # 2040-01-01 +_CFABSOLUTE_TIME_MIN = 100_000_000 # ~2004, seconds since 2001-01-01 +_CFABSOLUTE_TIME_MAX = 1_230_768_000 # ~2040 + +_DATE_STRING_RE = re.compile(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$") +_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") +_HASHED_ID_RE = re.compile(r"^_[0-9a-fA-F]{32}$") +_BINARY_DATA_SENTINEL_RE = re.compile(r"^$") + +# sanitize_plist_values() (scanners/_utils.py) converts plist blobs into +# this sentinel string before scanner output ever reaches the mapping layer, +# so bytes objects never appear here — matching the sentinel format is the +# only way to detect binary-origin values. +_SPARKLE_ALLOWLIST: frozenset[str] = frozenset({"SUEnableAutomaticChecks"}) + +_CAMEL_TOKEN_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z0-9]+|[A-Z0-9]+") +_NON_ALNUM_RE = re.compile(r"[^A-Za-z0-9]+") + + +def _tokenize(key: str) -> list[str]: + tokens: list[str] = [] + for segment in _NON_ALNUM_RE.split(key): + if segment: + tokens.extend(match.lower() for match in _CAMEL_TOKEN_RE.findall(segment)) + return tokens + + +def _is_ui_state_key(key: str) -> bool: + if key in _UI_STATE_KEY_EXACT: + return True + if any(key.startswith(prefix) for prefix in _UI_STATE_KEY_PREFIXES): + return True + if any(pattern in key for pattern in _UI_STATE_KEY_CONTAINS): + return True + return key.endswith("Frame") and ("Window" in key or "window" in key) + + +def _is_timestamp_key(key: str) -> bool: + tokens = _tokenize(key) + widths = (1, 2) + return any(len(tokens) >= width and "".join(tokens[-width:]) in _TIMESTAMP_KEY_SUFFIXES for width in widths) + + +def _is_uuid_string(text: str) -> bool: + return bool(_UUID_RE.match(text)) or bool(_HASHED_ID_RE.match(text)) + + +def _is_uuid_key(key: str) -> bool: + return _is_uuid_string(key) + + +def _is_sparkle_key(key: str) -> bool: + return key.startswith("SU") and key not in _SPARKLE_ALLOWLIST + + +def _is_timestamp_value_range(value: Any) -> bool: + if isinstance(value, bool) or not isinstance(value, int | float): + return False + return _UNIX_TIMESTAMP_MIN <= value <= _UNIX_TIMESTAMP_MAX or _CFABSOLUTE_TIME_MIN <= value <= _CFABSOLUTE_TIME_MAX + + +def _is_date_string_value(value: Any) -> bool: + return isinstance(value, str) and bool(_DATE_STRING_RE.match(value)) + + +def _is_uuid_value(value: Any) -> bool: + return isinstance(value, str) and _is_uuid_string(value) + + +def _is_binary_data_sentinel(value: Any) -> bool: + return isinstance(value, str) and bool(_BINARY_DATA_SENTINEL_RE.match(value)) + + +def _parses_as_float(token: str) -> bool: + try: + float(token) + except ValueError: + return False + return True + + +def _is_ui_geometry_value(value: Any) -> bool: + if not isinstance(value, str): + return False + if value.startswith("{{") and value.endswith("}}"): + return True + if value.startswith("{") and value.endswith("}") and value.count(",") == 1 and "=" not in value: + return True + tokens = value.split() + if len(tokens) == 8 and all(_parses_as_float(token) for token in tokens): + return True + return value.count(",") == 5 and value.endswith(("NO", "YES")) + + +_KEY_PREDICATES: tuple[Any, ...] = ( + _is_ui_state_key, + _is_timestamp_key, + _is_uuid_key, + _is_sparkle_key, +) + +_VALUE_PREDICATES: tuple[Any, ...] = ( + _is_timestamp_value_range, + _is_date_string_value, + _is_uuid_value, + _is_binary_data_sentinel, + _is_ui_geometry_value, +) + + +def is_ephemeral(key: str, value: Any) -> bool: + """Return True if key/value looks like ephemeral UI or runtime state. + + Note on cache keys: there is deliberately no key-pattern rule for + ``*Cache*``/``*CacheSize*``/``*CachePath*`` — real config like + ``WebKitCacheModel``, ``DiskCacheSize``, and ``CachePolicy`` would be + wrongly filtered. A cache key is only caught here when its *value* is + independently timestamp-like (via ``_is_timestamp_value_range`` or + ``_is_date_string_value``), never from the key name alone. + """ + if any(predicate(key) for predicate in _KEY_PREDICATES): + return True + return any(predicate(value) for predicate in _VALUE_PREDICATES) + + +def filter_ephemeral(keys: dict[str, Any]) -> dict[str, Any]: + """Return a copy of keys with all is_ephemeral-matching entries removed.""" + return {k: v for k, v in keys.items() if not is_ephemeral(k, v)} diff --git a/tests/mappings/test_ephemeral_filter.py b/tests/mappings/test_ephemeral_filter.py new file mode 100644 index 0000000..f5c92e2 --- /dev/null +++ b/tests/mappings/test_ephemeral_filter.py @@ -0,0 +1,202 @@ +"""Tests for the ephemeral state filter. + +Every axis below pairs a case that SHOULD be filtered with a deliberate +near-miss that looks similar but must NOT be filtered — this module exists +specifically to fix defaults2nix's substring-overmatch bugs, so the +near-miss cases are the load-bearing assertions. +""" + +from mac2nix.mappings.ephemeral_filter import filter_ephemeral, is_ephemeral + + +class TestUiStateKeyPatterns: + def test_ns_window_frame_prefix_is_filtered(self) -> None: + assert is_ephemeral("NSWindow Frame TerminalWindow", "100 100 800 600 0 0 1440 900") is True + + def test_exact_match_is_filtered(self) -> None: + assert is_ephemeral("NSNavPanelExpandedSize", True) is True + + def test_frame_suffix_with_window_is_filtered(self) -> None: + assert is_ephemeral("MainWindowFrame", "100 100 800 600 0 0 1440 900") is True + + def test_contains_pattern_is_filtered(self) -> None: + assert is_ephemeral("CropRect", "{{0, 0}, {100, 100}}") is True + + def test_unrelated_window_key_is_not_filtered(self) -> None: + assert is_ephemeral("WindowTabbingMode", "always") is False + + def test_similar_nav_panel_key_is_not_filtered(self) -> None: + assert is_ephemeral("NSNavPanelStyle", "expanded") is False + + def test_frame_without_window_is_not_filtered(self) -> None: + assert is_ephemeral("AnimationFrame", 12) is False + + +class TestTimestampKeySuffix: + def test_last_used_suffix_is_filtered(self) -> None: + assert is_ephemeral("SomeAppLastUsed", 5) is True + + def test_checked_at_suffix_is_filtered(self) -> None: + assert is_ephemeral("LastCheckedAt", 5) is True + + def test_epoch_suffix_is_filtered(self) -> None: + assert is_ephemeral("UpdateEpoch", 5) is True + + def test_lowercase_camel_checked_at_is_filtered(self) -> None: + assert is_ephemeral("checkedAt", 5) is True + + def test_autohide_time_modifier_is_not_filtered(self) -> None: + """The flagship defaults2nix bug: 'time' as a substring, not a suffix word.""" + assert is_ephemeral("autohide-time-modifier", 2) is False + + def test_time_format_is_not_filtered(self) -> None: + assert is_ephemeral("TimeFormat", "24Hour") is False + + def test_date_format_is_not_filtered(self) -> None: + assert is_ephemeral("DateFormat", "MM/DD/YYYY") is False + + def test_screen_saver_idle_time_is_not_filtered(self) -> None: + assert is_ephemeral("ScreenSaverIdleTime", 600) is False + + def test_category_is_not_filtered(self) -> None: + """Guards against defaults2nix's 'at' substring bug (Category contains 'at').""" + assert is_ephemeral("Category", "Productivity") is False + + def test_authenticate_is_not_filtered(self) -> None: + """Guards against defaults2nix's 'when'/'at' substring bugs.""" + assert is_ephemeral("Authenticate", False) is False + + +class TestTimestampValueRange: + def test_unix_timestamp_range_is_filtered(self) -> None: + assert is_ephemeral("RandomCounter", 1_700_000_000) is True + + def test_cfabsolute_time_range_is_filtered(self) -> None: + assert is_ephemeral("RandomCounter", 500_000_000) is True + + def test_small_int_is_not_filtered(self) -> None: + assert is_ephemeral("RetryCount", 42) is False + + def test_bool_is_not_treated_as_numeric_timestamp(self) -> None: + assert is_ephemeral("SomeFlag", True) is False + + def test_out_of_range_large_int_is_not_filtered(self) -> None: + assert is_ephemeral("FileSizeBytes", 5_000_000_000) is False + + +class TestDateStringValue: + def test_date_only_is_filtered(self) -> None: + assert is_ephemeral("SomeKey", "2026-07-20") is True + + def test_full_datetime_is_filtered(self) -> None: + assert is_ephemeral("SomeKey", "2026-07-20T11:52:00") is True + + def test_partial_date_is_not_filtered(self) -> None: + assert is_ephemeral("SomeKey", "2026-07") is False + + def test_digits_only_is_not_filtered(self) -> None: + assert is_ephemeral("SomeKey", "20260720") is False + + def test_version_string_is_not_filtered(self) -> None: + assert is_ephemeral("AppVersion", "1.2.3") is False + + +class TestUuidDetection: + def test_uuid_whole_key_is_filtered(self) -> None: + assert is_ephemeral("550e8400-e29b-41d4-a716-446655440000", "value") is True + + def test_uuid_whole_value_is_filtered(self) -> None: + assert is_ephemeral("SessionID", "550e8400-e29b-41d4-a716-446655440000") is True + + def test_hashed_id_key_is_filtered(self) -> None: + assert is_ephemeral("_19a3bc4999bddb89e1a44f4b87bdc37c", "value") is True + + def test_uuid_embedded_in_larger_string_is_not_filtered(self) -> None: + assert is_ephemeral("SessionID-550e8400-e29b-41d4-a716-446655440000-cache", "value") is False + + def test_short_underscore_prefixed_string_is_not_filtered(self) -> None: + assert is_ephemeral("_1234", "value") is False + + +class TestBinaryDataSentinel: + def test_sentinel_format_is_filtered(self) -> None: + assert is_ephemeral("SomeKey", "") is True + + def test_malformed_sentinel_is_not_filtered(self) -> None: + assert is_ephemeral("SomeKey", "") is False + + def test_plain_bytes_description_is_not_filtered(self) -> None: + assert is_ephemeral("SomeKey", "128 bytes") is False + + +class TestSparklePrefix: + def test_su_last_check_time_is_filtered(self) -> None: + assert is_ephemeral("SULastCheckTime", "2026-07-20") is True + + def test_su_update_relaunch_path_is_filtered(self) -> None: + assert is_ephemeral("SUUpdateRelaunchPath", "/Applications/App.app") is True + + def test_su_has_launched_before_is_filtered(self) -> None: + assert is_ephemeral("SUHasLaunchedBefore", True) is True + + def test_enable_automatic_checks_is_not_filtered(self) -> None: + assert is_ephemeral("SUEnableAutomaticChecks", True) is False + + def test_lowercase_su_prefix_is_not_filtered(self) -> None: + assert is_ephemeral("SubtitleFont", "Helvetica") is False + + +class TestUiGeometryValue: + def test_nsrect_is_filtered(self) -> None: + assert is_ephemeral("WindowGeometry", "{{0, 0}, {800, 600}}") is True + + def test_nssize_is_filtered(self) -> None: + assert is_ephemeral("PanelSize", "{800, 600}") is True + + def test_window_frame_floats_is_filtered(self) -> None: + assert is_ephemeral("SomeGeometry", "100 200 300 400 500 600 700 800") is True + + def test_split_view_frame_is_filtered(self) -> None: + assert is_ephemeral("SomeSplitView", "0,0,100,100,50,YES") is True + + def test_nssize_with_equals_is_not_filtered(self) -> None: + assert is_ephemeral("PanelSize", "{width=800, height=600}") is False + + def test_eight_non_float_tokens_is_not_filtered(self) -> None: + assert is_ephemeral("SomeGeometry", "one two three four five six seven eight") is False + + def test_five_commas_without_yes_no_is_not_filtered(self) -> None: + assert is_ephemeral("SomeSplitView", "0,0,100,100,50,MAYBE") is False + + +class TestCacheKeysNotFilteredOnPatternAlone: + def test_disk_cache_size_with_ordinary_value_is_not_filtered(self) -> None: + assert is_ephemeral("DiskCacheSize", 52_428_800) is False + + def test_webkit_cache_model_is_not_filtered(self) -> None: + assert is_ephemeral("WebKitCacheModel", 0) is False + + def test_cache_policy_is_not_filtered(self) -> None: + assert is_ephemeral("CachePolicy", 1) is False + + def test_cache_key_with_timestamp_like_value_is_filtered(self) -> None: + """Not a key-pattern rule — this only fires via the general value-range check.""" + assert is_ephemeral("ThumbnailCacheLastPurged", 1_700_000_000) is True + + +class TestFilterEphemeral: + def test_mixed_dict_keeps_only_non_ephemeral_entries(self) -> None: + keys = { + "NSWindow Frame Main": "100 100 800 600 0 0 1440 900", + "SULastCheckTime": "2026-07-20", + "AppVersion": "1.2.3", + "TimeFormat": "24Hour", + "RetryCount": 3, + } + + result = filter_ephemeral(keys) + + assert result == {"AppVersion": "1.2.3", "TimeFormat": "24Hour", "RetryCount": 3} + + def test_empty_dict_returns_empty_dict(self) -> None: + assert filter_ephemeral({}) == {} From e9709a65a7b7e1f75591377f255e7c9703495d53 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 11:59:10 -0400 Subject: [PATCH 06/35] feat(mappings): add non-defaults nix-darwin option mappings --- src/mac2nix/mappings/non_defaults_to_nix.py | 325 ++++++++++++++++++++ tests/mappings/test_non_defaults_to_nix.py | 161 ++++++++++ 2 files changed, 486 insertions(+) create mode 100644 src/mac2nix/mappings/non_defaults_to_nix.py create mode 100644 tests/mappings/test_non_defaults_to_nix.py diff --git a/src/mac2nix/mappings/non_defaults_to_nix.py b/src/mac2nix/mappings/non_defaults_to_nix.py new file mode 100644 index 0000000..7a921d8 --- /dev/null +++ b/src/mac2nix/mappings/non_defaults_to_nix.py @@ -0,0 +1,325 @@ +"""Non-``system.defaults`` scanner fields -> nix-darwin option paths. + +Covers the environment, fonts, launchd, networking, power, programs, +security, and time nix-darwin modules -- everything Tier 1-mappable outside +the ``system.defaults.*`` tree. :data:`FONT_TO_NIXPKGS` and +:func:`get_font_nixpkgs` resolve installed font filenames to nixpkgs +attributes; :data:`LAUNCHD_LABEL_TO_SERVICE` and :data:`LAUNCHD_KEYS_TO_DROP` +support routing ``launch_agents`` scanner output to native ``services.*`` +modules or raw ``launchd.*.serviceConfig``; the remaining dicts are direct +scanner-field-name -> nix-darwin-option-path lookups. + +Source: hack/research/feat-research-1746309600-nix-darwin-non-defaults-options.md +""" + +from __future__ import annotations + +import fnmatch +import re + +# --------------------------------------------------------------------------- +# Fonts (fonts.packages) +# --------------------------------------------------------------------------- + +# Keys are the output of `_normalize_font_name`, not raw display names. Nerd +# Font variants keep a trailing "nf" marker post-normalization specifically +# so they don't collide with their base-font counterpart (e.g. "Fira Code" +# and "FiraCode NF" both reduce to "firacode" once whitespace/case/"NF" are +# treated as pure noise -- the "nf" marker is what nixpkgs.nerd-fonts.* needs +# to stay distinct from pkgs.fira-code). A value of `None` marks a font with +# a confirmed, deliberate lack of a nixpkgs equivalent (Apple system fonts). +FONT_TO_NIXPKGS: dict[str, str | None] = { + # Developer / monospace fonts + "firacode": "pkgs.fira-code", + "jetbrainsmono": "pkgs.jetbrains-mono", + "hack": "pkgs.hack-font", + "sourcecodepro": "pkgs.source-code-pro", + "cascadiacode": "pkgs.cascadia-code", + "inconsolata": "pkgs.inconsolata", + "iosevka": "pkgs.iosevka", + "iosevkaterm": "pkgs.iosevka", + "victormono": "pkgs.victor-mono", + "ibmplexmono": "pkgs.ibm-plex", + "monaspace": "pkgs.monaspace", + "recursive": "pkgs.recursive", + "recursivemono": "pkgs.recursive", + "overpass": "pkgs.overpass", + "overpassmono": "pkgs.overpass", + "maplemono": "pkgs.maple-mono", + "juliamono": "pkgs.julia-mono", + "commitmono": "pkgs.commit-mono", + "dejavu": "pkgs.dejavu_fonts", + "dejavusans": "pkgs.dejavu_fonts", + "dejavuserif": "pkgs.dejavu_fonts", + "dejavusansmono": "pkgs.dejavu_fonts", + # Nerd Font variants (pkgs.nerd-fonts. -- old pkgs.nerdfonts is removed) + "firacodenf": "pkgs.nerd-fonts.fira-code", + "jetbrainsmononf": "pkgs.nerd-fonts.jetbrains-mono", + "hacknf": "pkgs.nerd-fonts.hack", + "saucecodepronf": "pkgs.nerd-fonts.sauce-code-pro", + "caskaydiacovenf": "pkgs.nerd-fonts.caskaydia-cove", + "caskaydiamononf": "pkgs.nerd-fonts.caskaydia-mono", + "iosevkanf": "pkgs.nerd-fonts.iosevka", + "iosevkatermnf": "pkgs.nerd-fonts.iosevka-term", + "victormononf": "pkgs.nerd-fonts.victor-mono", + "blexmononf": "pkgs.nerd-fonts.blex-mono", + "inconsolatanf": "pkgs.nerd-fonts.inconsolata", + "monaspacenf": "pkgs.nerd-fonts.monaspace", + "geistmononf": "pkgs.nerd-fonts.geist-mono", + "meslolgsnf": "pkgs.nerd-fonts.meslo-lg", + "dejavusansmononf": "pkgs.nerd-fonts.dejavu-sans-mono", + "robotomononf": "pkgs.nerd-fonts.roboto-mono", + "ubuntunf": "pkgs.nerd-fonts.ubuntu", + "ubuntumononf": "pkgs.nerd-fonts.ubuntu-mono", + "mononokinf": "pkgs.nerd-fonts.mononoki", + "0xprotonf": "pkgs.nerd-fonts.0xproto", + "commitmononf": "pkgs.nerd-fonts.commit-mono", + "zedmononf": "pkgs.nerd-fonts.zed-mono", + "symbolsonly": "pkgs.nerd-fonts.symbols-only", + "symbolsnf": "pkgs.nerd-fonts.symbols-only", + # Google Fonts / UI fonts + "inter": "pkgs.inter", + "roboto": "pkgs.roboto", + "robotomono": "pkgs.roboto-mono", + "opensans": "pkgs.open-sans", + "lato": "pkgs.lato", + "notosans": "pkgs.noto-fonts", + "notoserif": "pkgs.noto-fonts", + "notocjksans": "pkgs.noto-fonts-cjk-sans", + "notocoloremoji": "pkgs.noto-fonts-color-emoji", + "ubuntu": "pkgs.ubuntu-classic", + "cantarell": "pkgs.cantarell-fonts", + "atkinsonhyperlegible": "pkgs.atkinson-hyperlegible-next", + "firasans": "pkgs.fira", + "liberation": "pkgs.liberation_ttf", + # Icon fonts + "fontawesome": "pkgs.font-awesome", + "materialdesignicons": "pkgs.material-design-icons", + # Microsoft legacy (core fonts + Vista fonts) + "arial": "pkgs.corefonts", + "times": "pkgs.corefonts", + "timesnewroman": "pkgs.corefonts", + "courier": "pkgs.corefonts", + "couriernew": "pkgs.corefonts", + "cambria": "pkgs.vistafonts", + "calibri": "pkgs.vistafonts", + "consolas": "pkgs.vistafonts", + # Apple system fonts -- bundled with macOS, no nixpkgs equivalent + "sfpro": None, + "sfmono": None, + "sfcompact": None, + "newyork": None, + "menlo": None, + "monaco": None, + "lucidagrande": None, + "helveticaneue": None, + "avenir": None, + "avenirnext": None, + "futura": None, + "gillsans": None, + "optima": None, + "palatino": None, + "baskerville": None, + "didot": None, +} + +_FONT_EXTENSION_RE = re.compile(r"\.(ttf|otf|ttc|dfont|woff2?|collection)$", re.IGNORECASE) +_NERD_FONT_PHRASE_RE = re.compile(r"nerd[\s_-]*font(?:[\s_-]*(?:mono|propo|complete))?", re.IGNORECASE) +_NERD_FONT_ABBR_RE = re.compile(r"(?:^|[\s_-])nf$", re.IGNORECASE) +_WEIGHT_SUFFIX_RE = re.compile( + r"[\s_-]*(extralight|extrabold|semibold|demibold|bolditalic|regular|italic|medium|light|black|thin|heavy|book|bold)$", + re.IGNORECASE, +) +_SEPARATOR_RE = re.compile(r"[\s_-]+") + + +def _normalize_font_name(name: str) -> str: + """Reduce a font filename/display name to a bare lookup key. + + Strips the file extension, weight suffixes (Regular/Bold/Italic/...), + and separator noise. "Nerd Font"/"NF" suffixes are folded into a + canonical trailing ``nf`` marker rather than dropped outright, since + Nerd Font variants and their base fonts otherwise normalize to the + same string (see :data:`FONT_TO_NIXPKGS`). + """ + stripped = _FONT_EXTENSION_RE.sub("", name).strip() + + is_nerd_font = False + without_phrase = _NERD_FONT_PHRASE_RE.sub("", stripped) + if without_phrase != stripped: + stripped = without_phrase + is_nerd_font = True + without_abbr = _NERD_FONT_ABBR_RE.sub("", stripped) + if without_abbr != stripped: + stripped = without_abbr + is_nerd_font = True + + changed = True + while changed: + without_weight = _WEIGHT_SUFFIX_RE.sub("", stripped) + changed = without_weight != stripped + stripped = without_weight + + normalized = _SEPARATOR_RE.sub("", stripped).lower() + return f"{normalized}nf" if is_nerd_font else normalized + + +def get_font_nixpkgs(font_name: str) -> str | None: + """Resolve a font filename/display name to its nixpkgs attribute path. + + Returns `None` for fonts with no nixpkgs equivalent (Apple system + fonts) as well as for names with no exact normalized match -- no fuzzy + matching is attempted. + """ + return FONT_TO_NIXPKGS.get(_normalize_font_name(font_name)) + + +# --------------------------------------------------------------------------- +# Launchd (launchd.{agents,daemons,user.agents}..serviceConfig) +# --------------------------------------------------------------------------- + +# Apple plist keys seen in real LaunchAgent/LaunchDaemon plists that have no +# corresponding nix-darwin serviceConfig option. serviceConfig is a closed +# submodule -- passing these through unmodified fails Nix evaluation. +LAUNCHD_KEYS_TO_DROP: frozenset[str] = frozenset( + { + "LegacyTimers", + "AssociatedBundleIdentifiers", + "EnablePressuredExit", + "BundleProgram", + "MaterializeDatalessFiles", + "LimitLoadToHardware", + "LimitLoadFromHardware", + } +) + +# Ordered (fnmatch-glob-pattern, nix-darwin-service-path) pairs. Order +# matters: specific labels/prefixes are listed before broad wildcard +# patterns so the more precise match is tried first. +LAUNCHD_LABEL_TO_SERVICE: list[tuple[str, str]] = [ + ("org.nixos.nix-daemon", "services.nix-daemon"), + ("org.nixos.activate-system", "services.activate-system"), + ("org.nixos.nix-gc", "nix.gc"), + ("org.nixos.nix-optimise", "nix.optimise"), + ("com.tailscale.tailscaled", "services.tailscale"), + ("org.pqrs.karabiner.*", "services.karabiner-elements"), + ("com.openssh.sshd", "services.openssh"), + ("*yabai*", "services.yabai"), + ("*skhd*", "services.skhd"), + ("*aerospace*", "services.aerospace"), + ("*spacebar*", "services.spacebar"), + ("*sketchybar*", "services.sketchybar"), + ("*jankyborders*", "services.jankyborders"), + ("*borders*", "services.jankyborders"), + ("*emacs*", "services.emacs"), + ("*lorri*", "services.lorri"), + ("*buildkite*", "services.buildkite-agents"), + ("*gitlab-runner*", "services.gitlab-runner"), + ("*github-runner*", "services.github-runners"), + ("*hercules-ci*", "services.hercules-ci-agent"), + ("*cachix*", "services.cachix-agent"), + ("*dnscrypt*", "services.dnscrypt-proxy"), + ("*dnsmasq*", "services.dnsmasq"), + ("*nextdns*", "services.nextdns"), + ("*tailscale*", "services.tailscale"), + ("*redis*", "services.redis"), + ("*postgresql*", "services.postgresql"), + ("*postgres*", "services.postgresql"), + ("*netbird*", "services.netbird"), + ("*wg-quick*", "networking.wg-quick"), + ("*wireguard*", "networking.wg-quick"), +] + + +def get_launchd_service(label: str) -> str | None: + """Resolve a launchd Label to a native nix-darwin service option path. + + Returns `None` if *label* matches no known service -- the caller + should fall back to a generic ``launchd.*.serviceConfig`` mapping. + """ + for pattern, service in LAUNCHD_LABEL_TO_SERVICE: + if fnmatch.fnmatch(label, pattern): + return service + return None + + +def is_launchd_key_droppable(key: str) -> bool: + """Whether *key* is an Apple plist key with no serviceConfig equivalent.""" + return key in LAUNCHD_KEYS_TO_DROP + + +# --------------------------------------------------------------------------- +# Power (power.sleep.*, power.restartAfterPowerFailure, networking.wakeOnLan) +# --------------------------------------------------------------------------- + +# pmset key -> nix-darwin option path. Only pmset settings with a direct +# nix-darwin equivalent are listed; nix-darwin applies these globally via +# `systemsetup` (no per-AC/battery control). Everything else (hibernatemode, +# standby, lidwake, etc.) has no nix-darwin option and is Tier 3 +# (activation script) -- omitted here rather than mapped to `None`. +POWER_SETTING_MAP: dict[str, str] = { + "displaysleep": "power.sleep.display", + "sleep": "power.sleep.computer", + "disksleep": "power.sleep.harddisk", + "autorestart": "power.restartAfterPowerFailure", + "womp": "networking.wakeOnLan.enable", +} + + +def get_power_nix_option(pmset_key: str) -> str | None: + """Resolve a pmset setting name to its nix-darwin option path.""" + return POWER_SETTING_MAP.get(pmset_key) + + +# --------------------------------------------------------------------------- +# Programs (programs.{zsh,bash,fish}.enable, programs.tmux.enable) +# --------------------------------------------------------------------------- + +SHELL_PROGRAM_MAP: dict[str, str] = { + "zsh": "programs.zsh.enable", + "bash": "programs.bash.enable", + "fish": "programs.fish.enable", + "tmux": "programs.tmux.enable", +} + + +def get_shell_program(shell_type: str) -> str | None: + """Resolve a shell/multiplexer type to its `programs..enable` path.""" + return SHELL_PROGRAM_MAP.get(shell_type) + + +# --------------------------------------------------------------------------- +# Networking (networking.hostName, networking.dns, ...) +# --------------------------------------------------------------------------- + +NETWORKING_MAP: dict[str, str] = { + "hostname": "networking.hostName", + "computer_name": "networking.computerName", + "local_hostname": "networking.localHostName", + "dns_servers": "networking.dns", + "search_domains": "networking.search", + "known_network_services": "networking.knownNetworkServices", +} + + +# --------------------------------------------------------------------------- +# Security (security.pam.*, security.pki.*, networking.applicationFirewall.*) +# --------------------------------------------------------------------------- + +# The Application Firewall migrated FROM `system.defaults.alf.*` (removed) +# TO `networking.applicationFirewall.*` -- firewall fields route through +# the networking module here, not system.defaults. +SECURITY_MAP: dict[str, str] = { + "touch_id_sudo": "security.pam.services.sudo_local.touchIdAuth", + "custom_certificates": "security.pki.certificates", + "firewall_enabled": "networking.applicationFirewall.enable", + "firewall_stealth_mode": "networking.applicationFirewall.enableStealthMode", + "firewall_block_all_incoming": "networking.applicationFirewall.blockAllIncoming", +} + + +# --------------------------------------------------------------------------- +# Time +# --------------------------------------------------------------------------- + +TIMEZONE_NIX_OPTION = "time.timeZone" diff --git a/tests/mappings/test_non_defaults_to_nix.py b/tests/mappings/test_non_defaults_to_nix.py new file mode 100644 index 0000000..876f65b --- /dev/null +++ b/tests/mappings/test_non_defaults_to_nix.py @@ -0,0 +1,161 @@ +"""Tests for non_defaults_to_nix mapping.""" + +from mac2nix.mappings.non_defaults_to_nix import ( + LAUNCHD_KEYS_TO_DROP, + LAUNCHD_LABEL_TO_SERVICE, + NETWORKING_MAP, + SECURITY_MAP, + SHELL_PROGRAM_MAP, + TIMEZONE_NIX_OPTION, + _normalize_font_name, + get_font_nixpkgs, + get_launchd_service, + get_power_nix_option, + get_shell_program, + is_launchd_key_droppable, +) + + +class TestNormalizeFontName: + def test_strips_weight_suffix(self) -> None: + assert _normalize_font_name("JetBrainsMono-Regular") == "jetbrainsmono" + assert _normalize_font_name("IBMPlexMono-BoldItalic") == "ibmplexmono" + + def test_strips_nerd_font_suffix_and_appends_canonical_marker(self) -> None: + assert _normalize_font_name("FiraCodeNerdFont-Regular") == "firacodenf" + assert _normalize_font_name("MesloLGS NF") == "meslolgsnf" + + def test_base_font_and_nerd_variant_do_not_collide(self) -> None: + base = _normalize_font_name("FiraCode-Regular") + nerd = _normalize_font_name("FiraCodeNerdFont-Regular") + assert base == "firacode" + assert nerd == "firacodenf" + assert base != nerd + + def test_extension_is_stripped(self) -> None: + assert _normalize_font_name("Hack-Bold.ttf") == "hack" + + def test_separators_and_case_are_normalized(self) -> None: + assert _normalize_font_name("source_code_pro") == _normalize_font_name("Source Code Pro") + + +class TestGetFontNixpkgs: + def test_known_developer_font(self) -> None: + assert get_font_nixpkgs("Fira Code") == "pkgs.fira-code" + + def test_known_nerd_font_variant_uses_new_nerd_fonts_namespace(self) -> None: + assert get_font_nixpkgs("JetBrainsMonoNerdFont-Regular") == "pkgs.nerd-fonts.jetbrains-mono" + + def test_apple_system_font_returns_none_explicitly(self) -> None: + assert get_font_nixpkgs("Menlo") is None + assert get_font_nixpkgs("SF Pro") is None + + def test_unrecognized_font_returns_none(self) -> None: + assert get_font_nixpkgs("SomeRandomFontNobodyHasHeardOf") is None + + +class TestLaunchdLabelToService: + def test_exact_label_match(self) -> None: + assert get_launchd_service("org.nixos.nix-daemon") == "services.nix-daemon" + assert get_launchd_service("com.tailscale.tailscaled") == "services.tailscale" + + def test_prefix_glob_pattern_match(self) -> None: + assert get_launchd_service("org.pqrs.karabiner.karabiner_console_user_server") == "services.karabiner-elements" + + def test_wildcard_substring_pattern_match(self) -> None: + assert get_launchd_service("com.koekeishiya.yabai") == "services.yabai" + assert get_launchd_service("com.koekeishiya.skhd") == "services.skhd" + + def test_unmatched_label_returns_none(self) -> None: + assert get_launchd_service("com.example.totally-unknown-service") is None + + def test_all_patterns_are_registered(self) -> None: + assert len(LAUNCHD_LABEL_TO_SERVICE) >= 25 + + +class TestLaunchdKeysToDrop: + def test_known_unmappable_keys_are_droppable(self) -> None: + assert is_launchd_key_droppable("LegacyTimers") is True + assert is_launchd_key_droppable("AssociatedBundleIdentifiers") is True + assert is_launchd_key_droppable("BundleProgram") is True + + def test_mappable_keys_are_not_droppable(self) -> None: + assert is_launchd_key_droppable("RunAtLoad") is False + assert is_launchd_key_droppable("ProgramArguments") is False + + def test_drop_set_contains_all_documented_keys(self) -> None: + expected = frozenset( + { + "LegacyTimers", + "AssociatedBundleIdentifiers", + "EnablePressuredExit", + "BundleProgram", + "MaterializeDatalessFiles", + "LimitLoadToHardware", + "LimitLoadFromHardware", + } + ) + assert expected == LAUNCHD_KEYS_TO_DROP + + +class TestPowerSettingMap: + def test_known_pmset_keys(self) -> None: + assert get_power_nix_option("displaysleep") == "power.sleep.display" + assert get_power_nix_option("sleep") == "power.sleep.computer" + assert get_power_nix_option("disksleep") == "power.sleep.harddisk" + assert get_power_nix_option("autorestart") == "power.restartAfterPowerFailure" + + def test_wake_on_lan_routes_to_networking(self) -> None: + assert get_power_nix_option("womp") == "networking.wakeOnLan.enable" + + def test_unmappable_pmset_key_returns_none(self) -> None: + assert get_power_nix_option("hibernatemode") is None + assert get_power_nix_option("standby") is None + + +class TestShellProgramMap: + def test_known_shells(self) -> None: + assert get_shell_program("zsh") == "programs.zsh.enable" + assert get_shell_program("bash") == "programs.bash.enable" + assert get_shell_program("fish") == "programs.fish.enable" + + def test_tmux_multiplexer(self) -> None: + assert get_shell_program("tmux") == "programs.tmux.enable" + + def test_unknown_shell_returns_none(self) -> None: + assert get_shell_program("csh") is None + + +class TestNetworkingMap: + def test_contains_expected_fields(self) -> None: + assert NETWORKING_MAP["hostname"] == "networking.hostName" + assert NETWORKING_MAP["computer_name"] == "networking.computerName" + assert NETWORKING_MAP["local_hostname"] == "networking.localHostName" + assert NETWORKING_MAP["dns_servers"] == "networking.dns" + assert NETWORKING_MAP["search_domains"] == "networking.search" + assert NETWORKING_MAP["known_network_services"] == "networking.knownNetworkServices" + + +class TestSecurityMap: + def test_touch_id_and_certificates(self) -> None: + assert SECURITY_MAP["touch_id_sudo"] == "security.pam.services.sudo_local.touchIdAuth" + assert SECURITY_MAP["custom_certificates"] == "security.pki.certificates" + + def test_firewall_fields_route_through_application_firewall(self) -> None: + assert SECURITY_MAP["firewall_enabled"] == "networking.applicationFirewall.enable" + assert SECURITY_MAP["firewall_stealth_mode"] == "networking.applicationFirewall.enableStealthMode" + assert SECURITY_MAP["firewall_block_all_incoming"] == "networking.applicationFirewall.blockAllIncoming" + for option in SECURITY_MAP.values(): + if "firewall" in option.lower(): + assert option.startswith("networking.applicationFirewall.") + assert "alf" not in option.lower() + + +class TestTimezoneOption: + def test_timezone_option_path(self) -> None: + assert TIMEZONE_NIX_OPTION == "time.timeZone" + + +class TestShellProgramMapContents: + def test_all_documented_shells_present(self) -> None: + assert set(SHELL_PROGRAM_MAP) == {"zsh", "bash", "fish", "tmux"} From 09788db4175adfee4c473a8e59bec54149b3506b Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 11:59:14 -0400 Subject: [PATCH 07/35] feat(mappings): add brew_to_nixpkgs equivalence mapping --- src/mac2nix/mappings/brew_to_nixpkgs.py | 141 ++++++++++++++++++ tests/mappings/test_brew_to_nixpkgs.py | 188 ++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 src/mac2nix/mappings/brew_to_nixpkgs.py create mode 100644 tests/mappings/test_brew_to_nixpkgs.py diff --git a/src/mac2nix/mappings/brew_to_nixpkgs.py b/src/mac2nix/mappings/brew_to_nixpkgs.py new file mode 100644 index 0000000..fb540a0 --- /dev/null +++ b/src/mac2nix/mappings/brew_to_nixpkgs.py @@ -0,0 +1,141 @@ +"""Homebrew formula name -> nixpkgs attribute name mapping. + +Homebrew formula names and nixpkgs attribute names diverge for a meaningful +slice of common formulae. :func:`get_nixpkgs_equivalent` runs a formula name +through an ordered pipeline of naming rules -- tap stripping, versioned +package patterns (Python/Node/OpenJDK/PostgreSQL/PHP), GNU tool renaming, +dot-to-hyphen normalization, a static override table, and finally an +identity fallback -- to resolve the nixpkgs attribute. + +Source: hack/research/feat-research-1777834576-nixpkgs-hm-mapping-tables.md +(Category 1) and hack/plans/2026-05-03-brew-to-nixpkgs-mapping-research.md. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +@dataclass +class NixpkgsEquivalent: + attr_name: str + note: str | None = None + + +# Formulae that are version managers made redundant by Nix's declarative, +# multi-version package model (e.g. pkgs.python313 instead of pyenv). +VERSION_MANAGER_FORMULAE: frozenset[str] = frozenset({"nvm", "pyenv", "rbenv", "tfenv"}) + +# Static overrides for brew-formula -> nixpkgs-attribute pairs that no +# programmatic rule can derive. A value of `None` marks a formula with no +# nixpkgs equivalent at all -- looked up deliberately, not a fallthrough for +# an unrecognized name. +BREW_TO_NIXPKGS: dict[str, NixpkgsEquivalent | None] = { + "node": NixpkgsEquivalent("nodejs"), + "kubernetes-cli": NixpkgsEquivalent("kubectl"), + "docker": NixpkgsEquivalent( + "docker-client", + note="Full pkgs.docker is Linux-only; docker-client is the Darwin client-only equivalent", + ), + "openjdk": NixpkgsEquivalent("jdk", note="Alias; pkgs.openjdk is also available"), + "rust": NixpkgsEquivalent( + "rustc", + note="brew rust installs rustc+cargo; consider pkgs.rustup for full toolchain management", + ), + "awscli": NixpkgsEquivalent("awscli2", note="v2 is brew's default; pkgs.awscli is v1"), + "llvm": NixpkgsEquivalent("llvmPackages.clang", note="Use pkgs.llvmPackages_XX.clang for a pinned version"), + "helm": NixpkgsEquivalent("kubernetes-helm"), + "yq": NixpkgsEquivalent("yq-go", note="brew yq is mikefarah's Go implementation; nixpkgs yq is python-yq"), + "ca-certificates": NixpkgsEquivalent("cacert"), + "jpeg-xl": NixpkgsEquivalent("libjxl"), + "composer": NixpkgsEquivalent("phpPackages.composer", note="Not top-level; lives under the PHP package set"), + "telnet": NixpkgsEquivalent( + "inetutils", + note="inetutils provides telnet plus other utilities; known aarch64-darwin build failures as of early 2026", + ), + "terraform": NixpkgsEquivalent( + "terraform", + note="terraform >= 1.6 is BSL-licensed (unfree); pkgs.opentofu is the FOSS alternative", + ), + "flux": NixpkgsEquivalent( + "fluxcd", + note="Tap-stripped from fluxcd/tap/flux; nixpkgs attribute is fluxcd, not flux", + ), + "nvm": None, + "tfenv": None, + "xcbeautify": None, + "cocoapods": None, + "fastlane": None, + "mint": None, + "xclogparser": None, + "xcodegen": None, + "xcresultparser": None, +} + +_PYTHON_VERSIONED_RE = re.compile(r"^python@3\.(\d+)$") +_NODE_VERSIONED_RE = re.compile(r"^node@(\d+)$") +_OPENJDK_VERSIONED_RE = re.compile(r"^openjdk@(\d+)$") +_POSTGRESQL_VERSIONED_RE = re.compile(r"^postgresql@(\d+)$") +_PHP_VERSIONED_RE = re.compile(r"^php@(\d)\.(\d+)$") +_GNU_TOOL_RE = re.compile(r"^gnu-(.+)$") + + +def _strip_tap(formula: str) -> str: + """Rule 1: `org/tap/pkg` -> `pkg` (last path segment).""" + return formula.rsplit("/", maxsplit=1)[-1] + + +def _versioned_pattern(name: str) -> str | None: + """Rules 2-6: versioned patterns for Python, Node, OpenJDK, PostgreSQL, PHP.""" + if match := _PYTHON_VERSIONED_RE.match(name): + return f"python3{match.group(1)}" + if match := _NODE_VERSIONED_RE.match(name): + return f"nodejs_{match.group(1)}" + if match := _OPENJDK_VERSIONED_RE.match(name): + return f"jdk{match.group(1)}" + if match := _POSTGRESQL_VERSIONED_RE.match(name): + return f"postgresql_{match.group(1)}" + if match := _PHP_VERSIONED_RE.match(name): + return f"php{match.group(1)}{match.group(2)}" + return None + + +def _strip_gnu_hyphen(name: str) -> str: + """Rule 7: `gnu-X` -> `gnuX` (e.g. gnu-sed -> gnused).""" + if match := _GNU_TOOL_RE.match(name): + return f"gnu{match.group(1)}" + return name + + +def _dot_to_hyphen(name: str) -> str: + """Rule 8: `name.suffix` -> `name-suffix` (e.g. llama.cpp -> llama-cpp).""" + return name.replace(".", "-") + + +def get_nixpkgs_equivalent(formula: str) -> NixpkgsEquivalent | None: + """Resolve a Homebrew formula name to its nixpkgs equivalent. + + Runs *formula* through the ordered pipeline described in the module + docstring. Returns ``None`` only for formulae in :data:`BREW_TO_NIXPKGS` + explicitly mapped to ``None`` (macOS/Xcode-only tools with no nixpkgs + package). Unrecognized formulae fall through to the identity rule and + resolve to a :class:`NixpkgsEquivalent` with the same name. + """ + name = _strip_tap(formula) + + if (versioned := _versioned_pattern(name)) is not None: + return NixpkgsEquivalent(attr_name=versioned) + + name = _strip_gnu_hyphen(name) + name = _dot_to_hyphen(name) + + if name in BREW_TO_NIXPKGS: + return BREW_TO_NIXPKGS[name] + + return NixpkgsEquivalent(attr_name=name) + + +def is_unnecessary_in_nix(formula: str) -> bool: + """Whether *formula* is a version manager made redundant by Nix.""" + return formula in VERSION_MANAGER_FORMULAE diff --git a/tests/mappings/test_brew_to_nixpkgs.py b/tests/mappings/test_brew_to_nixpkgs.py new file mode 100644 index 0000000..36d728b --- /dev/null +++ b/tests/mappings/test_brew_to_nixpkgs.py @@ -0,0 +1,188 @@ +"""Tests for brew_to_nixpkgs mapping.""" + +from mac2nix.mappings.brew_to_nixpkgs import ( + BREW_TO_NIXPKGS, + VERSION_MANAGER_FORMULAE, + NixpkgsEquivalent, + get_nixpkgs_equivalent, + is_unnecessary_in_nix, +) + + +class TestProgrammaticRules: + """One test per rule in the 9-rule ordered pipeline.""" + + def test_rule1_tap_stripping(self) -> None: + assert get_nixpkgs_equivalent("oven-sh/bun/bun") == NixpkgsEquivalent("bun") + + def test_rule2_python_versioned(self) -> None: + assert get_nixpkgs_equivalent("python@3.13") == NixpkgsEquivalent("python313") + + def test_rule3_node_versioned(self) -> None: + assert get_nixpkgs_equivalent("node@22") == NixpkgsEquivalent("nodejs_22") + + def test_rule4_openjdk_versioned(self) -> None: + assert get_nixpkgs_equivalent("openjdk@17") == NixpkgsEquivalent("jdk17") + + def test_rule5_postgresql_versioned(self) -> None: + assert get_nixpkgs_equivalent("postgresql@14") == NixpkgsEquivalent("postgresql_14") + + def test_rule6_php_versioned(self) -> None: + assert get_nixpkgs_equivalent("php@8.3") == NixpkgsEquivalent("php83") + + def test_rule7_gnu_tool_hyphen_removal(self) -> None: + assert get_nixpkgs_equivalent("gnu-sed") == NixpkgsEquivalent("gnused") + assert get_nixpkgs_equivalent("gnu-tar") == NixpkgsEquivalent("gnutar") + + def test_rule8_dot_to_hyphen(self) -> None: + assert get_nixpkgs_equivalent("llama.cpp") == NixpkgsEquivalent("llama-cpp") + + def test_rule9_static_override(self) -> None: + assert get_nixpkgs_equivalent("helm") == NixpkgsEquivalent("kubernetes-helm") + + +class TestRuleOrdering: + def test_tap_stripped_versioned_formula_resolves_through_multiple_rules(self) -> None: + """A tap-prefixed versioned formula must be tap-stripped (rule 1) before + the versioned-pattern rule (rule 2) can match it.""" + assert get_nixpkgs_equivalent("user/tap/python@3.12") == NixpkgsEquivalent("python312") + + def test_tap_stripped_name_still_resolves_via_static_dict(self) -> None: + """fluxcd/tap/flux strips to "flux", which only resolves correctly + via the static override table (identity alone would be wrong).""" + result = get_nixpkgs_equivalent("fluxcd/tap/flux") + assert result is not None + assert result.attr_name == "fluxcd" + + def test_tap_prefix_alone_would_be_wrong_without_stripping(self) -> None: + """Sanity check: the raw tap-prefixed string is not a valid nixpkgs + attribute -- stripping is required for a correct result.""" + result = get_nixpkgs_equivalent("hashicorp/tap/terraform") + assert result is not None + assert result.attr_name == "terraform" + assert "/" not in result.attr_name + + +class TestStaticNameMismatches: + def test_node(self) -> None: + assert get_nixpkgs_equivalent("node") == NixpkgsEquivalent("nodejs") + + def test_kubernetes_cli(self) -> None: + assert get_nixpkgs_equivalent("kubernetes-cli") == NixpkgsEquivalent("kubectl") + + def test_awscli(self) -> None: + assert get_nixpkgs_equivalent("awscli") == NixpkgsEquivalent( + "awscli2", note="v2 is brew's default; pkgs.awscli is v1" + ) + + def test_yq(self) -> None: + result = get_nixpkgs_equivalent("yq") + assert result is not None + assert result.attr_name == "yq-go" + + def test_ca_certificates(self) -> None: + assert get_nixpkgs_equivalent("ca-certificates") == NixpkgsEquivalent("cacert") + + def test_jpeg_xl(self) -> None: + assert get_nixpkgs_equivalent("jpeg-xl") == NixpkgsEquivalent("libjxl") + + def test_rust(self) -> None: + result = get_nixpkgs_equivalent("rust") + assert result is not None + assert result.attr_name == "rustc" + + def test_llvm(self) -> None: + result = get_nixpkgs_equivalent("llvm") + assert result is not None + assert result.attr_name == "llvmPackages.clang" + + def test_composer(self) -> None: + result = get_nixpkgs_equivalent("composer") + assert result is not None + assert result.attr_name == "phpPackages.composer" + + def test_telnet(self) -> None: + result = get_nixpkgs_equivalent("telnet") + assert result is not None + assert result.attr_name == "inetutils" + + def test_openjdk_unversioned(self) -> None: + result = get_nixpkgs_equivalent("openjdk") + assert result is not None + assert result.attr_name == "jdk" + + +class TestDockerDarwinSpecific: + def test_docker_maps_to_docker_client(self) -> None: + result = get_nixpkgs_equivalent("docker") + assert result is not None + assert result.attr_name == "docker-client" + assert result.note is not None + assert "Linux-only" in result.note + + +class TestIdentityFallback: + def test_unrecognized_but_plausible_formula(self) -> None: + assert get_nixpkgs_equivalent("ripgrep") == NixpkgsEquivalent("ripgrep") + + def test_completely_unknown_formula(self) -> None: + assert get_nixpkgs_equivalent("some-brand-new-formula") == NixpkgsEquivalent("some-brand-new-formula") + + +class TestNoNixpkgsEquivalent: + def test_nvm_is_none(self) -> None: + assert get_nixpkgs_equivalent("nvm") is None + + def test_tfenv_is_none(self) -> None: + assert get_nixpkgs_equivalent("tfenv") is None + + def test_xcbeautify_is_none(self) -> None: + assert get_nixpkgs_equivalent("xcbeautify") is None + + def test_cocoapods_is_none(self) -> None: + assert get_nixpkgs_equivalent("cocoapods") is None + + def test_fastlane_is_none(self) -> None: + assert get_nixpkgs_equivalent("fastlane") is None + + def test_mint_is_none(self) -> None: + assert get_nixpkgs_equivalent("mint") is None + + def test_xclogparser_is_none(self) -> None: + assert get_nixpkgs_equivalent("xclogparser") is None + + def test_xcodegen_is_none(self) -> None: + assert get_nixpkgs_equivalent("xcodegen") is None + + def test_xcresultparser_is_none(self) -> None: + assert get_nixpkgs_equivalent("xcresultparser") is None + + +class TestIsUnnecessaryInNix: + def test_version_manager_formula(self) -> None: + assert is_unnecessary_in_nix("pyenv") is True + + def test_non_version_manager_formula(self) -> None: + assert is_unnecessary_in_nix("ripgrep") is False + + def test_all_version_managers_flagged(self) -> None: + for formula in VERSION_MANAGER_FORMULAE: + assert is_unnecessary_in_nix(formula) is True + + +class TestBrewToNixpkgsTableIntegrity: + def test_none_entries_are_deliberate_not_missing_keys(self) -> None: + """Explicit None values differ from an absent key: absent keys fall + through to the identity rule, while None entries are terminal.""" + none_entries = {name for name, value in BREW_TO_NIXPKGS.items() if value is None} + assert none_entries == { + "nvm", + "tfenv", + "xcbeautify", + "cocoapods", + "fastlane", + "mint", + "xclogparser", + "xcodegen", + "xcresultparser", + } From 0b899365830d1703ec60eb590342d877da48d840 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 11:59:46 -0400 Subject: [PATCH 08/35] feat(mappings): add defaults_to_nix with all 22 system.defaults categories --- src/mac2nix/mappings/__init__.py | 1 + src/mac2nix/mappings/defaults_to_nix.py | 537 ++++++++++++++++++++++++ tests/mappings/__init__.py | 0 tests/mappings/test_defaults_to_nix.py | 261 ++++++++++++ 4 files changed, 799 insertions(+) create mode 100644 src/mac2nix/mappings/__init__.py create mode 100644 src/mac2nix/mappings/defaults_to_nix.py create mode 100644 tests/mappings/__init__.py create mode 100644 tests/mappings/test_defaults_to_nix.py diff --git a/src/mac2nix/mappings/__init__.py b/src/mac2nix/mappings/__init__.py new file mode 100644 index 0000000..0d6bf22 --- /dev/null +++ b/src/mac2nix/mappings/__init__.py @@ -0,0 +1 @@ +"""Mapping layer: macOS scan data to nix-darwin option paths.""" diff --git a/src/mac2nix/mappings/defaults_to_nix.py b/src/mac2nix/mappings/defaults_to_nix.py new file mode 100644 index 0000000..c4ff87e --- /dev/null +++ b/src/mac2nix/mappings/defaults_to_nix.py @@ -0,0 +1,537 @@ +"""Mapping of macOS `defaults` domain/key pairs to typed nix-darwin `system.defaults.*` options. + +Source data transcribed from hack/research/feat-research-1746313200-nix-darwin-defaults-mapping.md +(197 typed options across 20 nix-darwin modules, as of that research pass). +""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, slots=True) +class NixOption: + """A single nix-darwin option mapping for one macOS defaults (domain, key) pair.""" + + nix_path: str + nix_type: str + coercion: Callable[[Any], Any] | None = None + conditions: dict[str, Any] | None = None + + +# ────────────────────────────────────────────── +# Reverse coercion lookup tables + factory +# ────────────────────────────────────────────── +# Pattern 1 (floatWithDeprecationError) and pattern 5 (path-to-string) require no +# python-side transform going scanner-value -> nix-value: coercion=None (identity). +# Pattern 6 (complex struct) is marked non-mappable via `conditions`, not a coercion. +# Patterns 2-4 below need an actual reverse lookup, implemented via a shared factory. + +_CONTROLCENTER_BOOL_REVERSE: dict[int, bool] = {18: True, 24: False} + +_HITOOLBOX_FN_REVERSE: dict[int, str] = { + 0: "Do Nothing", + 1: "Change Input Source", + 2: "Show Emoji & Symbols", + 3: "Start Dictation", +} + +_ICAL_DAY_REVERSE: dict[int, str] = { + 0: "System Setting", + 1: "Sunday", + 2: "Monday", + 3: "Tuesday", + 4: "Wednesday", + 5: "Thursday", + 6: "Friday", + 7: "Saturday", +} + +_FINDER_WINDOW_TARGET_REVERSE: dict[str, str] = { + "PfCm": "Computer", + "PfVo": "OS volume", + "PfHm": "Home", + "PfDe": "Desktop", + "PfDo": "Documents", + "PfAF": "Recents", + "PfID": "iCloud Drive", + "PfLo": "Other", +} + + +def _reverse_lookup_factory(table: dict[Any, Any]) -> Callable[[Any], Any]: + """Build a coercion callable that reverses a scanned value via a lookup table. + + Falls back to the original value when the scanned value isn't in the table + (e.g. a future macOS release changes the underlying encoding). + """ + + def _reverse(value: Any) -> Any: + return table.get(value, value) + + return _reverse + + +# Pattern 2: bool-to-int (controlcenter apply: true->18, false->24) +reverse_controlcenter_bool = _reverse_lookup_factory(_CONTROLCENTER_BOOL_REVERSE) + +# Pattern 3: enum-to-int (hitoolbox AppleFnUsageType, iCal first-day-of-week) +reverse_hitoolbox_fn_usage_type = _reverse_lookup_factory(_HITOOLBOX_FN_REVERSE) +reverse_ical_first_day_of_week = _reverse_lookup_factory(_ICAL_DAY_REVERSE) + +# Pattern 4: enum-to-string-code (finder NewWindowTarget: PfCm/PfHm/etc.) +reverse_finder_new_window_target = _reverse_lookup_factory(_FINDER_WINDOW_TARGET_REVERSE) + + +# ────────────────────────────────────────────── +# Raw mapping, grouped by domain for readability/transcription fidelity. +# Flattened into DEFAULTS_TO_NIX below. +# ────────────────────────────────────────────── + +_RAW: dict[str, dict[str, NixOption]] = { + # ── dock (com.apple.dock) — 33 options ────── + "com.apple.dock": { + "appswitcher-all-displays": NixOption("system.defaults.dock.appswitcher-all-displays", "bool"), + "autohide": NixOption("system.defaults.dock.autohide", "bool"), + "autohide-delay": NixOption("system.defaults.dock.autohide-delay", "float"), + "autohide-time-modifier": NixOption("system.defaults.dock.autohide-time-modifier", "float"), + "dashboard-in-overlay": NixOption("system.defaults.dock.dashboard-in-overlay", "bool"), + "enable-spring-load-actions-on-all-items": NixOption( + "system.defaults.dock.enable-spring-load-actions-on-all-items", "bool" + ), + "expose-animation-duration": NixOption("system.defaults.dock.expose-animation-duration", "float"), + "expose-group-apps": NixOption("system.defaults.dock.expose-group-apps", "bool"), + "launchanim": NixOption("system.defaults.dock.launchanim", "bool"), + "mineffect": NixOption("system.defaults.dock.mineffect", "enum:genie|suck|scale"), + "minimize-to-application": NixOption("system.defaults.dock.minimize-to-application", "bool"), + "mouse-over-hilite-stack": NixOption("system.defaults.dock.mouse-over-hilite-stack", "bool"), + "mru-spaces": NixOption("system.defaults.dock.mru-spaces", "bool"), + "orientation": NixOption("system.defaults.dock.orientation", "enum:bottom|left|right"), + # Complex struct pattern (6): not directly mappable, still registered so + # get_unmapped_keys() doesn't treat these as unmapped. tier_override signals + # a future classifier to route straight to Tier 2 despite having a nix_path. + "persistent-apps": NixOption( + "system.defaults.dock.persistent-apps", "complex", conditions={"tier_override": 2} + ), + "persistent-others": NixOption( + "system.defaults.dock.persistent-others", "complex", conditions={"tier_override": 2} + ), + "scroll-to-open": NixOption("system.defaults.dock.scroll-to-open", "bool"), + "show-process-indicators": NixOption("system.defaults.dock.show-process-indicators", "bool"), + "show-recents": NixOption("system.defaults.dock.show-recents", "bool"), + "showAppExposeGestureEnabled": NixOption("system.defaults.dock.showAppExposeGestureEnabled", "bool"), + "showDesktopGestureEnabled": NixOption("system.defaults.dock.showDesktopGestureEnabled", "bool"), + "showLaunchpadGestureEnabled": NixOption("system.defaults.dock.showLaunchpadGestureEnabled", "bool"), + "showMissionControlGestureEnabled": NixOption("system.defaults.dock.showMissionControlGestureEnabled", "bool"), + "showhidden": NixOption("system.defaults.dock.showhidden", "bool"), + "slow-motion-allowed": NixOption("system.defaults.dock.slow-motion-allowed", "bool"), + "static-only": NixOption("system.defaults.dock.static-only", "bool"), + "tilesize": NixOption("system.defaults.dock.tilesize", "int"), + "magnification": NixOption("system.defaults.dock.magnification", "bool"), + "largesize": NixOption("system.defaults.dock.largesize", "bounded_int:16-128"), + "wvous-tl-corner": NixOption("system.defaults.dock.wvous-tl-corner", "positive_int"), + "wvous-bl-corner": NixOption("system.defaults.dock.wvous-bl-corner", "positive_int"), + "wvous-tr-corner": NixOption("system.defaults.dock.wvous-tr-corner", "positive_int"), + "wvous-br-corner": NixOption("system.defaults.dock.wvous-br-corner", "positive_int"), + }, + # ── NSGlobalDomain — 53 options ───────────── + # Also reachable via ".GlobalPreferences" (disk plist domain name) — see DOMAIN_ALIASES. + "NSGlobalDomain": { + "AppleShowAllFiles": NixOption("system.defaults.NSGlobalDomain.AppleShowAllFiles", "bool"), + "AppleEnableMouseSwipeNavigateWithScrolls": NixOption( + "system.defaults.NSGlobalDomain.AppleEnableMouseSwipeNavigateWithScrolls", "bool" + ), + "AppleEnableSwipeNavigateWithScrolls": NixOption( + "system.defaults.NSGlobalDomain.AppleEnableSwipeNavigateWithScrolls", "bool" + ), + "AppleFontSmoothing": NixOption("system.defaults.NSGlobalDomain.AppleFontSmoothing", "enum_int:0|1|2"), + "AppleInterfaceStyle": NixOption("system.defaults.NSGlobalDomain.AppleInterfaceStyle", "enum:Dark"), + "AppleIconAppearanceTheme": NixOption( + "system.defaults.NSGlobalDomain.AppleIconAppearanceTheme", + "enum:RegularDark|RegularAutomatic|ClearLight|ClearDark|ClearAutomatic|TintedLight|TintedDark|TintedAutomatic", + ), + "AppleInterfaceStyleSwitchesAutomatically": NixOption( + "system.defaults.NSGlobalDomain.AppleInterfaceStyleSwitchesAutomatically", "bool" + ), + "AppleKeyboardUIMode": NixOption("system.defaults.NSGlobalDomain.AppleKeyboardUIMode", "enum_int:0|2|3"), + "ApplePressAndHoldEnabled": NixOption("system.defaults.NSGlobalDomain.ApplePressAndHoldEnabled", "bool"), + "AppleShowAllExtensions": NixOption("system.defaults.NSGlobalDomain.AppleShowAllExtensions", "bool"), + "AppleShowScrollBars": NixOption( + "system.defaults.NSGlobalDomain.AppleShowScrollBars", "enum:WhenScrolling|Automatic|Always" + ), + "AppleScrollerPagingBehavior": NixOption("system.defaults.NSGlobalDomain.AppleScrollerPagingBehavior", "bool"), + "AppleSpacesSwitchOnActivate": NixOption("system.defaults.NSGlobalDomain.AppleSpacesSwitchOnActivate", "bool"), + "NSAutomaticCapitalizationEnabled": NixOption( + "system.defaults.NSGlobalDomain.NSAutomaticCapitalizationEnabled", "bool" + ), + "NSAutomaticInlinePredictionEnabled": NixOption( + "system.defaults.NSGlobalDomain.NSAutomaticInlinePredictionEnabled", "bool" + ), + "NSAutomaticDashSubstitutionEnabled": NixOption( + "system.defaults.NSGlobalDomain.NSAutomaticDashSubstitutionEnabled", "bool" + ), + "NSAutomaticPeriodSubstitutionEnabled": NixOption( + "system.defaults.NSGlobalDomain.NSAutomaticPeriodSubstitutionEnabled", "bool" + ), + "NSAutomaticQuoteSubstitutionEnabled": NixOption( + "system.defaults.NSGlobalDomain.NSAutomaticQuoteSubstitutionEnabled", "bool" + ), + "NSAutomaticSpellingCorrectionEnabled": NixOption( + "system.defaults.NSGlobalDomain.NSAutomaticSpellingCorrectionEnabled", "bool" + ), + "NSAutomaticWindowAnimationsEnabled": NixOption( + "system.defaults.NSGlobalDomain.NSAutomaticWindowAnimationsEnabled", "bool" + ), + "NSDisableAutomaticTermination": NixOption( + "system.defaults.NSGlobalDomain.NSDisableAutomaticTermination", "bool" + ), + "NSDocumentSaveNewDocumentsToCloud": NixOption( + "system.defaults.NSGlobalDomain.NSDocumentSaveNewDocumentsToCloud", "bool" + ), + "AppleWindowTabbingMode": NixOption( + "system.defaults.NSGlobalDomain.AppleWindowTabbingMode", "enum:manual|always|fullscreen" + ), + "NSNavPanelExpandedStateForSaveMode": NixOption( + "system.defaults.NSGlobalDomain.NSNavPanelExpandedStateForSaveMode", "bool" + ), + "NSNavPanelExpandedStateForSaveMode2": NixOption( + "system.defaults.NSGlobalDomain.NSNavPanelExpandedStateForSaveMode2", "bool" + ), + "NSTableViewDefaultSizeMode": NixOption( + "system.defaults.NSGlobalDomain.NSTableViewDefaultSizeMode", "enum_int:1|2|3" + ), + "NSTextShowsControlCharacters": NixOption( + "system.defaults.NSGlobalDomain.NSTextShowsControlCharacters", "bool" + ), + "NSUseAnimatedFocusRing": NixOption("system.defaults.NSGlobalDomain.NSUseAnimatedFocusRing", "bool"), + "NSScrollAnimationEnabled": NixOption("system.defaults.NSGlobalDomain.NSScrollAnimationEnabled", "bool"), + "NSWindowResizeTime": NixOption("system.defaults.NSGlobalDomain.NSWindowResizeTime", "float"), + "NSWindowShouldDragOnGesture": NixOption("system.defaults.NSGlobalDomain.NSWindowShouldDragOnGesture", "bool"), + "NSStatusItemSpacing": NixOption("system.defaults.NSGlobalDomain.NSStatusItemSpacing", "int"), + "NSStatusItemSelectionPadding": NixOption("system.defaults.NSGlobalDomain.NSStatusItemSelectionPadding", "int"), + "InitialKeyRepeat": NixOption("system.defaults.NSGlobalDomain.InitialKeyRepeat", "int"), + "KeyRepeat": NixOption("system.defaults.NSGlobalDomain.KeyRepeat", "int"), + "PMPrintingExpandedStateForPrint": NixOption( + "system.defaults.NSGlobalDomain.PMPrintingExpandedStateForPrint", "bool" + ), + "PMPrintingExpandedStateForPrint2": NixOption( + "system.defaults.NSGlobalDomain.PMPrintingExpandedStateForPrint2", "bool" + ), + "com.apple.keyboard.fnState": NixOption('system.defaults.NSGlobalDomain."com.apple.keyboard.fnState"', "bool"), + "com.apple.mouse.tapBehavior": NixOption( + 'system.defaults.NSGlobalDomain."com.apple.mouse.tapBehavior"', "enum_int:1" + ), + "com.apple.sound.beep.volume": NixOption( + 'system.defaults.NSGlobalDomain."com.apple.sound.beep.volume"', "float" + ), + "com.apple.sound.beep.feedback": NixOption( + 'system.defaults.NSGlobalDomain."com.apple.sound.beep.feedback"', "int" + ), + "com.apple.trackpad.enableSecondaryClick": NixOption( + 'system.defaults.NSGlobalDomain."com.apple.trackpad.enableSecondaryClick"', "bool" + ), + "com.apple.trackpad.trackpadCornerClickBehavior": NixOption( + 'system.defaults.NSGlobalDomain."com.apple.trackpad.trackpadCornerClickBehavior"', "enum_int:1" + ), + "com.apple.trackpad.scaling": NixOption('system.defaults.NSGlobalDomain."com.apple.trackpad.scaling"', "float"), + "com.apple.trackpad.forceClick": NixOption( + 'system.defaults.NSGlobalDomain."com.apple.trackpad.forceClick"', "bool" + ), + "com.apple.springing.enabled": NixOption( + 'system.defaults.NSGlobalDomain."com.apple.springing.enabled"', "bool" + ), + "com.apple.springing.delay": NixOption('system.defaults.NSGlobalDomain."com.apple.springing.delay"', "float"), + "com.apple.swipescrolldirection": NixOption( + 'system.defaults.NSGlobalDomain."com.apple.swipescrolldirection"', "bool" + ), + "AppleMeasurementUnits": NixOption( + "system.defaults.NSGlobalDomain.AppleMeasurementUnits", "enum:Centimeters|Inches" + ), + "AppleMetricUnits": NixOption("system.defaults.NSGlobalDomain.AppleMetricUnits", "enum_int:0|1"), + "AppleTemperatureUnit": NixOption( + "system.defaults.NSGlobalDomain.AppleTemperatureUnit", "enum:Celsius|Fahrenheit" + ), + "AppleICUForce24HourTime": NixOption("system.defaults.NSGlobalDomain.AppleICUForce24HourTime", "bool"), + "_HIHideMenuBar": NixOption("system.defaults.NSGlobalDomain._HIHideMenuBar", "bool"), + }, + # ── finder (com.apple.finder) — 20 options ── + "com.apple.finder": { + "AppleShowAllFiles": NixOption("system.defaults.finder.AppleShowAllFiles", "bool"), + "ShowStatusBar": NixOption("system.defaults.finder.ShowStatusBar", "bool"), + "ShowPathbar": NixOption("system.defaults.finder.ShowPathbar", "bool"), + "FXDefaultSearchScope": NixOption("system.defaults.finder.FXDefaultSearchScope", "str"), + "FXRemoveOldTrashItems": NixOption("system.defaults.finder.FXRemoveOldTrashItems", "bool"), + "FXPreferredViewStyle": NixOption("system.defaults.finder.FXPreferredViewStyle", "str"), + "AppleShowAllExtensions": NixOption("system.defaults.finder.AppleShowAllExtensions", "bool"), + "CreateDesktop": NixOption("system.defaults.finder.CreateDesktop", "bool"), + "QuitMenuItem": NixOption("system.defaults.finder.QuitMenuItem", "bool"), + "ShowExternalHardDrivesOnDesktop": NixOption("system.defaults.finder.ShowExternalHardDrivesOnDesktop", "bool"), + "ShowHardDrivesOnDesktop": NixOption("system.defaults.finder.ShowHardDrivesOnDesktop", "bool"), + "ShowMountedServersOnDesktop": NixOption("system.defaults.finder.ShowMountedServersOnDesktop", "bool"), + "ShowRemovableMediaOnDesktop": NixOption("system.defaults.finder.ShowRemovableMediaOnDesktop", "bool"), + "_FXEnableColumnAutoSizing": NixOption("system.defaults.finder._FXEnableColumnAutoSizing", "bool"), + "_FXShowPosixPathInTitle": NixOption("system.defaults.finder._FXShowPosixPathInTitle", "bool"), + "_FXSortFoldersFirst": NixOption("system.defaults.finder._FXSortFoldersFirst", "bool"), + "_FXSortFoldersFirstOnDesktop": NixOption("system.defaults.finder._FXSortFoldersFirstOnDesktop", "bool"), + "FXEnableExtensionChangeWarning": NixOption("system.defaults.finder.FXEnableExtensionChangeWarning", "bool"), + # Pattern 4: enum-to-string-code — scanner reads the 4-char code, reverse to label. + "NewWindowTarget": NixOption( + "system.defaults.finder.NewWindowTarget", + "enum:Computer|OS volume|Home|Desktop|Documents|Recents|iCloud Drive|Other", + coercion=reverse_finder_new_window_target, + ), + # Only meaningful when NewWindowTarget == "Other" — documented interdependency. + "NewWindowTargetPath": NixOption( + "system.defaults.finder.NewWindowTargetPath", + "str", + conditions={"requires": {"NewWindowTarget": "Other"}}, + ), + }, + # ── trackpad (primary domain) — 22 options ── + # com.apple.driver.AppleBluetoothMultitouch.trackpad writes the same keys to the + # same nix options — handled via DOMAIN_ALIASES rather than duplicating entries. + "com.apple.AppleMultitouchTrackpad": { + "Clicking": NixOption("system.defaults.trackpad.Clicking", "bool"), + "Dragging": NixOption("system.defaults.trackpad.Dragging", "bool"), + "TrackpadRightClick": NixOption("system.defaults.trackpad.TrackpadRightClick", "bool"), + "TrackpadThreeFingerDrag": NixOption("system.defaults.trackpad.TrackpadThreeFingerDrag", "bool"), + "ActuationStrength": NixOption("system.defaults.trackpad.ActuationStrength", "enum_int:0|1"), + "FirstClickThreshold": NixOption("system.defaults.trackpad.FirstClickThreshold", "enum_int:0|1|2"), + "SecondClickThreshold": NixOption("system.defaults.trackpad.SecondClickThreshold", "enum_int:0|1|2"), + "TrackpadThreeFingerTapGesture": NixOption( + "system.defaults.trackpad.TrackpadThreeFingerTapGesture", "enum_int:0|2" + ), + "ActuateDetents": NixOption("system.defaults.trackpad.ActuateDetents", "bool"), + "DragLock": NixOption("system.defaults.trackpad.DragLock", "bool"), + "ForceSuppressed": NixOption("system.defaults.trackpad.ForceSuppressed", "bool"), + "TrackpadCornerSecondaryClick": NixOption( + "system.defaults.trackpad.TrackpadCornerSecondaryClick", "enum_int:0|1|2" + ), + "TrackpadFourFingerHorizSwipeGesture": NixOption( + "system.defaults.trackpad.TrackpadFourFingerHorizSwipeGesture", "enum_int:0|2" + ), + "TrackpadFourFingerPinchGesture": NixOption( + "system.defaults.trackpad.TrackpadFourFingerPinchGesture", "enum_int:0|2" + ), + "TrackpadFourFingerVertSwipeGesture": NixOption( + "system.defaults.trackpad.TrackpadFourFingerVertSwipeGesture", "enum_int:0|2" + ), + "TrackpadMomentumScroll": NixOption("system.defaults.trackpad.TrackpadMomentumScroll", "bool"), + "TrackpadPinch": NixOption("system.defaults.trackpad.TrackpadPinch", "bool"), + "TrackpadRotate": NixOption("system.defaults.trackpad.TrackpadRotate", "bool"), + "TrackpadThreeFingerHorizSwipeGesture": NixOption( + "system.defaults.trackpad.TrackpadThreeFingerHorizSwipeGesture", "enum_int:0|1|2" + ), + "TrackpadThreeFingerVertSwipeGesture": NixOption( + "system.defaults.trackpad.TrackpadThreeFingerVertSwipeGesture", "enum_int:0|2" + ), + "TrackpadTwoFingerDoubleTapGesture": NixOption( + "system.defaults.trackpad.TrackpadTwoFingerDoubleTapGesture", "bool" + ), + "TrackpadTwoFingerFromRightEdgeSwipeGesture": NixOption( + "system.defaults.trackpad.TrackpadTwoFingerFromRightEdgeSwipeGesture", "enum_int:0|3" + ), + }, + # ── loginwindow (com.apple.loginwindow) — 11 options ── + "com.apple.loginwindow": { + "SHOWFULLNAME": NixOption("system.defaults.loginwindow.SHOWFULLNAME", "bool"), + "autoLoginUser": NixOption("system.defaults.loginwindow.autoLoginUser", "str"), + "GuestEnabled": NixOption("system.defaults.loginwindow.GuestEnabled", "bool"), + "LoginwindowText": NixOption("system.defaults.loginwindow.LoginwindowText", "str"), + "ShutDownDisabled": NixOption("system.defaults.loginwindow.ShutDownDisabled", "bool"), + "SleepDisabled": NixOption("system.defaults.loginwindow.SleepDisabled", "bool"), + "RestartDisabled": NixOption("system.defaults.loginwindow.RestartDisabled", "bool"), + "ShutDownDisabledWhileLoggedIn": NixOption("system.defaults.loginwindow.ShutDownDisabledWhileLoggedIn", "bool"), + "PowerOffDisabledWhileLoggedIn": NixOption("system.defaults.loginwindow.PowerOffDisabledWhileLoggedIn", "bool"), + "RestartDisabledWhileLoggedIn": NixOption("system.defaults.loginwindow.RestartDisabledWhileLoggedIn", "bool"), + "DisableConsoleAccess": NixOption("system.defaults.loginwindow.DisableConsoleAccess", "bool"), + }, + # ── controlcenter (com.apple.controlcenter) — 7 options ── + # ByHost domain: scanner may report "com.apple.controlcenter." — see + # _strip_byhost_suffix(), used internally by get_nix_option(). + "com.apple.controlcenter": { + "BatteryShowPercentage": NixOption("system.defaults.controlcenter.BatteryShowPercentage", "bool"), + # Pattern 2: bool-to-int — scanner reads 18/24, reverse to bool. + "Sound": NixOption("system.defaults.controlcenter.Sound", "bool", coercion=reverse_controlcenter_bool), + "Bluetooth": NixOption("system.defaults.controlcenter.Bluetooth", "bool", coercion=reverse_controlcenter_bool), + "AirDrop": NixOption("system.defaults.controlcenter.AirDrop", "bool", coercion=reverse_controlcenter_bool), + "Display": NixOption("system.defaults.controlcenter.Display", "bool", coercion=reverse_controlcenter_bool), + "FocusModes": NixOption( + "system.defaults.controlcenter.FocusModes", "bool", coercion=reverse_controlcenter_bool + ), + "NowPlaying": NixOption( + "system.defaults.controlcenter.NowPlaying", "bool", coercion=reverse_controlcenter_bool + ), + }, + # ── WindowManager (com.apple.WindowManager) — 12 options ── + "com.apple.WindowManager": { + "GloballyEnabled": NixOption("system.defaults.WindowManager.GloballyEnabled", "bool"), + "EnableStandardClickToShowDesktop": NixOption( + "system.defaults.WindowManager.EnableStandardClickToShowDesktop", "bool" + ), + "AutoHide": NixOption("system.defaults.WindowManager.AutoHide", "bool"), + "AppWindowGroupingBehavior": NixOption("system.defaults.WindowManager.AppWindowGroupingBehavior", "bool"), + "StandardHideDesktopIcons": NixOption("system.defaults.WindowManager.StandardHideDesktopIcons", "bool"), + "HideDesktop": NixOption("system.defaults.WindowManager.HideDesktop", "bool"), + "EnableTilingByEdgeDrag": NixOption("system.defaults.WindowManager.EnableTilingByEdgeDrag", "bool"), + "EnableTopTilingByEdgeDrag": NixOption("system.defaults.WindowManager.EnableTopTilingByEdgeDrag", "bool"), + "EnableTilingOptionAccelerator": NixOption( + "system.defaults.WindowManager.EnableTilingOptionAccelerator", "bool" + ), + "EnableTiledWindowMargins": NixOption("system.defaults.WindowManager.EnableTiledWindowMargins", "bool"), + "StandardHideWidgets": NixOption("system.defaults.WindowManager.StandardHideWidgets", "bool"), + "StageManagerHideWidgets": NixOption("system.defaults.WindowManager.StageManagerHideWidgets", "bool"), + }, + # ── ActivityMonitor (com.apple.ActivityMonitor) — 5 options ── + "com.apple.ActivityMonitor": { + "ShowCategory": NixOption( + "system.defaults.ActivityMonitor.ShowCategory", "enum_int:100|101|102|103|104|105|106|107" + ), + "IconType": NixOption("system.defaults.ActivityMonitor.IconType", "int"), + "SortColumn": NixOption("system.defaults.ActivityMonitor.SortColumn", "str"), + "SortDirection": NixOption("system.defaults.ActivityMonitor.SortDirection", "int"), + "OpenMainWindow": NixOption("system.defaults.ActivityMonitor.OpenMainWindow", "bool"), + }, + # ── universalaccess (com.apple.universalaccess) — 5 options ── + "com.apple.universalaccess": { + "mouseDriverCursorSize": NixOption("system.defaults.universalaccess.mouseDriverCursorSize", "float"), + "reduceMotion": NixOption("system.defaults.universalaccess.reduceMotion", "bool"), + "reduceTransparency": NixOption("system.defaults.universalaccess.reduceTransparency", "bool"), + "closeViewScrollWheelToggle": NixOption("system.defaults.universalaccess.closeViewScrollWheelToggle", "bool"), + "closeViewZoomFollowsFocus": NixOption("system.defaults.universalaccess.closeViewZoomFollowsFocus", "bool"), + }, + # ── iCal (com.apple.iCal) — 3 options ── + "com.apple.iCal": { + # Pattern 3: enum-to-int — scanner reads int 0-7, reverse to day-of-week string. + "first day of week": NixOption( + 'system.defaults.iCal."first day of week"', + "enum:System Setting|Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday", + coercion=reverse_ical_first_day_of_week, + ), + "CalendarSidebarShown": NixOption("system.defaults.iCal.CalendarSidebarShown", "bool"), + "TimeZone support enabled": NixOption('system.defaults.iCal."TimeZone support enabled"', "bool"), + }, + # ── screencapture (com.apple.screencapture) — 7 options ── + "com.apple.screencapture": { + "location": NixOption("system.defaults.screencapture.location", "str"), + "type": NixOption("system.defaults.screencapture.type", "str"), + "disable-shadow": NixOption("system.defaults.screencapture.disable-shadow", "bool"), + "include-date": NixOption("system.defaults.screencapture.include-date", "bool"), + "save-selections": NixOption("system.defaults.screencapture.save-selections", "bool"), + "show-thumbnail": NixOption("system.defaults.screencapture.show-thumbnail", "bool"), + "target": NixOption("system.defaults.screencapture.target", "enum:file|clipboard|preview|mail|messages"), + }, + # ── menuExtraClock (com.apple.menuextra.clock) — 8 options ── + "com.apple.menuextra.clock": { + "FlashDateSeparators": NixOption("system.defaults.menuExtraClock.FlashDateSeparators", "bool"), + "IsAnalog": NixOption("system.defaults.menuExtraClock.IsAnalog", "bool"), + "Show24Hour": NixOption("system.defaults.menuExtraClock.Show24Hour", "bool"), + "ShowAMPM": NixOption("system.defaults.menuExtraClock.ShowAMPM", "bool"), + "ShowDayOfMonth": NixOption("system.defaults.menuExtraClock.ShowDayOfMonth", "bool"), + "ShowDayOfWeek": NixOption("system.defaults.menuExtraClock.ShowDayOfWeek", "bool"), + "ShowDate": NixOption("system.defaults.menuExtraClock.ShowDate", "enum_int:0|1|2"), + "ShowSeconds": NixOption("system.defaults.menuExtraClock.ShowSeconds", "bool"), + }, + # ── .GlobalPreferences — 2 options ── + # Distinct from NSGlobalDomain (dot-prefixed domain); aliased both ways in DOMAIN_ALIASES. + ".GlobalPreferences": { + # Pattern 5: path-to-string — identity, no python-side transform needed. + "com.apple.sound.beep.sound": NixOption( + 'system.defaults.".GlobalPreferences"."com.apple.sound.beep.sound"', "path" + ), + "com.apple.mouse.scaling": NixOption('system.defaults.".GlobalPreferences"."com.apple.mouse.scaling"', "float"), + }, + # ── HIToolbox (com.apple.HIToolbox) — 1 option ── + "com.apple.HIToolbox": { + # Pattern 3: enum-to-int — scanner reads int 0-3, reverse to label string. + "AppleFnUsageType": NixOption( + "system.defaults.hitoolbox.AppleFnUsageType", + "enum:Do Nothing|Change Input Source|Show Emoji & Symbols|Start Dictation", + coercion=reverse_hitoolbox_fn_usage_type, + ), + }, + # ── screensaver (com.apple.screensaver) — 2 options ── + "com.apple.screensaver": { + "askForPassword": NixOption("system.defaults.screensaver.askForPassword", "bool"), + "askForPasswordDelay": NixOption("system.defaults.screensaver.askForPasswordDelay", "int"), + }, + # ── spaces (com.apple.spaces) — 1 option ── + "com.apple.spaces": { + "spans-displays": NixOption("system.defaults.spaces.spans-displays", "bool"), + }, + # ── smb (com.apple.smb.server) — 2 options ── + "com.apple.smb.server": { + "NetBIOSName": NixOption("system.defaults.smb.NetBIOSName", "str"), + "ServerDescription": NixOption("system.defaults.smb.ServerDescription", "str"), + }, + # ── magicmouse (primary domain) — 1 option ── + # com.apple.driver.AppleMultitouchMouse.mouse writes the same key — see DOMAIN_ALIASES. + "com.apple.AppleMultitouchMouse": { + "MouseButtonMode": NixOption("system.defaults.magicmouse.MouseButtonMode", "enum:OneButton|TwoButton"), + }, + # ── SoftwareUpdate (com.apple.SoftwareUpdate) — 1 option ── + "com.apple.SoftwareUpdate": { + "AutomaticallyInstallMacOSUpdates": NixOption( + "system.defaults.SoftwareUpdate.AutomaticallyInstallMacOSUpdates", "bool" + ), + }, + # ── LaunchServices (com.apple.LaunchServices) — 1 option ── + "com.apple.LaunchServices": { + "LSQuarantine": NixOption("system.defaults.LaunchServices.LSQuarantine", "bool"), + }, +} + + +DEFAULTS_TO_NIX: dict[tuple[str, str], NixOption] = { + (domain, key): option for domain, entries in _RAW.items() for key, option in entries.items() +} + + +# ────────────────────────────────────────────── +# Domain aliases: a scanned domain name that should also be looked up under a +# different canonical domain name (bidirectional where the alias's key set is +# a strict subset/superset of the canonical domain, e.g. NSGlobalDomain). +# ────────────────────────────────────────────── +DOMAIN_ALIASES: dict[str, str] = { + # Scanner reads .GlobalPreferences.plist from disk; the cfprefsd fallback reports + # NSGlobalDomain. Both bidirectional so either lookup falls back to the other. + "NSGlobalDomain": ".GlobalPreferences", + ".GlobalPreferences": "NSGlobalDomain", + # trackpad / magicmouse write the same keys to two domains simultaneously. + "com.apple.driver.AppleBluetoothMultitouch.trackpad": "com.apple.AppleMultitouchTrackpad", + "com.apple.driver.AppleMultitouchMouse.mouse": "com.apple.AppleMultitouchMouse", +} + + +# ByHost preference files are named "..plist"; the +# scanner derives domain_name from the plist stem, so the suffix survives as part of +# the domain string. Strip a trailing hex/dash segment of 8+ characters to recover +# the canonical domain (e.g. "com.apple.controlcenter.F8DD5F35-...-D6D3"). +_BYHOST_SUFFIX_RE = re.compile(r"\.[0-9A-Fa-f-]{8,}$") + + +def _strip_byhost_suffix(domain: str) -> str: + return _BYHOST_SUFFIX_RE.sub("", domain) + + +def get_nix_option(domain: str, key: str) -> NixOption | None: + """Look up the nix-darwin option for a scanned (domain, key) pair, resolving ByHost suffixes and aliases.""" + resolved = _strip_byhost_suffix(domain) + + option = DEFAULTS_TO_NIX.get((resolved, key)) + if option is not None: + return option + + alias = DOMAIN_ALIASES.get(resolved) + if alias is not None: + return DEFAULTS_TO_NIX.get((alias, key)) + + return None + + +def get_unmapped_keys(domain: str, keys: Iterable[str]) -> list[str]: + """Return the subset of `keys` that have no nix-darwin mapping for `domain`.""" + return [key for key in keys if get_nix_option(domain, key) is None] diff --git a/tests/mappings/__init__.py b/tests/mappings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mappings/test_defaults_to_nix.py b/tests/mappings/test_defaults_to_nix.py new file mode 100644 index 0000000..0402173 --- /dev/null +++ b/tests/mappings/test_defaults_to_nix.py @@ -0,0 +1,261 @@ +"""Tests for defaults_to_nix mapping layer.""" + +from mac2nix.mappings.defaults_to_nix import ( + DEFAULTS_TO_NIX, + DOMAIN_ALIASES, + get_nix_option, + get_unmapped_keys, +) + + +class TestSpotCheckPerCategory: + """One representative option per major category, to catch transcription/keying errors.""" + + def test_dock(self) -> None: + option = get_nix_option("com.apple.dock", "autohide") + assert option is not None + assert option.nix_path == "system.defaults.dock.autohide" + assert option.nix_type == "bool" + + def test_nsglobaldomain(self) -> None: + option = get_nix_option("NSGlobalDomain", "AppleShowAllFiles") + assert option is not None + assert option.nix_path == "system.defaults.NSGlobalDomain.AppleShowAllFiles" + + def test_finder(self) -> None: + option = get_nix_option("com.apple.finder", "ShowPathbar") + assert option is not None + assert option.nix_path == "system.defaults.finder.ShowPathbar" + + def test_trackpad(self) -> None: + option = get_nix_option("com.apple.AppleMultitouchTrackpad", "Clicking") + assert option is not None + assert option.nix_path == "system.defaults.trackpad.Clicking" + + def test_loginwindow(self) -> None: + option = get_nix_option("com.apple.loginwindow", "GuestEnabled") + assert option is not None + assert option.nix_path == "system.defaults.loginwindow.GuestEnabled" + + def test_controlcenter(self) -> None: + option = get_nix_option("com.apple.controlcenter", "BatteryShowPercentage") + assert option is not None + assert option.nix_path == "system.defaults.controlcenter.BatteryShowPercentage" + + def test_windowmanager(self) -> None: + option = get_nix_option("com.apple.WindowManager", "GloballyEnabled") + assert option is not None + assert option.nix_path == "system.defaults.WindowManager.GloballyEnabled" + + def test_activitymonitor(self) -> None: + option = get_nix_option("com.apple.ActivityMonitor", "OpenMainWindow") + assert option is not None + assert option.nix_path == "system.defaults.ActivityMonitor.OpenMainWindow" + + def test_universalaccess(self) -> None: + option = get_nix_option("com.apple.universalaccess", "reduceMotion") + assert option is not None + assert option.nix_path == "system.defaults.universalaccess.reduceMotion" + + def test_ical(self) -> None: + option = get_nix_option("com.apple.iCal", "CalendarSidebarShown") + assert option is not None + assert option.nix_path == "system.defaults.iCal.CalendarSidebarShown" + + def test_screencapture(self) -> None: + option = get_nix_option("com.apple.screencapture", "location") + assert option is not None + assert option.nix_path == "system.defaults.screencapture.location" + + def test_menu_extra_clock(self) -> None: + option = get_nix_option("com.apple.menuextra.clock", "Show24Hour") + assert option is not None + assert option.nix_path == "system.defaults.menuExtraClock.Show24Hour" + + def test_global_preferences(self) -> None: + option = get_nix_option(".GlobalPreferences", "com.apple.mouse.scaling") + assert option is not None + assert option.nix_path == 'system.defaults.".GlobalPreferences"."com.apple.mouse.scaling"' + + def test_hitoolbox(self) -> None: + option = get_nix_option("com.apple.HIToolbox", "AppleFnUsageType") + assert option is not None + assert option.nix_path == "system.defaults.hitoolbox.AppleFnUsageType" + + def test_screensaver(self) -> None: + option = get_nix_option("com.apple.screensaver", "askForPassword") + assert option is not None + assert option.nix_path == "system.defaults.screensaver.askForPassword" + + def test_spaces(self) -> None: + option = get_nix_option("com.apple.spaces", "spans-displays") + assert option is not None + assert option.nix_path == "system.defaults.spaces.spans-displays" + + def test_smb(self) -> None: + option = get_nix_option("com.apple.smb.server", "NetBIOSName") + assert option is not None + assert option.nix_path == "system.defaults.smb.NetBIOSName" + + def test_magicmouse(self) -> None: + option = get_nix_option("com.apple.AppleMultitouchMouse", "MouseButtonMode") + assert option is not None + assert option.nix_path == "system.defaults.magicmouse.MouseButtonMode" + + def test_software_update(self) -> None: + option = get_nix_option("com.apple.SoftwareUpdate", "AutomaticallyInstallMacOSUpdates") + assert option is not None + assert option.nix_path == "system.defaults.SoftwareUpdate.AutomaticallyInstallMacOSUpdates" + + def test_launch_services(self) -> None: + option = get_nix_option("com.apple.LaunchServices", "LSQuarantine") + assert option is not None + assert option.nix_path == "system.defaults.LaunchServices.LSQuarantine" + + +class TestCoercionPatterns: + """One test per each of the 6 documented coercion patterns.""" + + def test_pattern1_float_with_deprecation_error_is_identity(self) -> None: + option = get_nix_option("com.apple.dock", "autohide-delay") + assert option is not None + assert option.nix_type == "float" + assert option.coercion is None + + def test_pattern2_bool_to_int_reversal(self) -> None: + option = get_nix_option("com.apple.controlcenter", "Sound") + assert option is not None + assert option.coercion is not None + assert option.coercion(18) is True + assert option.coercion(24) is False + + def test_pattern3_enum_to_int_reversal_hitoolbox(self) -> None: + option = get_nix_option("com.apple.HIToolbox", "AppleFnUsageType") + assert option is not None + assert option.coercion is not None + assert option.coercion(0) == "Do Nothing" + assert option.coercion(3) == "Start Dictation" + + def test_pattern3_enum_to_int_reversal_ical(self) -> None: + option = get_nix_option("com.apple.iCal", "first day of week") + assert option is not None + assert option.coercion is not None + assert option.coercion(0) == "System Setting" + assert option.coercion(1) == "Sunday" + + def test_pattern4_enum_to_string_code_reversal(self) -> None: + option = get_nix_option("com.apple.finder", "NewWindowTarget") + assert option is not None + assert option.coercion is not None + assert option.coercion("PfCm") == "Computer" + assert option.coercion("PfHm") == "Home" + + def test_pattern5_path_to_string_is_identity(self) -> None: + option = get_nix_option(".GlobalPreferences", "com.apple.sound.beep.sound") + assert option is not None + assert option.nix_type == "path" + assert option.coercion is None + + def test_pattern6_complex_struct_marked_not_directly_mappable(self) -> None: + option = get_nix_option("com.apple.dock", "persistent-apps") + assert option is not None + assert option.nix_type == "complex" + assert option.coercion is None + assert option.conditions == {"tier_override": 2} + + def test_unknown_reverse_value_falls_back_to_original(self) -> None: + option = get_nix_option("com.apple.controlcenter", "Sound") + assert option is not None + assert option.coercion is not None + assert option.coercion(999) == 999 + + +class TestConditions: + def test_finder_new_window_target_path_requires_other(self) -> None: + option = get_nix_option("com.apple.finder", "NewWindowTargetPath") + assert option is not None + assert option.conditions == {"requires": {"NewWindowTarget": "Other"}} + + +class TestDomainAliasResolution: + def test_nsglobaldomain_to_globalpreferences_direct(self) -> None: + option = get_nix_option("NSGlobalDomain", "AppleShowAllFiles") + assert option is not None + assert option.nix_path == "system.defaults.NSGlobalDomain.AppleShowAllFiles" + + def test_globalpreferences_resolves_nsglobaldomain_key_via_alias(self) -> None: + # Not a direct entry under ".GlobalPreferences" — must fall back via DOMAIN_ALIASES. + option = get_nix_option(".GlobalPreferences", "AppleShowAllFiles") + assert option is not None + assert option.nix_path == "system.defaults.NSGlobalDomain.AppleShowAllFiles" + + def test_nsglobaldomain_resolves_globalpreferences_key_via_alias(self) -> None: + # Not a direct entry under "NSGlobalDomain" — must fall back via DOMAIN_ALIASES. + option = get_nix_option("NSGlobalDomain", "com.apple.mouse.scaling") + assert option is not None + assert option.nix_path == 'system.defaults.".GlobalPreferences"."com.apple.mouse.scaling"' + + def test_trackpad_bluetooth_domain_aliases_to_primary(self) -> None: + primary = get_nix_option("com.apple.AppleMultitouchTrackpad", "Clicking") + aliased = get_nix_option("com.apple.driver.AppleBluetoothMultitouch.trackpad", "Clicking") + assert primary is not None + assert aliased == primary + + def test_magicmouse_secondary_domain_aliases_to_primary(self) -> None: + primary = get_nix_option("com.apple.AppleMultitouchMouse", "MouseButtonMode") + aliased = get_nix_option("com.apple.driver.AppleMultitouchMouse.mouse", "MouseButtonMode") + assert primary is not None + assert aliased == primary + + def test_domain_aliases_dict_contains_expected_entries(self) -> None: + assert DOMAIN_ALIASES["NSGlobalDomain"] == ".GlobalPreferences" + assert DOMAIN_ALIASES[".GlobalPreferences"] == "NSGlobalDomain" + + +class TestByHostSuffixStripping: + def test_controlcenter_byhost_uuid_suffix_resolves_same_as_bare_domain(self) -> None: + bare = get_nix_option("com.apple.controlcenter", "Sound") + byhost = get_nix_option("com.apple.controlcenter.F8DD5F35-6DC1-56D2-9364-C2A0C2C4D6D3", "Sound") + assert bare is not None + assert byhost == bare + + def test_controlcenter_byhost_short_hex_suffix_resolves_same_as_bare_domain(self) -> None: + bare = get_nix_option("com.apple.controlcenter", "BatteryShowPercentage") + byhost = get_nix_option("com.apple.controlcenter.AABBCCDD", "BatteryShowPercentage") + assert bare is not None + assert byhost == bare + + +class TestGetUnmappedKeys: + def test_mix_of_mapped_and_unmapped(self) -> None: + unmapped = get_unmapped_keys("com.apple.dock", ["autohide", "tilesize", "TotallyMadeUpKey", "AnotherFakeKey"]) + assert unmapped == ["TotallyMadeUpKey", "AnotherFakeKey"] + + def test_all_mapped_returns_empty(self) -> None: + unmapped = get_unmapped_keys("com.apple.dock", ["autohide", "tilesize"]) + assert unmapped == [] + + def test_all_unmapped_returns_all(self) -> None: + unmapped = get_unmapped_keys("com.apple.nonexistent.domain", ["a", "b"]) + assert unmapped == ["a", "b"] + + def test_resolves_aliases_before_reporting_unmapped(self) -> None: + # AppleShowAllFiles only lives under "NSGlobalDomain" in the raw dict — must not be + # reported as unmapped when queried via the ".GlobalPreferences" alias. + unmapped = get_unmapped_keys(".GlobalPreferences", ["AppleShowAllFiles", "NotARealKey"]) + assert unmapped == ["NotARealKey"] + + +class TestDictIntegrity: + def test_entry_count_matches_research_doc(self) -> None: + assert len(DEFAULTS_TO_NIX) == 197 + + def test_persistent_apps_and_others_present_not_reported_unmapped(self) -> None: + unmapped = get_unmapped_keys("com.apple.dock", ["persistent-apps", "persistent-others"]) + assert unmapped == [] + + def test_unknown_key_returns_none(self) -> None: + assert get_nix_option("com.apple.dock", "not-a-real-key") is None + + def test_unknown_domain_returns_none(self) -> None: + assert get_nix_option("com.apple.not.a.real.domain", "autohide") is None From 0374520bd4cf9e1951f0b45e0a04c651678ab1f1 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 12:12:32 -0400 Subject: [PATCH 09/35] feat(mappings): add four-tier setting classifier --- src/mac2nix/mappings/__init__.py | 111 +++++++- src/mac2nix/mappings/classifier.py | 428 +++++++++++++++++++++++++++++ tests/mappings/test_classifier.py | 365 ++++++++++++++++++++++++ 3 files changed, 903 insertions(+), 1 deletion(-) create mode 100644 src/mac2nix/mappings/classifier.py create mode 100644 tests/mappings/test_classifier.py diff --git a/src/mac2nix/mappings/__init__.py b/src/mac2nix/mappings/__init__.py index 0d6bf22..77687c0 100644 --- a/src/mac2nix/mappings/__init__.py +++ b/src/mac2nix/mappings/__init__.py @@ -1 +1,110 @@ -"""Mapping layer: macOS scan data to nix-darwin option paths.""" +"""Mapping layer: macOS scan data to nix-darwin option paths. + +Re-exports the public API of all nine mapping modules. `app_to_package`'s +`classify_app(name: str) -> AppClassification | None` (a raw name lookup) is +deliberately NOT re-exported at this level -- it would shadow this package's +own `classify_app(app: InstalledApp) -> ClassificationResult` (the +classifier's per-InstalledApp routing function). Reach it via +`mac2nix.mappings.app_to_package.classify_app` if needed. +""" + +from __future__ import annotations + +from mac2nix.mappings.app_config_registry import APP_CONFIG_REGISTRY, AppConfigInfo, get_app_config +from mac2nix.mappings.app_to_hm import APP_TO_HM_MODULE, HMModuleInfo, get_hm_module +from mac2nix.mappings.app_to_package import AppClassification, normalize_app_name +from mac2nix.mappings.brew_to_nixpkgs import ( + BREW_TO_NIXPKGS, + VERSION_MANAGER_FORMULAE, + NixpkgsEquivalent, + get_nixpkgs_equivalent, + is_unnecessary_in_nix, +) +from mac2nix.mappings.classifier import ( + ClassificationResult, + ClassificationTier, + classify_app, + classify_brew_formula, + classify_dotfile, + classify_font, + classify_launch_agent, + classify_network_setting, + classify_preference, + classify_security_setting, + classify_shell_setting, + classify_system_setting, +) +from mac2nix.mappings.defaults_to_nix import ( + DEFAULTS_TO_NIX, + DOMAIN_ALIASES, + NixOption, + get_nix_option, + get_unmapped_keys, +) +from mac2nix.mappings.dotfile_to_hm import DOTFILE_TO_HM, get_hm_program +from mac2nix.mappings.ephemeral_filter import filter_ephemeral, is_ephemeral +from mac2nix.mappings.non_defaults_to_nix import ( + FONT_TO_NIXPKGS, + LAUNCHD_KEYS_TO_DROP, + LAUNCHD_LABEL_TO_SERVICE, + NETWORKING_MAP, + POWER_SETTING_MAP, + SECURITY_MAP, + SHELL_PROGRAM_MAP, + TIMEZONE_NIX_OPTION, + get_font_nixpkgs, + get_launchd_service, + get_power_nix_option, + get_shell_program, + is_launchd_key_droppable, +) + +__all__ = [ + "APP_CONFIG_REGISTRY", + "APP_TO_HM_MODULE", + "BREW_TO_NIXPKGS", + "DEFAULTS_TO_NIX", + "DOMAIN_ALIASES", + "DOTFILE_TO_HM", + "FONT_TO_NIXPKGS", + "LAUNCHD_KEYS_TO_DROP", + "LAUNCHD_LABEL_TO_SERVICE", + "NETWORKING_MAP", + "POWER_SETTING_MAP", + "SECURITY_MAP", + "SHELL_PROGRAM_MAP", + "TIMEZONE_NIX_OPTION", + "VERSION_MANAGER_FORMULAE", + "AppClassification", + "AppConfigInfo", + "ClassificationResult", + "ClassificationTier", + "HMModuleInfo", + "NixOption", + "NixpkgsEquivalent", + "classify_app", + "classify_brew_formula", + "classify_dotfile", + "classify_font", + "classify_launch_agent", + "classify_network_setting", + "classify_preference", + "classify_security_setting", + "classify_shell_setting", + "classify_system_setting", + "filter_ephemeral", + "get_app_config", + "get_font_nixpkgs", + "get_hm_module", + "get_hm_program", + "get_launchd_service", + "get_nix_option", + "get_nixpkgs_equivalent", + "get_power_nix_option", + "get_shell_program", + "get_unmapped_keys", + "is_ephemeral", + "is_launchd_key_droppable", + "is_unnecessary_in_nix", + "normalize_app_name", +] diff --git a/src/mac2nix/mappings/classifier.py b/src/mac2nix/mappings/classifier.py new file mode 100644 index 0000000..81ef18c --- /dev/null +++ b/src/mac2nix/mappings/classifier.py @@ -0,0 +1,428 @@ +"""Four-tier classifier: routes scanned macOS settings to their nix-darwin destination. + +Tier 1 (NATIVE) -> a typed nix-darwin option exists (system.defaults.* or a +non-defaults module option). Tier 2 (CUSTOM_PREFS) -> known domain/structure, +but no typed option; passed through generically (CustomUserPreferences, +CustomSystemPreferences, or a generic launchd.*.serviceConfig block). Tier 3 +(ACTIVATION_SCRIPT) -> requires an imperative `defaults write`/similar at +activation time (binary data, root-level launchd daemons). Tier 4 +(MANUAL_REPORT) -> no automated mapping; surfaced to the user, or (via +metadata.skipped) silently dropped as ephemeral noise. + +This module is the integration point for all other mapping modules -- see +hack/PROJECT.md's "Mapping Layer Architecture" section for the tier +rationale and hack/swarm/roadmap-phase-1-mapping-layer-1784561690/ for the +architect plan and security design review this implementation follows. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass +from enum import IntEnum +from pathlib import Path +from typing import Any + +from mac2nix.mappings import app_to_package +from mac2nix.mappings.app_to_hm import get_hm_module +from mac2nix.mappings.brew_to_nixpkgs import get_nixpkgs_equivalent, is_unnecessary_in_nix +from mac2nix.mappings.defaults_to_nix import get_nix_option +from mac2nix.mappings.dotfile_to_hm import get_hm_program +from mac2nix.mappings.ephemeral_filter import is_ephemeral +from mac2nix.mappings.non_defaults_to_nix import ( + NETWORKING_MAP, + SECURITY_MAP, + get_font_nixpkgs, + get_launchd_service, + get_power_nix_option, + get_shell_program, + is_launchd_key_droppable, +) +from mac2nix.models.application import BrewFormula, InstalledApp +from mac2nix.models.files import DotfileEntry, FontEntry +from mac2nix.models.preferences import PreferencesDomain, PreferenceValue +from mac2nix.models.services import LaunchAgentEntry, LaunchAgentSource, ShellConfig +from mac2nix.scanners._utils import SENSITIVE_KEY_PATTERNS + + +class ClassificationTier(IntEnum): + NATIVE = 1 + CUSTOM_PREFS = 2 + ACTIVATION_SCRIPT = 3 + MANUAL_REPORT = 4 + + +@dataclass(frozen=True, slots=True) +class ClassificationResult: + """The routing decision for a single scanned setting. + + `destination` is a human-readable description of where the setting + lands (a nix option path, "CustomUserPreferences", or a manual-report + reason). `metadata` for Tier 3 entries must contain only structured + data (domain/key/value_type/value) -- never a pre-built shell command + string; escaping is a Phase 6 generator concern (SEC-2). + """ + + tier: ClassificationTier + destination: str + nix_path: str | None = None + coercion: Callable[[Any], Any] | None = None + metadata: dict[str, Any] | None = None + + +# sanitize_plist_values() (scanners/_utils.py) converts plist blobs into +# this sentinel string before scanner output ever reaches this layer -- real +# `bytes` objects never appear here. Matching the sentinel is the only way to +# detect binary-origin values (SEC-3): isinstance(value, bytes) would be dead code. +_BINARY_SENTINEL_RE = re.compile(r"^$") + +# The Application Firewall's alf domain has no entry in DEFAULTS_TO_NIX (the +# module was removed from nix-darwin in June 2025); these are the only two +# alf plist keys with a confirmed, unambiguous SECURITY_MAP equivalent. +# Everything else in com.apple.alf (loggingenabled, exceptions, applications, +# ...) falls through to the generic Tier 2 CustomSystemPreferences path. +_ALF_DOMAIN = "com.apple.alf" +_ALF_KEY_ALIASES: dict[str, str] = { + "globalstate": "firewall_enabled", + "stealthenabled": "firewall_stealth_mode", +} + + +def _is_sensitive_key(key: str) -> bool: + upper = key.upper() + return any(pattern in upper for pattern in SENSITIVE_KEY_PATTERNS) + + +def _is_binary_sentinel(value: Any) -> bool: + return isinstance(value, str) and bool(_BINARY_SENTINEL_RE.match(value)) + + +def _preferences_tier2_destination(domain: PreferencesDomain) -> str: + """Distinguish CustomUserPreferences vs CustomSystemPreferences by source_path. + + cfprefsd-only domains (source_path is None) default to CustomUserPreferences -- + most such domains reflect per-user cached settings with no system-wide plist. + """ + if domain.source_path is not None and not str(domain.source_path).startswith(str(Path.home())): + return "CustomSystemPreferences" + return "CustomUserPreferences" + + +def _classify_preference_precheck( + domain: PreferencesDomain, key: str, value: PreferenceValue +) -> ClassificationResult | None: + """SEC-1/SEC-3 gating checks plus ephemeral-noise filtering, run before any tier routing.""" + if _is_sensitive_key(key): + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"manual report: key '{key}' in domain '{domain.domain_name}' matches a sensitive pattern", + metadata={ + "potentially_sensitive": True, + "reason": "key name matches sensitive pattern", + "domain": domain.domain_name, + "key": key, + "value": "***REDACTED***", + }, + ) + + if _is_binary_sentinel(value): + return ClassificationResult( + tier=ClassificationTier.ACTIVATION_SCRIPT, + destination=f"activationScripts: defaults write for {domain.domain_name} {key} (binary data)", + metadata={ + "command_type": "defaults_write", + "domain": domain.domain_name, + "key": key, + "value_type": "data", + "value": value, + }, + ) + + if is_ephemeral(key, value): + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination="skipped: ephemeral UI/runtime state, not reproducible config", + metadata={ + "skipped": True, + "reason": "ephemeral value (UI state, timestamp, cache, or transient identifier)", + }, + ) + + return None + + +def classify_preference(domain: PreferencesDomain, key: str, value: PreferenceValue) -> ClassificationResult: + """Classify a single scanned (domain, key, value) preference triple.""" + precheck = _classify_preference_precheck(domain, key, value) + if precheck is not None: + return precheck + + domain_name = domain.domain_name + if domain_name == _ALF_DOMAIN: + alias = _ALF_KEY_ALIASES.get(key) + alf_nix_path = SECURITY_MAP.get(alias) if alias is not None else None + if alf_nix_path is not None: + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=alf_nix_path, + nix_path=alf_nix_path, + metadata={"domain": domain_name, "key": key, "alf_alias": alias}, + ) + + option = get_nix_option(domain_name, key) + if option is not None: + conditions = option.conditions or {} + tier_override = conditions.get("tier_override") + if tier_override is None: + metadata: dict[str, Any] = {"nix_type": option.nix_type} + if conditions: + metadata["conditions"] = conditions + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=option.nix_path, + nix_path=option.nix_path, + coercion=option.coercion, + metadata=metadata, + ) + return ClassificationResult( + tier=ClassificationTier(tier_override), + destination=_preferences_tier2_destination(domain), + metadata={ + "native_nix_path_available": option.nix_path, + "reason": "complex struct type not directly mappable to a typed nix-darwin option", + }, + ) + + return ClassificationResult( + tier=ClassificationTier.CUSTOM_PREFS, + destination=_preferences_tier2_destination(domain), + metadata={"domain": domain_name, "key": key}, + ) + + +def classify_launch_agent(entry: LaunchAgentEntry) -> ClassificationResult: + """Classify a scanned LaunchAgent/LaunchDaemon/login item entry. + + Agents (user or system-scoped) route to Tier 2 generic serviceConfig + passthrough, matching CustomUserPreferences' role for preferences: a + known nix-darwin sink, but a whole-structure passthrough rather than a + per-key typed mapping. Daemons run as root and route to Tier 3 -- + treated with the same activation-time caution as other privileged + changes. Login items have no launchd-based nix-darwin equivalent at all. + """ + service = get_launchd_service(entry.label) + if service is not None: + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=service, + nix_path=service, + metadata={"label": entry.label}, + ) + + if entry.source == LaunchAgentSource.LOGIN_ITEM: + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"manual report: login item '{entry.label}' has no nix-darwin equivalent", + metadata={"label": entry.label, "source": entry.source.value}, + ) + + cleaned_plist = {k: v for k, v in entry.raw_plist.items() if not is_launchd_key_droppable(k)} + if entry.source == LaunchAgentSource.DAEMON: + tier = ClassificationTier.ACTIVATION_SCRIPT + destination = f'launchd.daemons."{entry.label}".serviceConfig' + else: + tier = ClassificationTier.CUSTOM_PREFS + namespace = "user.agents" if entry.source == LaunchAgentSource.USER else "agents" + destination = f'launchd.{namespace}."{entry.label}".serviceConfig' + + return ClassificationResult( + tier=tier, + destination=destination, + nix_path=destination, + metadata={"label": entry.label, "source": entry.source.value, "raw_plist": cleaned_plist}, + ) + + +def classify_app(app: InstalledApp) -> ClassificationResult: + """Classify a scanned installed application by its installation channel.""" + classification = app_to_package.classify_app(app.name) + if classification is None: + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"manual report: unrecognized app '{app.name}', no package classification available", + metadata={"app_name": app.name, "bundle_id": app.bundle_id}, + ) + + if classification.source == "nixpkgs": + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination="environment.systemPackages", + nix_path=f"pkgs.{classification.package_name}", + metadata={"source": "nixpkgs", "package_name": classification.package_name}, + ) + if classification.source == "cask": + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination="homebrew.casks", + nix_path="homebrew.casks", + metadata={ + "source": "cask", + "cask_name": classification.package_name, + "nix_alternative": classification.nix_alternative, + }, + ) + if classification.source == "appstore": + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination="homebrew.masApps", + nix_path="homebrew.masApps", + metadata={ + "source": "appstore", + "display_name": classification.package_name, + "note": "app-id must be resolved from HomebrewState.mas_apps", + }, + ) + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination="pre-bundled with macOS, no installation action needed", + metadata={"source": "system", "app_name": app.name}, + ) + + +def classify_dotfile(entry: DotfileEntry) -> ClassificationResult: + """Classify a scanned dotfile by its home-manager program module, if any.""" + hm_program = get_hm_program(entry.path) + if hm_program is not None: + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=hm_program, + nix_path=hm_program, + metadata={"managed_by": entry.managed_by.value, "sensitive": entry.sensitive}, + ) + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"manual report: no home-manager program module found for dotfile '{entry.path}'", + metadata={"managed_by": entry.managed_by.value, "sensitive": entry.sensitive}, + ) + + +def classify_brew_formula(formula: BrewFormula) -> ClassificationResult: + """Classify a scanned Homebrew formula by its nixpkgs equivalent.""" + if is_unnecessary_in_nix(formula.name): + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"skip: '{formula.name}' is a version manager redundant under Nix's declarative package model", + metadata={"unnecessary_in_nix": True, "formula": formula.name}, + ) + + equivalent = get_nixpkgs_equivalent(formula.name) + if equivalent is None: + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"manual report: no nixpkgs equivalent for brew formula '{formula.name}'", + metadata={"formula": formula.name}, + ) + + metadata: dict[str, Any] = {"formula": formula.name, "nixpkgs_attr": equivalent.attr_name} + if equivalent.note: + metadata["note"] = equivalent.note + hm_info = get_hm_module(formula.name) + if hm_info is not None: + metadata["hm_module_available"] = hm_info.module_path + + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination="environment.systemPackages", + nix_path=f"pkgs.{equivalent.attr_name}", + metadata=metadata, + ) + + +def classify_font(entry: FontEntry) -> ClassificationResult: + """Classify a scanned font by its nixpkgs package, if any.""" + nix_attr = get_font_nixpkgs(entry.name) + if nix_attr is None: + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"manual report: no nixpkgs package for font '{entry.name}'", + metadata={"font_name": entry.name}, + ) + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination="fonts.packages", + nix_path=nix_attr, + metadata={"font_name": entry.name}, + ) + + +def classify_system_setting(field_name: str, value: Any) -> ClassificationResult: + """Classify a SystemConfig field (currently: pmset power settings).""" + nix_path = get_power_nix_option(field_name) + if nix_path is None: + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"manual report: no nix-darwin option for system setting '{field_name}'", + metadata={"field_name": field_name, "value": value}, + ) + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=nix_path, + nix_path=nix_path, + metadata={"field_name": field_name, "value": value}, + ) + + +def classify_security_setting(field_name: str, value: Any) -> ClassificationResult: + """Classify a SecurityState field against SECURITY_MAP.""" + nix_path = SECURITY_MAP.get(field_name) + if nix_path is None: + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"manual report: no nix-darwin option for security setting '{field_name}'", + metadata={"field_name": field_name, "value": value}, + ) + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=nix_path, + nix_path=nix_path, + metadata={"field_name": field_name, "value": value}, + ) + + +def classify_network_setting(field_name: str, value: Any) -> ClassificationResult: + """Classify a SystemConfig/NetworkConfig field against NETWORKING_MAP.""" + nix_path = NETWORKING_MAP.get(field_name) + if nix_path is None: + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"manual report: no nix-darwin option for network setting '{field_name}'", + metadata={"field_name": field_name, "value": value}, + ) + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=nix_path, + nix_path=nix_path, + metadata={"field_name": field_name, "value": value}, + ) + + +def classify_shell_setting(config: ShellConfig) -> list[ClassificationResult]: + """Classify a scanned shell configuration's shell type.""" + nix_path = get_shell_program(config.shell_type) + if nix_path is None: + return [ + ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"manual report: no nix-darwin program mapping for shell type '{config.shell_type}'", + metadata={"shell_type": config.shell_type}, + ) + ] + return [ + ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=nix_path, + nix_path=nix_path, + metadata={"shell_type": config.shell_type}, + ) + ] diff --git a/tests/mappings/test_classifier.py b/tests/mappings/test_classifier.py new file mode 100644 index 0000000..734c3fd --- /dev/null +++ b/tests/mappings/test_classifier.py @@ -0,0 +1,365 @@ +"""Tests for the four-tier setting classifier.""" + +from pathlib import Path + +from mac2nix.mappings.classifier import ( + ClassificationTier, + classify_app, + classify_brew_formula, + classify_dotfile, + classify_font, + classify_launch_agent, + classify_network_setting, + classify_preference, + classify_security_setting, + classify_shell_setting, + classify_system_setting, +) +from mac2nix.models.application import AppSource, BrewFormula, InstalledApp +from mac2nix.models.files import DotfileEntry, DotfileManager, FontEntry, FontSource +from mac2nix.models.preferences import PreferencesDomain +from mac2nix.models.services import LaunchAgentEntry, LaunchAgentSource, ShellConfig + + +def _domain(name: str, keys: dict, *, source_path: Path | None = None, source: str = "disk") -> PreferencesDomain: + return PreferencesDomain(domain_name=name, source_path=source_path, source=source, keys=keys) + + +class TestClassifyPreferenceNativeTier: + def test_known_defaults_key_routes_to_tier_1(self) -> None: + domain = _domain( + "com.apple.dock", {"autohide": True}, source_path=Path.home() / "Library/Preferences/com.apple.dock.plist" + ) + result = classify_preference(domain, "autohide", True) + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "system.defaults.dock.autohide" + assert result.destination == "system.defaults.dock.autohide" + + def test_coercion_is_carried_through_from_nix_option(self) -> None: + domain = _domain("com.apple.controlcenter", {"Sound": 18}) + result = classify_preference(domain, "Sound", 18) + assert result.tier == ClassificationTier.NATIVE + assert result.coercion is not None + assert result.coercion(18) is True + assert result.coercion(24) is False + + +class TestClassifyPreferenceTierOverride: + def test_dock_persistent_apps_routes_to_tier_override_not_native(self) -> None: + domain = _domain( + "com.apple.dock", + {"persistent-apps": []}, + source_path=Path.home() / "Library/Preferences/com.apple.dock.plist", + ) + result = classify_preference(domain, "persistent-apps", [{"tile-data": {}}]) + assert result.tier == ClassificationTier.CUSTOM_PREFS + assert result.destination == "CustomUserPreferences" + assert result.nix_path is None + assert result.metadata is not None + assert result.metadata["native_nix_path_available"] == "system.defaults.dock.persistent-apps" + + +class TestClassifyPreferenceUnmappedKey: + def test_unmapped_key_in_user_domain_routes_to_custom_user_preferences(self) -> None: + domain = _domain( + "com.example.someapp", + {"SomeSetting": "value"}, + source_path=Path.home() / "Library/Preferences/com.example.someapp.plist", + ) + result = classify_preference(domain, "SomeSetting", "value") + assert result.tier == ClassificationTier.CUSTOM_PREFS + assert result.destination == "CustomUserPreferences" + + def test_unmapped_key_in_system_domain_routes_to_custom_system_preferences(self) -> None: + domain = _domain( + "com.apple.loginwindow", + {"SomeUnknownKey": 1}, + source_path=Path("/Library/Preferences/com.apple.loginwindow.plist"), + ) + result = classify_preference(domain, "SomeUnknownKey", 1) + assert result.tier == ClassificationTier.CUSTOM_PREFS + assert result.destination == "CustomSystemPreferences" + + def test_cfprefsd_domain_with_no_source_path_defaults_to_user_preferences(self) -> None: + domain = _domain("com.example.cfprefsdonly", {"Key": 1}, source_path=None, source="cfprefsd") + result = classify_preference(domain, "Key", 1) + assert result.tier == ClassificationTier.CUSTOM_PREFS + assert result.destination == "CustomUserPreferences" + + +class TestClassifyPreferenceSensitiveKeyRedaction: + def test_key_matching_sensitive_pattern_routes_to_manual_report_redacted(self) -> None: + domain = _domain("com.example.someapp", {"API_TOKEN": "sk-live-abc123"}) + result = classify_preference(domain, "API_TOKEN", "sk-live-abc123") + assert result.tier == ClassificationTier.MANUAL_REPORT + assert result.metadata is not None + assert result.metadata["potentially_sensitive"] is True + assert result.metadata["value"] == "***REDACTED***" + + def test_sensitive_match_takes_priority_over_native_mapping(self) -> None: + """A sensitive-looking key must never leak into Tier 1/2/3 even if otherwise mappable.""" + domain = _domain("com.apple.dock", {"autohide_TOKEN": True}) + result = classify_preference(domain, "autohide_TOKEN", True) + assert result.tier == ClassificationTier.MANUAL_REPORT + assert result.metadata is not None + assert result.metadata["value"] == "***REDACTED***" + + def test_non_sensitive_key_is_not_redacted(self) -> None: + domain = _domain("com.example.someapp", {"autohide": True}) + result = classify_preference(domain, "autohide", True) + assert ( + result.tier != ClassificationTier.MANUAL_REPORT + or (result.metadata or {}).get("potentially_sensitive") is not True + ) + + +class TestClassifyPreferenceBinarySentinel: + def test_binary_sentinel_routes_to_activation_script_not_dropped(self) -> None: + domain = _domain("com.example.someapp", {"IconData": ""}) + result = classify_preference(domain, "IconData", "") + assert result.tier == ClassificationTier.ACTIVATION_SCRIPT + assert result.metadata is not None + assert result.metadata["command_type"] == "defaults_write" + assert result.metadata["value"] == "" + + def test_binary_sentinel_metadata_has_no_shell_command_string(self) -> None: + """SEC-2: metadata must be structured, never a pre-built shell command.""" + domain = _domain("com.example.someapp", {"IconData": ""}) + result = classify_preference(domain, "IconData", "") + assert result.metadata is not None + assert set(result.metadata) == {"command_type", "domain", "key", "value_type", "value"} + + def test_malformed_sentinel_like_string_is_not_treated_as_binary(self) -> None: + domain = _domain("com.example.someapp", {"Description": "128 bytes"}) + result = classify_preference(domain, "Description", "128 bytes") + assert result.tier != ClassificationTier.ACTIVATION_SCRIPT + + +class TestClassifyPreferenceEphemeralSkip: + def test_ephemeral_value_is_skipped_not_reported(self) -> None: + domain = _domain("com.example.someapp", {"NSWindow Frame Main": "100 100 800 600 0 0 1440 900"}) + result = classify_preference(domain, "NSWindow Frame Main", "100 100 800 600 0 0 1440 900") + assert result.tier == ClassificationTier.MANUAL_REPORT + assert result.metadata is not None + assert result.metadata["skipped"] is True + + def test_ephemeral_check_runs_before_defaults_lookup(self) -> None: + """A key that would otherwise look ephemeral must not sneak past into Tier 1.""" + domain = _domain("com.example.someapp", {"SULastCheckTime": "2026-07-20"}) + result = classify_preference(domain, "SULastCheckTime", "2026-07-20") + assert result.tier == ClassificationTier.MANUAL_REPORT + assert (result.metadata or {}).get("skipped") is True + + +class TestClassifyPreferenceAlfSpecialCase: + def test_globalstate_routes_to_application_firewall_enable(self) -> None: + domain = _domain( + "com.apple.alf", {"globalstate": 1}, source_path=Path("/Library/Preferences/com.apple.alf.plist") + ) + result = classify_preference(domain, "globalstate", 1) + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "networking.applicationFirewall.enable" + + def test_stealthenabled_routes_to_stealth_mode_option(self) -> None: + domain = _domain( + "com.apple.alf", {"stealthenabled": 1}, source_path=Path("/Library/Preferences/com.apple.alf.plist") + ) + result = classify_preference(domain, "stealthenabled", 1) + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "networking.applicationFirewall.enableStealthMode" + + def test_unaliased_alf_key_falls_back_to_custom_system_preferences(self) -> None: + domain = _domain( + "com.apple.alf", {"loggingenabled": 1}, source_path=Path("/Library/Preferences/com.apple.alf.plist") + ) + result = classify_preference(domain, "loggingenabled", 1) + assert result.tier == ClassificationTier.CUSTOM_PREFS + assert result.destination == "CustomSystemPreferences" + + def test_alf_destination_never_mentions_removed_system_defaults_alf_module(self) -> None: + domain = _domain("com.apple.alf", {"globalstate": 1}) + result = classify_preference(domain, "globalstate", 1) + assert "system.defaults.alf" not in result.destination + + +class TestClassifyLaunchAgent: + def _entry(self, label: str, source: LaunchAgentSource, raw_plist: dict | None = None) -> LaunchAgentEntry: + return LaunchAgentEntry(label=label, source=source, raw_plist=raw_plist or {}) + + def test_known_service_label_routes_to_native_tier(self) -> None: + entry = self._entry("com.tailscale.tailscaled", LaunchAgentSource.DAEMON) + result = classify_launch_agent(entry) + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "services.tailscale" + + def test_unmatched_user_agent_routes_to_custom_prefs_generic_passthrough(self) -> None: + entry = self._entry("com.example.myagent", LaunchAgentSource.USER) + result = classify_launch_agent(entry) + assert result.tier == ClassificationTier.CUSTOM_PREFS + assert "launchd.user.agents" in result.destination + + def test_unmatched_daemon_routes_to_activation_script_tier(self) -> None: + entry = self._entry("com.example.mydaemon", LaunchAgentSource.DAEMON) + result = classify_launch_agent(entry) + assert result.tier == ClassificationTier.ACTIVATION_SCRIPT + assert "launchd.daemons" in result.destination + + def test_login_item_has_no_nix_darwin_equivalent(self) -> None: + entry = self._entry("com.example.loginhelper", LaunchAgentSource.LOGIN_ITEM) + result = classify_launch_agent(entry) + assert result.tier == ClassificationTier.MANUAL_REPORT + + def test_droppable_keys_are_stripped_from_raw_plist_metadata(self) -> None: + entry = self._entry( + "com.example.myagent", + LaunchAgentSource.USER, + raw_plist={"Label": "com.example.myagent", "LegacyTimers": True, "RunAtLoad": True}, + ) + result = classify_launch_agent(entry) + assert result.metadata is not None + cleaned = result.metadata["raw_plist"] + assert "LegacyTimers" not in cleaned + assert cleaned["RunAtLoad"] is True + + +class TestClassifyApp: + def test_nixpkgs_app_routes_to_native_system_packages(self) -> None: + app = InstalledApp(name="Firefox", path=Path("/Applications/Firefox.app"), source=AppSource.MANUAL) + result = classify_app(app) + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "pkgs.firefox" + + def test_cask_app_routes_to_homebrew_casks(self) -> None: + app = InstalledApp(name="Slack", path=Path("/Applications/Slack.app"), source=AppSource.MANUAL) + result = classify_app(app) + assert result.tier == ClassificationTier.NATIVE + assert result.destination == "homebrew.casks" + + def test_appstore_app_routes_to_mas_apps(self) -> None: + app = InstalledApp(name="Xcode", path=Path("/Applications/Xcode.app"), source=AppSource.APPSTORE) + result = classify_app(app) + assert result.tier == ClassificationTier.NATIVE + assert result.destination == "homebrew.masApps" + + def test_system_app_needs_no_action(self) -> None: + app = InstalledApp(name="Safari", path=Path("/Applications/Safari.app"), source=AppSource.MANUAL) + result = classify_app(app) + assert result.tier == ClassificationTier.MANUAL_REPORT + + def test_unrecognized_app_routes_to_manual_report(self) -> None: + app = InstalledApp( + name="SomeObscureInternalTool", + path=Path("/Applications/SomeObscureInternalTool.app"), + source=AppSource.MANUAL, + ) + result = classify_app(app) + assert result.tier == ClassificationTier.MANUAL_REPORT + + +class TestClassifyDotfile: + def test_known_dotfile_routes_to_home_manager_program(self) -> None: + entry = DotfileEntry(path=Path.home() / ".zshrc", managed_by=DotfileManager.MANUAL) + result = classify_dotfile(entry) + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "programs.zsh" + + def test_unknown_dotfile_routes_to_manual_report(self) -> None: + entry = DotfileEntry(path=Path.home() / ".some-totally-unknown-rc", managed_by=DotfileManager.UNKNOWN) + result = classify_dotfile(entry) + assert result.tier == ClassificationTier.MANUAL_REPORT + + +class TestClassifyBrewFormula: + def test_version_manager_formula_is_flagged_redundant(self) -> None: + formula = BrewFormula(name="pyenv") + result = classify_brew_formula(formula) + assert result.tier == ClassificationTier.MANUAL_REPORT + assert result.metadata is not None + assert result.metadata["unnecessary_in_nix"] is True + + def test_no_nixpkgs_equivalent_formula_routes_to_manual_report(self) -> None: + formula = BrewFormula(name="cocoapods") + result = classify_brew_formula(formula) + assert result.tier == ClassificationTier.MANUAL_REPORT + assert result.metadata is not None + assert result.metadata.get("unnecessary_in_nix") is None + + def test_mapped_formula_routes_to_native_with_nixpkgs_attr(self) -> None: + formula = BrewFormula(name="node") + result = classify_brew_formula(formula) + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "pkgs.nodejs" + + def test_formula_with_hm_module_gets_metadata_hint(self) -> None: + formula = BrewFormula(name="git") + result = classify_brew_formula(formula) + assert result.tier == ClassificationTier.NATIVE + assert result.metadata is not None + assert result.metadata["hm_module_available"] == "programs.git" + + +class TestClassifyFont: + def test_known_font_routes_to_native_fonts_packages(self) -> None: + entry = FontEntry( + name="Fira Code", path=Path.home() / "Library/Fonts/FiraCode-Regular.ttf", source=FontSource.USER + ) + result = classify_font(entry) + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "pkgs.fira-code" + + def test_apple_system_font_routes_to_manual_report(self) -> None: + entry = FontEntry(name="Menlo", path=Path("/Library/Fonts/Menlo.ttc"), source=FontSource.SYSTEM) + result = classify_font(entry) + assert result.tier == ClassificationTier.MANUAL_REPORT + + +class TestClassifySystemSetting: + def test_known_pmset_key_routes_to_native(self) -> None: + result = classify_system_setting("displaysleep", "10") + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "power.sleep.display" + + def test_unmapped_field_routes_to_manual_report(self) -> None: + result = classify_system_setting("icloud", {"signed_in": True}) + assert result.tier == ClassificationTier.MANUAL_REPORT + + +class TestClassifySecuritySetting: + def test_known_security_field_routes_to_native(self) -> None: + result = classify_security_setting("firewall_enabled", True) + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "networking.applicationFirewall.enable" + + def test_unmapped_security_field_routes_to_manual_report(self) -> None: + result = classify_security_setting("filevault_enabled", True) + assert result.tier == ClassificationTier.MANUAL_REPORT + + def test_sip_and_gatekeeper_are_always_manual(self) -> None: + assert classify_security_setting("sip_enabled", True).tier == ClassificationTier.MANUAL_REPORT + assert classify_security_setting("gatekeeper_enabled", True).tier == ClassificationTier.MANUAL_REPORT + + +class TestClassifyNetworkSetting: + def test_known_network_field_routes_to_native(self) -> None: + result = classify_network_setting("computer_name", "MyMac") + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "networking.computerName" + + def test_unmapped_network_field_routes_to_manual_report(self) -> None: + result = classify_network_setting("proxy_settings", {}) + assert result.tier == ClassificationTier.MANUAL_REPORT + + +class TestClassifyShellSetting: + def test_known_shell_type_routes_to_native(self) -> None: + config = ShellConfig(shell_type="fish") + results = classify_shell_setting(config) + assert len(results) == 1 + assert results[0].tier == ClassificationTier.NATIVE + assert results[0].nix_path == "programs.fish.enable" + + def test_unknown_shell_type_routes_to_manual_report(self) -> None: + config = ShellConfig(shell_type="csh") + results = classify_shell_setting(config) + assert len(results) == 1 + assert results[0].tier == ClassificationTier.MANUAL_REPORT From 4970542257e9ecb754d1bb08f154e65389453c4b Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 12:30:57 -0400 Subject: [PATCH 10/35] fix(mappings): correct ghostty nixpkgs attribute to ghostty-bin Ghostty cannot build from source on Darwin via nixpkgs (no Swift 6/ xcodebuild support in the sandboxed builder). pkgs.ghostty-bin is the correct Darwin-buildable package. --- src/mac2nix/mappings/app_to_package.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mac2nix/mappings/app_to_package.py b/src/mac2nix/mappings/app_to_package.py index 346c3e6..f1ac71e 100644 --- a/src/mac2nix/mappings/app_to_package.py +++ b/src/mac2nix/mappings/app_to_package.py @@ -51,7 +51,7 @@ def classify_app(name: str) -> AppClassification | None: "vlc": AppClassification("nixpkgs", "vlc"), "emacs": AppClassification("nixpkgs", "emacs"), "iina": AppClassification("nixpkgs", "iina"), - "ghostty": AppClassification("nixpkgs", "ghostty"), + "ghostty": AppClassification("nixpkgs", "ghostty-bin"), "obs": AppClassification("nixpkgs", "obs-studio"), "handbrake": AppClassification("nixpkgs", "handbrake"), "neovide": AppClassification("nixpkgs", "neovide"), From 64448dc8aee814b572ea873ecaa556a0f7465f6c Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 12:31:13 -0400 Subject: [PATCH 11/35] feat(mappings): add environment mappings, wire timezone and app config registry into classifier Adds ENVIRONMENT_MAP (aliases/env_vars/path_components -> environment.* nix-darwin options) and expands classify_shell_setting() to emit ClassificationResults for ShellConfig.aliases/env_vars/path_components, routing frameworks/dynamic_commands to Tier 4 manual report. Wires the previously-unreachable TIMEZONE_NIX_OPTION into classify_system_setting() for the "timezone" field, and adds classify_app_config() to route app_config_registry lookups through the four-tier classifier. --- src/mac2nix/mappings/__init__.py | 2 + src/mac2nix/mappings/classifier.py | 130 ++++++++++++++++++-- src/mac2nix/mappings/non_defaults_to_nix.py | 11 ++ tests/mappings/test_classifier.py | 64 +++++++++- tests/mappings/test_non_defaults_to_nix.py | 8 ++ 5 files changed, 203 insertions(+), 12 deletions(-) diff --git a/src/mac2nix/mappings/__init__.py b/src/mac2nix/mappings/__init__.py index 77687c0..a160d14 100644 --- a/src/mac2nix/mappings/__init__.py +++ b/src/mac2nix/mappings/__init__.py @@ -24,6 +24,7 @@ ClassificationResult, ClassificationTier, classify_app, + classify_app_config, classify_brew_formula, classify_dotfile, classify_font, @@ -83,6 +84,7 @@ "NixOption", "NixpkgsEquivalent", "classify_app", + "classify_app_config", "classify_brew_formula", "classify_dotfile", "classify_font", diff --git a/src/mac2nix/mappings/classifier.py b/src/mac2nix/mappings/classifier.py index 81ef18c..a6b54da 100644 --- a/src/mac2nix/mappings/classifier.py +++ b/src/mac2nix/mappings/classifier.py @@ -25,14 +25,17 @@ from typing import Any from mac2nix.mappings import app_to_package +from mac2nix.mappings.app_config_registry import get_app_config from mac2nix.mappings.app_to_hm import get_hm_module from mac2nix.mappings.brew_to_nixpkgs import get_nixpkgs_equivalent, is_unnecessary_in_nix from mac2nix.mappings.defaults_to_nix import get_nix_option from mac2nix.mappings.dotfile_to_hm import get_hm_program from mac2nix.mappings.ephemeral_filter import is_ephemeral from mac2nix.mappings.non_defaults_to_nix import ( + ENVIRONMENT_MAP, NETWORKING_MAP, SECURITY_MAP, + TIMEZONE_NIX_OPTION, get_font_nixpkgs, get_launchd_service, get_power_nix_option, @@ -290,6 +293,42 @@ def classify_app(app: InstalledApp) -> ClassificationResult: ) +def classify_app_config(bundle_id: str) -> ClassificationResult: + """Classify an app's Application Support (or equivalent) config location via app_config_registry. + + Registry entries flagged `scannable` route to Tier 2 as a candidate for + further per-key analysis; entries flagged not scannable (opaque + databases, encrypted vaults, LevelDB stores) and bundle IDs with no + registry entry both route to Tier 4. + """ + info = get_app_config(bundle_id) + if info is None: + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"manual report: unknown app config location for bundle id '{bundle_id}'", + metadata={"bundle_id": bundle_id}, + ) + + metadata: dict[str, Any] = { + "bundle_id": bundle_id, + "config_paths": list(info.config_paths), + "file_type": info.file_type.value, + "notes": info.notes, + } + if info.scannable: + return ClassificationResult( + tier=ClassificationTier.CUSTOM_PREFS, + destination="CustomUserPreferences: further scan recommended", + metadata=metadata, + ) + + return ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination=f"manual report: app config for '{bundle_id}' not scannable (e.g. database)", + metadata=metadata, + ) + + def classify_dotfile(entry: DotfileEntry) -> ClassificationResult: """Classify a scanned dotfile by its home-manager program module, if any.""" hm_program = get_hm_program(entry.path) @@ -357,7 +396,17 @@ def classify_font(entry: FontEntry) -> ClassificationResult: def classify_system_setting(field_name: str, value: Any) -> ClassificationResult: - """Classify a SystemConfig field (currently: pmset power settings).""" + """Classify a SystemConfig field: pmset power settings via POWER_SETTING_MAP, + plus the standalone ``timezone`` field routed to TIMEZONE_NIX_OPTION. + """ + if field_name == "timezone": + return ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=TIMEZONE_NIX_OPTION, + nix_path=TIMEZONE_NIX_OPTION, + metadata={"field_name": field_name, "value": value}, + ) + nix_path = get_power_nix_option(field_name) if nix_path is None: return ClassificationResult( @@ -408,21 +457,80 @@ def classify_network_setting(field_name: str, value: Any) -> ClassificationResul def classify_shell_setting(config: ShellConfig) -> list[ClassificationResult]: - """Classify a scanned shell configuration's shell type.""" + """Classify a scanned shell configuration: program enablement, environment + state (aliases/env vars/PATH), and any framework or dynamically-generated + command with no direct nix-darwin equivalent. + """ + results: list[ClassificationResult] = [] + nix_path = get_shell_program(config.shell_type) if nix_path is None: - return [ + results.append( ClassificationResult( tier=ClassificationTier.MANUAL_REPORT, destination=f"manual report: no nix-darwin program mapping for shell type '{config.shell_type}'", metadata={"shell_type": config.shell_type}, ) - ] - return [ - ClassificationResult( - tier=ClassificationTier.NATIVE, - destination=nix_path, - nix_path=nix_path, - metadata={"shell_type": config.shell_type}, ) - ] + else: + results.append( + ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=nix_path, + nix_path=nix_path, + metadata={"shell_type": config.shell_type}, + ) + ) + + if config.aliases: + destination = ENVIRONMENT_MAP["aliases"] + results.append( + ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=destination, + nix_path=destination, + metadata={"aliases": config.aliases}, + ) + ) + + if config.env_vars: + destination = ENVIRONMENT_MAP["env_vars"] + results.append( + ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=destination, + nix_path=destination, + metadata={"env_vars": config.env_vars}, + ) + ) + + if config.path_components: + destination = ENVIRONMENT_MAP["path_components"] + results.append( + ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=destination, + nix_path=destination, + metadata={"path_components": config.path_components}, + ) + ) + + if config.frameworks: + results.append( + ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination="manual report: shell framework(s) have no direct nix-darwin equivalent", + metadata={"frameworks": [framework.name for framework in config.frameworks]}, + ) + ) + + if config.dynamic_commands: + results.append( + ClassificationResult( + tier=ClassificationTier.MANUAL_REPORT, + destination="manual report: dynamically-generated shell commands have no direct nix-darwin equivalent", + metadata={"dynamic_commands": config.dynamic_commands}, + ) + ) + + return results diff --git a/src/mac2nix/mappings/non_defaults_to_nix.py b/src/mac2nix/mappings/non_defaults_to_nix.py index 7a921d8..f9ff33d 100644 --- a/src/mac2nix/mappings/non_defaults_to_nix.py +++ b/src/mac2nix/mappings/non_defaults_to_nix.py @@ -318,6 +318,17 @@ def get_shell_program(shell_type: str) -> str | None: } +# --------------------------------------------------------------------------- +# Environment (environment.shellAliases, environment.variables, environment.systemPath) +# --------------------------------------------------------------------------- + +ENVIRONMENT_MAP: dict[str, str] = { + "aliases": "environment.shellAliases", + "env_vars": "environment.variables", + "path_components": "environment.systemPath", +} + + # --------------------------------------------------------------------------- # Time # --------------------------------------------------------------------------- diff --git a/tests/mappings/test_classifier.py b/tests/mappings/test_classifier.py index 734c3fd..c101304 100644 --- a/tests/mappings/test_classifier.py +++ b/tests/mappings/test_classifier.py @@ -5,6 +5,7 @@ from mac2nix.mappings.classifier import ( ClassificationTier, classify_app, + classify_app_config, classify_brew_formula, classify_dotfile, classify_font, @@ -18,7 +19,7 @@ from mac2nix.models.application import AppSource, BrewFormula, InstalledApp from mac2nix.models.files import DotfileEntry, DotfileManager, FontEntry, FontSource from mac2nix.models.preferences import PreferencesDomain -from mac2nix.models.services import LaunchAgentEntry, LaunchAgentSource, ShellConfig +from mac2nix.models.services import LaunchAgentEntry, LaunchAgentSource, ShellConfig, ShellFramework def _domain(name: str, keys: dict, *, source_path: Path | None = None, source: str = "disk") -> PreferencesDomain: @@ -256,6 +257,27 @@ def test_unrecognized_app_routes_to_manual_report(self) -> None: assert result.tier == ClassificationTier.MANUAL_REPORT +class TestClassifyAppConfig: + def test_scannable_config_routes_to_custom_prefs(self) -> None: + result = classify_app_config("com.microsoft.VSCode") + assert result.tier == ClassificationTier.CUSTOM_PREFS + assert result.metadata is not None + assert result.metadata["bundle_id"] == "com.microsoft.VSCode" + assert "~/Library/Application Support/Code/User/settings.json" in result.metadata["config_paths"] + + def test_non_scannable_config_routes_to_manual_report(self) -> None: + result = classify_app_config("com.apple.Safari") + assert result.tier == ClassificationTier.MANUAL_REPORT + assert result.metadata is not None + assert result.metadata["bundle_id"] == "com.apple.Safari" + assert "not scannable" in result.destination + + def test_unrecognized_bundle_id_routes_to_manual_report(self) -> None: + result = classify_app_config("com.example.SomeUnknownApp") + assert result.tier == ClassificationTier.MANUAL_REPORT + assert result.metadata == {"bundle_id": "com.example.SomeUnknownApp"} + + class TestClassifyDotfile: def test_known_dotfile_routes_to_home_manager_program(self) -> None: entry = DotfileEntry(path=Path.home() / ".zshrc", managed_by=DotfileManager.MANUAL) @@ -323,6 +345,11 @@ def test_unmapped_field_routes_to_manual_report(self) -> None: result = classify_system_setting("icloud", {"signed_in": True}) assert result.tier == ClassificationTier.MANUAL_REPORT + def test_timezone_routes_to_native_time_timezone(self) -> None: + result = classify_system_setting("timezone", "America/New_York") + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "time.timeZone" + class TestClassifySecuritySetting: def test_known_security_field_routes_to_native(self) -> None: @@ -363,3 +390,38 @@ def test_unknown_shell_type_routes_to_manual_report(self) -> None: results = classify_shell_setting(config) assert len(results) == 1 assert results[0].tier == ClassificationTier.MANUAL_REPORT + + def test_aliases_env_vars_and_path_components_each_produce_a_native_result(self) -> None: + config = ShellConfig( + shell_type="fish", + aliases={"ll": "ls -la"}, + env_vars={"EDITOR": "nvim"}, + path_components=["/opt/homebrew/bin"], + ) + results = classify_shell_setting(config) + assert len(results) == 4 + by_destination = {result.destination: result for result in results} + assert by_destination["environment.shellAliases"].tier == ClassificationTier.NATIVE + assert by_destination["environment.shellAliases"].metadata == {"aliases": {"ll": "ls -la"}} + assert by_destination["environment.variables"].tier == ClassificationTier.NATIVE + assert by_destination["environment.variables"].metadata == {"env_vars": {"EDITOR": "nvim"}} + assert by_destination["environment.systemPath"].tier == ClassificationTier.NATIVE + assert by_destination["environment.systemPath"].metadata == {"path_components": ["/opt/homebrew/bin"]} + + def test_empty_aliases_env_vars_and_path_components_produce_no_extra_results(self) -> None: + config = ShellConfig(shell_type="zsh") + results = classify_shell_setting(config) + assert len(results) == 1 + + def test_frameworks_and_dynamic_commands_route_to_manual_report(self) -> None: + config = ShellConfig( + shell_type="zsh", + frameworks=[ShellFramework(name="oh-my-zsh")], + dynamic_commands=["eval $(starship init zsh)"], + ) + results = classify_shell_setting(config) + assert len(results) == 3 + manual_results = [result for result in results if result.tier == ClassificationTier.MANUAL_REPORT] + assert len(manual_results) == 2 + assert any("framework" in result.destination for result in manual_results) + assert any("dynamic" in result.destination.lower() for result in manual_results) diff --git a/tests/mappings/test_non_defaults_to_nix.py b/tests/mappings/test_non_defaults_to_nix.py index 876f65b..2c1e2ba 100644 --- a/tests/mappings/test_non_defaults_to_nix.py +++ b/tests/mappings/test_non_defaults_to_nix.py @@ -1,6 +1,7 @@ """Tests for non_defaults_to_nix mapping.""" from mac2nix.mappings.non_defaults_to_nix import ( + ENVIRONMENT_MAP, LAUNCHD_KEYS_TO_DROP, LAUNCHD_LABEL_TO_SERVICE, NETWORKING_MAP, @@ -151,6 +152,13 @@ def test_firewall_fields_route_through_application_firewall(self) -> None: assert "alf" not in option.lower() +class TestEnvironmentMap: + def test_contains_expected_fields(self) -> None: + assert ENVIRONMENT_MAP["aliases"] == "environment.shellAliases" + assert ENVIRONMENT_MAP["env_vars"] == "environment.variables" + assert ENVIRONMENT_MAP["path_components"] == "environment.systemPath" + + class TestTimezoneOption: def test_timezone_option_path(self) -> None: assert TIMEZONE_NIX_OPTION == "time.timeZone" From 77b752fe031e2954af89bf00d25b670b5ff49f8f Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 12:31:38 -0400 Subject: [PATCH 12/35] fix(mappings): freeze lookup-result dataclasses and immutabilize AppConfigInfo.config_paths HMModuleInfo and NixpkgsEquivalent were plain @dataclass while every other lookup-result type in this layer is frozen+slots; they are stored in module-level "constant" dicts and should not be mutable. Also changes AppConfigInfo.config_paths from list[str] to tuple[str, ...] since a mutable list inside a frozen dataclass could still be corrupted via in-place mutation. Additionally deep-copies the raw_plist snapshot in classify_launch_agent() (previously a shallow dict comprehension shared nested dict/list references with the LaunchAgentEntry the orchestrator still holds) and documents that Phase 6 generators must check metadata.get("skipped") before surfacing MANUAL_REPORT entries in a manual-steps report. --- src/mac2nix/mappings/app_config_registry.py | 64 +++++++++++---------- src/mac2nix/mappings/app_to_hm.py | 2 +- src/mac2nix/mappings/brew_to_nixpkgs.py | 2 +- src/mac2nix/mappings/classifier.py | 5 +- tests/mappings/test_app_config_registry.py | 6 +- 5 files changed, 42 insertions(+), 37 deletions(-) diff --git a/src/mac2nix/mappings/app_config_registry.py b/src/mac2nix/mappings/app_config_registry.py index e849d26..e3cf99a 100644 --- a/src/mac2nix/mappings/app_config_registry.py +++ b/src/mac2nix/mappings/app_config_registry.py @@ -14,7 +14,7 @@ @dataclass(frozen=True, slots=True) class AppConfigInfo: - config_paths: list[str] + config_paths: tuple[str, ...] file_type: ConfigFileType scannable: bool = True notes: str | None = None @@ -22,168 +22,170 @@ class AppConfigInfo: APP_CONFIG_REGISTRY: dict[str, AppConfigInfo] = { "com.apple.Safari": AppConfigInfo( - config_paths=["~/Library/Safari/History.db"], + config_paths=("~/Library/Safari/History.db",), file_type=ConfigFileType.DATABASE, scannable=False, notes="Browsing history database; bookmarks stored separately in ~/Library/Safari/Bookmarks.plist", ), "com.apple.mail": AppConfigInfo( - config_paths=["~/Library/Mail"], + config_paths=("~/Library/Mail",), file_type=ConfigFileType.DATABASE, scannable=False, notes="Versioned V*/MailData/Envelope Index sqlite database; not directly editable config", ), "com.google.Chrome": AppConfigInfo( - config_paths=["~/Library/Application Support/Google/Chrome/Default/Preferences"], + config_paths=("~/Library/Application Support/Google/Chrome/Default/Preferences",), file_type=ConfigFileType.JSON, scannable=True, notes="Large single-file JSON blob; consider extracting only user-relevant keys", ), "com.brave.Browser": AppConfigInfo( - config_paths=["~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Preferences"], + config_paths=("~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Preferences",), file_type=ConfigFileType.JSON, scannable=True, notes="Chromium-based; same structure as Chrome's Preferences file", ), "org.mozilla.firefox": AppConfigInfo( - config_paths=["~/Library/Application Support/Firefox/Profiles"], + config_paths=("~/Library/Application Support/Firefox/Profiles",), file_type=ConfigFileType.CONF, scannable=False, notes="prefs.js uses JS call syntax (user_pref(...)), not simple key=value; " "profile directory names require profiles.ini resolution", ), "org.mozilla.thunderbird": AppConfigInfo( - config_paths=["~/Library/Thunderbird/Profiles"], + config_paths=("~/Library/Thunderbird/Profiles",), file_type=ConfigFileType.CONF, scannable=False, notes="Same prefs.js/profiles.ini structure as Firefox", ), "com.microsoft.VSCode": AppConfigInfo( - config_paths=[ + config_paths=( "~/Library/Application Support/Code/User/settings.json", "~/Library/Application Support/Code/User/keybindings.json", - ], + ), file_type=ConfigFileType.JSON, scannable=True, ), "com.sublimetext.4": AppConfigInfo( - config_paths=["~/Library/Application Support/Sublime Text/Packages/User/Preferences.sublime-settings"], + config_paths=("~/Library/Application Support/Sublime Text/Packages/User/Preferences.sublime-settings",), file_type=ConfigFileType.JSON, scannable=True, notes=".sublime-settings extension but JSON-with-comments format", ), "com.jetbrains.intellij": AppConfigInfo( - config_paths=["~/Library/Application Support/JetBrains"], + config_paths=("~/Library/Application Support/JetBrains",), file_type=ConfigFileType.XML, scannable=True, notes="Versioned per-release subdirectories (e.g. IntelliJIdea2026.1/options/*.xml)", ), "com.jetbrains.pycharm": AppConfigInfo( - config_paths=["~/Library/Application Support/JetBrains"], + config_paths=("~/Library/Application Support/JetBrains",), file_type=ConfigFileType.XML, scannable=True, notes="Same JetBrains versioned-subdirectory scheme as IntelliJ IDEA", ), "com.jetbrains.WebStorm": AppConfigInfo( - config_paths=["~/Library/Application Support/JetBrains"], + config_paths=("~/Library/Application Support/JetBrains",), file_type=ConfigFileType.XML, scannable=True, notes="Same JetBrains versioned-subdirectory scheme as IntelliJ IDEA", ), "com.github.GitHubClient": AppConfigInfo( - config_paths=["~/Library/Application Support/GitHub Desktop"], + config_paths=("~/Library/Application Support/GitHub Desktop",), file_type=ConfigFileType.JSON, scannable=True, notes="Electron app config; exact filename unverified, flagged for manual review", ), "com.spotify.client": AppConfigInfo( - config_paths=["~/Library/Application Support/Spotify/prefs"], + config_paths=("~/Library/Application Support/Spotify/prefs",), file_type=ConfigFileType.CONF, scannable=True, notes="key=value pairs despite no file extension", ), "com.1password.1password": AppConfigInfo( - config_paths=["~/Library/Group Containers/2BUA8C4S2C.com.1password/Library/Application Support/1Password/Data"], + config_paths=( + "~/Library/Group Containers/2BUA8C4S2C.com.1password/Library/Application Support/1Password/Data", + ), file_type=ConfigFileType.DATABASE, scannable=False, notes="Encrypted vault data, not human-editable config", ), "com.bitwarden.desktop": AppConfigInfo( - config_paths=["~/Library/Application Support/Bitwarden/data.json"], + config_paths=("~/Library/Application Support/Bitwarden/data.json",), file_type=ConfigFileType.DATABASE, scannable=False, notes="Encrypted vault export despite .json extension", ), "com.tinyspeck.slackmacgap": AppConfigInfo( - config_paths=["~/Library/Application Support/Slack/storage"], + config_paths=("~/Library/Application Support/Slack/storage",), file_type=ConfigFileType.DATABASE, scannable=False, notes="LevelDB-backed local storage, not directly parseable", ), "com.hnc.Discord": AppConfigInfo( - config_paths=["~/Library/Application Support/discord/settings.json"], + config_paths=("~/Library/Application Support/discord/settings.json",), file_type=ConfigFileType.JSON, scannable=True, notes="Message/user cache stored separately in LevelDB (not scannable)", ), "com.docker.docker": AppConfigInfo( - config_paths=["~/Library/Group Containers/group.com.docker/settings.json"], + config_paths=("~/Library/Group Containers/group.com.docker/settings.json",), file_type=ConfigFileType.JSON, scannable=True, ), "md.obsidian": AppConfigInfo( - config_paths=["~/Library/Application Support/obsidian/obsidian.json"], + config_paths=("~/Library/Application Support/obsidian/obsidian.json",), file_type=ConfigFileType.JSON, scannable=True, notes="Global app config; per-vault settings live in each vault's .obsidian/ folder, not under ~/Library", ), "com.raycast.macos": AppConfigInfo( - config_paths=["~/Library/Application Support/com.raycast.macos"], + config_paths=("~/Library/Application Support/com.raycast.macos",), file_type=ConfigFileType.DATABASE, scannable=False, notes="Settings and extension state stored in sqlite databases", ), "com.googlecode.iterm2": AppConfigInfo( - config_paths=["~/Library/Application Support/iTerm2/DynamicProfiles"], + config_paths=("~/Library/Application Support/iTerm2/DynamicProfiles",), file_type=ConfigFileType.JSON, scannable=True, notes="Dynamic profile JSON files; core preferences stored separately in " "~/Library/Preferences/com.googlecode.iterm2.plist (see preferences scanner)", ), "org.videolan.vlc": AppConfigInfo( - config_paths=["~/Library/Preferences/org.videolan.vlc/vlcrc"], + config_paths=("~/Library/Preferences/org.videolan.vlc/vlcrc",), file_type=ConfigFileType.CONF, scannable=True, notes="INI-style key=value config despite being under ~/Library/Preferences", ), "us.zoom.xos": AppConfigInfo( - config_paths=["~/Library/Application Support/zoom.us/data/zoomus.enc.db"], + config_paths=("~/Library/Application Support/zoom.us/data/zoomus.enc.db",), file_type=ConfigFileType.DATABASE, scannable=False, notes="Encrypted local settings db; user-visible preferences duplicated in " "~/Library/Preferences/us.zoom.xos.plist", ), "com.culturedcode.ThingsMac": AppConfigInfo( - config_paths=["~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac"], + config_paths=("~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac",), file_type=ConfigFileType.DATABASE, scannable=False, notes="Things Database.thingsdatabase sqlite store", ), "com.postmanlabs.mac": AppConfigInfo( - config_paths=["~/Library/Application Support/Postman"], + config_paths=("~/Library/Application Support/Postman",), file_type=ConfigFileType.DATABASE, scannable=False, notes="Mostly cloud-synced; local storage format unverified, flagged for manual review", ), "org.qbittorrent.qBittorrent": AppConfigInfo( - config_paths=["~/Library/Preferences/qBittorrent/qBittorrent.ini"], + config_paths=("~/Library/Preferences/qBittorrent/qBittorrent.ini",), file_type=ConfigFileType.CONF, scannable=True, ), "com.microsoft.rdc.macos": AppConfigInfo( - config_paths=[ - "~/Library/Containers/com.microsoft.rdc.macos/Data/Library/Application Support/Microsoft Remote Desktop" - ], + config_paths=( + "~/Library/Containers/com.microsoft.rdc.macos/Data/Library/Application Support/Microsoft Remote Desktop", + ), file_type=ConfigFileType.DATABASE, scannable=False, notes="Connection list stored in a Core Data sqlite store, not directly editable", diff --git a/src/mac2nix/mappings/app_to_hm.py b/src/mac2nix/mappings/app_to_hm.py index 7ba37cf..9b2ca14 100644 --- a/src/mac2nix/mappings/app_to_hm.py +++ b/src/mac2nix/mappings/app_to_hm.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field -@dataclass +@dataclass(frozen=True, slots=True) class HMModuleInfo: """Metadata describing a home-manager ``programs.*`` module for a detected app or tool.""" diff --git a/src/mac2nix/mappings/brew_to_nixpkgs.py b/src/mac2nix/mappings/brew_to_nixpkgs.py index fb540a0..8ba0376 100644 --- a/src/mac2nix/mappings/brew_to_nixpkgs.py +++ b/src/mac2nix/mappings/brew_to_nixpkgs.py @@ -17,7 +17,7 @@ from dataclasses import dataclass -@dataclass +@dataclass(frozen=True, slots=True) class NixpkgsEquivalent: attr_name: str note: str | None = None diff --git a/src/mac2nix/mappings/classifier.py b/src/mac2nix/mappings/classifier.py index a6b54da..5248c37 100644 --- a/src/mac2nix/mappings/classifier.py +++ b/src/mac2nix/mappings/classifier.py @@ -17,6 +17,7 @@ from __future__ import annotations +import copy import re from collections.abc import Callable from dataclasses import dataclass @@ -143,6 +144,8 @@ def _classify_preference_precheck( ) if is_ephemeral(key, value): + # Phase 6 generators must check metadata.get("skipped") before surfacing + # MANUAL_REPORT entries, so ephemeral values aren't shown in a manual-steps report. return ClassificationResult( tier=ClassificationTier.MANUAL_REPORT, destination="skipped: ephemeral UI/runtime state, not reproducible config", @@ -230,7 +233,7 @@ def classify_launch_agent(entry: LaunchAgentEntry) -> ClassificationResult: metadata={"label": entry.label, "source": entry.source.value}, ) - cleaned_plist = {k: v for k, v in entry.raw_plist.items() if not is_launchd_key_droppable(k)} + cleaned_plist = {k: copy.deepcopy(v) for k, v in entry.raw_plist.items() if not is_launchd_key_droppable(k)} if entry.source == LaunchAgentSource.DAEMON: tier = ClassificationTier.ACTIVATION_SCRIPT destination = f'launchd.daemons."{entry.label}".serviceConfig' diff --git a/tests/mappings/test_app_config_registry.py b/tests/mappings/test_app_config_registry.py index c8cbb46..f46c696 100644 --- a/tests/mappings/test_app_config_registry.py +++ b/tests/mappings/test_app_config_registry.py @@ -35,7 +35,7 @@ def test_safari_history_is_database_and_not_scannable(self): info = APP_CONFIG_REGISTRY["com.apple.Safari"] assert info.file_type == ConfigFileType.DATABASE assert info.scannable is False - assert info.config_paths == ["~/Library/Safari/History.db"] + assert info.config_paths == ("~/Library/Safari/History.db",) def test_vscode_settings_are_json_and_scannable(self): info = APP_CONFIG_REGISTRY["com.microsoft.VSCode"] @@ -56,9 +56,9 @@ def test_at_least_one_database_entry_not_scannable(self): def test_docker_settings_are_json(self): info = APP_CONFIG_REGISTRY["com.docker.docker"] assert info.file_type == ConfigFileType.JSON - assert info.config_paths == ["~/Library/Group Containers/group.com.docker/settings.json"] + assert info.config_paths == ("~/Library/Group Containers/group.com.docker/settings.json",) def test_notes_default_to_none(self): - info = AppConfigInfo(config_paths=["~/foo"], file_type=ConfigFileType.UNKNOWN) + info = AppConfigInfo(config_paths=("~/foo",), file_type=ConfigFileType.UNKNOWN) assert info.notes is None assert info.scannable is True From afb517b55503d6160034c69cf7afda1cef4df9fe Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 12:37:12 -0400 Subject: [PATCH 13/35] refactor(mappings): collapse repeated shell-env branches, freeze HMModuleInfo paths classify_shell_setting() had three near-identical if-blocks for aliases/env_vars/path_components that only differed by field name; collapsed into a loop over ENVIRONMENT_MAP's keys, which match the ShellConfig field names exactly. HMModuleInfo.config_paths/darwin_config_paths were still list[str] with mutable defaults despite the class being frozen+slots in the prior commit -- the same corruption risk AppConfigInfo.config_paths was just fixed for. Converted both to tuple[str, ...] for consistency and updated the 40+ registry entries and tests accordingly. --- src/mac2nix/mappings/app_to_hm.py | 98 +++++++++++++++--------------- src/mac2nix/mappings/classifier.py | 42 ++++--------- tests/mappings/test_app_to_hm.py | 22 +++---- 3 files changed, 71 insertions(+), 91 deletions(-) diff --git a/src/mac2nix/mappings/app_to_hm.py b/src/mac2nix/mappings/app_to_hm.py index 9b2ca14..74df404 100644 --- a/src/mac2nix/mappings/app_to_hm.py +++ b/src/mac2nix/mappings/app_to_hm.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass @dataclass(frozen=True, slots=True) @@ -10,8 +10,8 @@ class HMModuleInfo: """Metadata describing a home-manager ``programs.*`` module for a detected app or tool.""" module_path: str - config_paths: list[str] = field(default_factory=list) - darwin_config_paths: list[str] = field(default_factory=list) + config_paths: tuple[str, ...] = () + darwin_config_paths: tuple[str, ...] = () extensions_cmd: str | None = None inject_only: bool = False @@ -20,71 +20,71 @@ class HMModuleInfo: # Version Control / SSH / GPG "git": HMModuleInfo( module_path="programs.git", - config_paths=["~/.config/git/config", "~/.config/git/ignore", "~/.config/git/attributes"], + config_paths=("~/.config/git/config", "~/.config/git/ignore", "~/.config/git/attributes"), ), - "ssh": HMModuleInfo(module_path="programs.ssh", config_paths=["~/.ssh/config"]), + "ssh": HMModuleInfo(module_path="programs.ssh", config_paths=("~/.ssh/config",)), "gpg": HMModuleInfo( module_path="programs.gpg", - config_paths=["~/.gnupg/gpg.conf", "~/.gnupg/scdaemon.conf", "~/.gnupg/dirmngr.conf"], + config_paths=("~/.gnupg/gpg.conf", "~/.gnupg/scdaemon.conf", "~/.gnupg/dirmngr.conf"), ), "gh": HMModuleInfo( module_path="programs.gh", - config_paths=["~/.config/gh/config.yml"], + config_paths=("~/.config/gh/config.yml",), extensions_cmd="gh extension list", ), # Shells "fish": HMModuleInfo( module_path="programs.fish", - config_paths=["~/.config/fish/config.fish", "~/.config/fish/functions/", "~/.config/fish/completions/"], + config_paths=("~/.config/fish/config.fish", "~/.config/fish/functions/", "~/.config/fish/completions/"), ), - "zsh": HMModuleInfo(module_path="programs.zsh", config_paths=["~/.zshrc", "~/.zshenv", "~/.zprofile"]), - "bash": HMModuleInfo(module_path="programs.bash", config_paths=["~/.bashrc", "~/.bash_profile", "~/.profile"]), + "zsh": HMModuleInfo(module_path="programs.zsh", config_paths=("~/.zshrc", "~/.zshenv", "~/.zprofile")), + "bash": HMModuleInfo(module_path="programs.bash", config_paths=("~/.bashrc", "~/.bash_profile", "~/.profile")), # Terminal multiplexers / emulators - "tmux": HMModuleInfo(module_path="programs.tmux", config_paths=["~/.config/tmux/tmux.conf"]), - "alacritty": HMModuleInfo(module_path="programs.alacritty", config_paths=["~/.config/alacritty/alacritty.toml"]), - "kitty": HMModuleInfo(module_path="programs.kitty", config_paths=["~/.config/kitty/kitty.conf"]), - "ghostty": HMModuleInfo(module_path="programs.ghostty", config_paths=["~/.config/ghostty/config"]), - "wezterm": HMModuleInfo(module_path="programs.wezterm", config_paths=["~/.config/wezterm/wezterm.lua"]), + "tmux": HMModuleInfo(module_path="programs.tmux", config_paths=("~/.config/tmux/tmux.conf",)), + "alacritty": HMModuleInfo(module_path="programs.alacritty", config_paths=("~/.config/alacritty/alacritty.toml",)), + "kitty": HMModuleInfo(module_path="programs.kitty", config_paths=("~/.config/kitty/kitty.conf",)), + "ghostty": HMModuleInfo(module_path="programs.ghostty", config_paths=("~/.config/ghostty/config",)), + "wezterm": HMModuleInfo(module_path="programs.wezterm", config_paths=("~/.config/wezterm/wezterm.lua",)), # Editors "neovim": HMModuleInfo( module_path="programs.neovim", - config_paths=["~/.config/nvim/init.lua", "~/.local/share/nvim/site/pack/hm/"], + config_paths=("~/.config/nvim/init.lua", "~/.local/share/nvim/site/pack/hm/"), ), "vim": HMModuleInfo(module_path="programs.vim"), "vscode": HMModuleInfo( module_path="programs.vscode", - config_paths=["~/.config/Code/User/settings.json"], - darwin_config_paths=["~/Library/Application Support/Code/User/settings.json"], + config_paths=("~/.config/Code/User/settings.json",), + darwin_config_paths=("~/Library/Application Support/Code/User/settings.json",), extensions_cmd="code --list-extensions", ), "helix": HMModuleInfo( module_path="programs.helix", - config_paths=["~/.config/helix/config.toml", "~/.config/helix/languages.toml"], + config_paths=("~/.config/helix/config.toml", "~/.config/helix/languages.toml"), ), - "zed": HMModuleInfo(module_path="programs.zed-editor", config_paths=["~/.config/zed/settings.json"]), + "zed": HMModuleInfo(module_path="programs.zed-editor", config_paths=("~/.config/zed/settings.json",)), # Browsers "firefox": HMModuleInfo( module_path="programs.firefox", - config_paths=["~/.mozilla/firefox/"], - darwin_config_paths=["~/Library/Application Support/Firefox/"], + config_paths=("~/.mozilla/firefox/",), + darwin_config_paths=("~/Library/Application Support/Firefox/",), ), # Prompt / shell integration CLI tools - "starship": HMModuleInfo(module_path="programs.starship", config_paths=["~/.config/starship.toml"]), - "direnv": HMModuleInfo(module_path="programs.direnv", config_paths=["~/.config/direnv/direnv.toml"]), + "starship": HMModuleInfo(module_path="programs.starship", config_paths=("~/.config/starship.toml",)), + "direnv": HMModuleInfo(module_path="programs.direnv", config_paths=("~/.config/direnv/direnv.toml",)), "fzf": HMModuleInfo(module_path="programs.fzf", inject_only=True), - "bat": HMModuleInfo(module_path="programs.bat", config_paths=["~/.config/bat/config"]), - "eza": HMModuleInfo(module_path="programs.eza", config_paths=["~/.config/eza/theme.yml"]), - "fd": HMModuleInfo(module_path="programs.fd", config_paths=["~/.config/fd/ignore"]), - "ripgrep": HMModuleInfo(module_path="programs.ripgrep", config_paths=["~/.config/ripgrep/ripgreprc"]), + "bat": HMModuleInfo(module_path="programs.bat", config_paths=("~/.config/bat/config",)), + "eza": HMModuleInfo(module_path="programs.eza", config_paths=("~/.config/eza/theme.yml",)), + "fd": HMModuleInfo(module_path="programs.fd", config_paths=("~/.config/fd/ignore",)), + "ripgrep": HMModuleInfo(module_path="programs.ripgrep", config_paths=("~/.config/ripgrep/ripgreprc",)), "jq": HMModuleInfo(module_path="programs.jq", inject_only=True), - "htop": HMModuleInfo(module_path="programs.htop", config_paths=["~/.config/htop/htoprc"]), - "btop": HMModuleInfo(module_path="programs.btop", config_paths=["~/.config/btop/btop.conf"]), + "htop": HMModuleInfo(module_path="programs.htop", config_paths=("~/.config/htop/htoprc",)), + "btop": HMModuleInfo(module_path="programs.btop", config_paths=("~/.config/btop/btop.conf",)), "zoxide": HMModuleInfo(module_path="programs.zoxide", inject_only=True), - "atuin": HMModuleInfo(module_path="programs.atuin", config_paths=["~/.config/atuin/config.toml"]), - "readline": HMModuleInfo(module_path="programs.readline", config_paths=["~/.inputrc"]), - "yt-dlp": HMModuleInfo(module_path="programs.yt-dlp", config_paths=["~/.config/yt-dlp/config"]), + "atuin": HMModuleInfo(module_path="programs.atuin", config_paths=("~/.config/atuin/config.toml",)), + "readline": HMModuleInfo(module_path="programs.readline", config_paths=("~/.inputrc",)), + "yt-dlp": HMModuleInfo(module_path="programs.yt-dlp", config_paths=("~/.config/yt-dlp/config",)), "mpv": HMModuleInfo( - module_path="programs.mpv", config_paths=["~/.config/mpv/mpv.conf", "~/.config/mpv/input.conf"] + module_path="programs.mpv", config_paths=("~/.config/mpv/mpv.conf", "~/.config/mpv/input.conf") ), "mcfly": HMModuleInfo(module_path="programs.mcfly", inject_only=True), "autojump": HMModuleInfo(module_path="programs.autojump", inject_only=True), @@ -96,35 +96,35 @@ class HMModuleInfo: # DevOps / Cloud "k9s": HMModuleInfo( module_path="programs.k9s", - config_paths=["~/.config/k9s/config.yaml"], - darwin_config_paths=["~/Library/Application Support/k9s/"], + config_paths=("~/.config/k9s/config.yaml",), + darwin_config_paths=("~/Library/Application Support/k9s/",), ), "lazygit": HMModuleInfo( module_path="programs.lazygit", - config_paths=["~/.config/lazygit/config.yml"], - darwin_config_paths=["~/Library/Application Support/lazygit/"], + config_paths=("~/.config/lazygit/config.yml",), + darwin_config_paths=("~/Library/Application Support/lazygit/",), ), - "docker": HMModuleInfo(module_path="programs.docker-cli", config_paths=["~/.docker/config.json"]), - "aws": HMModuleInfo(module_path="programs.awscli", config_paths=["~/.aws/config", "~/.aws/credentials"]), + "docker": HMModuleInfo(module_path="programs.docker-cli", config_paths=("~/.docker/config.json",)), + "aws": HMModuleInfo(module_path="programs.awscli", config_paths=("~/.aws/config", "~/.aws/credentials")), # Programming languages / build tools "go": HMModuleInfo( module_path="programs.go", - config_paths=["~/.config/go/env"], - darwin_config_paths=["~/Library/Application Support/go/env"], + config_paths=("~/.config/go/env",), + darwin_config_paths=("~/Library/Application Support/go/env",), ), "java": HMModuleInfo(module_path="programs.java", inject_only=True), - "cargo": HMModuleInfo(module_path="programs.cargo", config_paths=["~/.cargo/config.toml"]), - "npm": HMModuleInfo(module_path="programs.npm", config_paths=["~/.npmrc"]), + "cargo": HMModuleInfo(module_path="programs.cargo", config_paths=("~/.cargo/config.toml",)), + "npm": HMModuleInfo(module_path="programs.npm", config_paths=("~/.npmrc",)), "pyenv": HMModuleInfo(module_path="programs.pyenv", inject_only=True), - "rbenv": HMModuleInfo(module_path="programs.rbenv", config_paths=["~/.rbenv/plugins/"]), - "mise": HMModuleInfo(module_path="programs.mise", config_paths=["~/.config/mise/config.toml"]), + "rbenv": HMModuleInfo(module_path="programs.rbenv", config_paths=("~/.rbenv/plugins/",)), + "mise": HMModuleInfo(module_path="programs.mise", config_paths=("~/.config/mise/config.toml",)), # macOS-only window/status managers "rectangle": HMModuleInfo( module_path="programs.rectangle", - darwin_config_paths=["~/Library/Preferences/com.knollsoft.Rectangle.plist"], + darwin_config_paths=("~/Library/Preferences/com.knollsoft.Rectangle.plist",), ), - "aerospace": HMModuleInfo(module_path="programs.aerospace", config_paths=["~/.config/aerospace/aerospace.toml"]), - "sketchybar": HMModuleInfo(module_path="programs.sketchybar", config_paths=["~/.config/sketchybar/sketchybarrc"]), + "aerospace": HMModuleInfo(module_path="programs.aerospace", config_paths=("~/.config/aerospace/aerospace.toml",)), + "sketchybar": HMModuleInfo(module_path="programs.sketchybar", config_paths=("~/.config/sketchybar/sketchybarrc",)), } diff --git a/src/mac2nix/mappings/classifier.py b/src/mac2nix/mappings/classifier.py index 5248c37..e0e3535 100644 --- a/src/mac2nix/mappings/classifier.py +++ b/src/mac2nix/mappings/classifier.py @@ -485,38 +485,18 @@ def classify_shell_setting(config: ShellConfig) -> list[ClassificationResult]: ) ) - if config.aliases: - destination = ENVIRONMENT_MAP["aliases"] - results.append( - ClassificationResult( - tier=ClassificationTier.NATIVE, - destination=destination, - nix_path=destination, - metadata={"aliases": config.aliases}, - ) - ) - - if config.env_vars: - destination = ENVIRONMENT_MAP["env_vars"] - results.append( - ClassificationResult( - tier=ClassificationTier.NATIVE, - destination=destination, - nix_path=destination, - metadata={"env_vars": config.env_vars}, - ) - ) - - if config.path_components: - destination = ENVIRONMENT_MAP["path_components"] - results.append( - ClassificationResult( - tier=ClassificationTier.NATIVE, - destination=destination, - nix_path=destination, - metadata={"path_components": config.path_components}, + for field_name in ("aliases", "env_vars", "path_components"): + field_value = getattr(config, field_name) + if field_value: + destination = ENVIRONMENT_MAP[field_name] + results.append( + ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=destination, + nix_path=destination, + metadata={field_name: field_value}, + ) ) - ) if config.frameworks: results.append( diff --git a/tests/mappings/test_app_to_hm.py b/tests/mappings/test_app_to_hm.py index 7b2878f..9f4e925 100644 --- a/tests/mappings/test_app_to_hm.py +++ b/tests/mappings/test_app_to_hm.py @@ -12,7 +12,7 @@ def test_git_module(self) -> None: def test_ssh_module(self) -> None: info = APP_TO_HM_MODULE["ssh"] assert info.module_path == "programs.ssh" - assert info.config_paths == ["~/.ssh/config"] + assert info.config_paths == ("~/.ssh/config",) def test_fish_module(self) -> None: info = APP_TO_HM_MODULE["fish"] @@ -27,26 +27,26 @@ def test_zsh_module(self) -> None: def test_darwin_aware_vscode_module(self) -> None: info = APP_TO_HM_MODULE["vscode"] assert info.module_path == "programs.vscode" - assert info.config_paths == ["~/.config/Code/User/settings.json"] - assert info.darwin_config_paths == ["~/Library/Application Support/Code/User/settings.json"] + assert info.config_paths == ("~/.config/Code/User/settings.json",) + assert info.darwin_config_paths == ("~/Library/Application Support/Code/User/settings.json",) assert info.extensions_cmd == "code --list-extensions" def test_darwin_aware_k9s_lazygit_go_modules(self) -> None: - assert APP_TO_HM_MODULE["k9s"].darwin_config_paths == ["~/Library/Application Support/k9s/"] - assert APP_TO_HM_MODULE["lazygit"].darwin_config_paths == ["~/Library/Application Support/lazygit/"] - assert APP_TO_HM_MODULE["go"].darwin_config_paths == ["~/Library/Application Support/go/env"] + assert APP_TO_HM_MODULE["k9s"].darwin_config_paths == ("~/Library/Application Support/k9s/",) + assert APP_TO_HM_MODULE["lazygit"].darwin_config_paths == ("~/Library/Application Support/lazygit/",) + assert APP_TO_HM_MODULE["go"].darwin_config_paths == ("~/Library/Application Support/go/env",) def test_darwin_only_rectangle_module(self) -> None: info = APP_TO_HM_MODULE["rectangle"] assert info.module_path == "programs.rectangle" - assert info.config_paths == [] - assert info.darwin_config_paths == ["~/Library/Preferences/com.knollsoft.Rectangle.plist"] + assert info.config_paths == () + assert info.darwin_config_paths == ("~/Library/Preferences/com.knollsoft.Rectangle.plist",) def test_inject_only_shell_integration_modules(self) -> None: for name in ("fzf", "zoxide", "jq", "pyenv"): info = APP_TO_HM_MODULE[name] assert info.inject_only is True - assert info.config_paths == [] + assert info.config_paths == () def test_non_inject_only_module_defaults_false(self) -> None: assert APP_TO_HM_MODULE["git"].inject_only is False @@ -62,7 +62,7 @@ def test_get_hm_module_unrecognized_returns_none(self) -> None: def test_hm_module_info_defaults(self) -> None: info = HMModuleInfo(module_path="programs.example") - assert info.config_paths == [] - assert info.darwin_config_paths == [] + assert info.config_paths == () + assert info.darwin_config_paths == () assert info.extensions_cmd is None assert info.inject_only is False From d4ef672d8b979486aa0057d6135a8aaa02231a6d Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 12:54:31 -0400 Subject: [PATCH 14/35] fix(mappings): redact sensitive shell alias/env-var/command values before classification classify_shell_setting() wrapped aliases, env_vars, and dynamic_commands into ClassificationResult.metadata with no sensitivity checking, unlike classify_preference()'s existing SENSITIVE_KEY_PATTERNS gate. Shell aliases and dynamic commands routinely embed credentials as inline text (e.g. AWS_SECRET_ACCESS_KEY=... in an alias body), and these values route to Tier NATIVE destinations that a Phase 6 generator will eventually write into a git-tracked, world-readable /nix/store module. - Generalize _is_sensitive_key() into _contains_sensitive_pattern() so the same substring check covers preference keys, alias/env-var names AND values, and full dynamic-command lines. - Add _redact_sensitive_dict_entries()/_redact_sensitive_list_entries() helpers that redact only the matching entry (not the whole field), and set metadata["potentially_sensitive_entries_redacted"] when any redaction occurs. - Iterate ENVIRONMENT_MAP directly in classify_shell_setting() instead of a separately-hardcoded field-name tuple, so the two can't drift. Also adds a regression test proving classify_launch_agent()'s raw_plist cleanup performs a true deep copy (nested dict/list values are not shared with entry.raw_plist) -- the existing test only used flat values, which can't distinguish a deep copy from a shallow one. --- src/mac2nix/mappings/classifier.py | 72 +++++++++++++++++++----- tests/mappings/test_classifier.py | 90 ++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 14 deletions(-) diff --git a/src/mac2nix/mappings/classifier.py b/src/mac2nix/mappings/classifier.py index e0e3535..67961f7 100644 --- a/src/mac2nix/mappings/classifier.py +++ b/src/mac2nix/mappings/classifier.py @@ -93,11 +93,42 @@ class ClassificationResult: } -def _is_sensitive_key(key: str) -> bool: - upper = key.upper() +def _contains_sensitive_pattern(text: str) -> bool: + upper = text.upper() return any(pattern in upper for pattern in SENSITIVE_KEY_PATTERNS) +def _redact_sensitive_dict_entries(entries: dict[str, str]) -> tuple[dict[str, str], bool]: + """Redact dict entries (shell aliases, env vars) whose name or value looks like a secret. + + Credentials routinely appear inline in alias bodies and env var values + (e.g. `alias awsprod='AWS_SECRET_ACCESS_KEY=... aws --profile prod'`), so + both the key and the value must be checked -- not just the key name. + """ + redacted_any = False + result: dict[str, str] = {} + for key, value in entries.items(): + if _contains_sensitive_pattern(key) or _contains_sensitive_pattern(value): + result[key] = "***REDACTED***" + redacted_any = True + else: + result[key] = value + return result, redacted_any + + +def _redact_sensitive_list_entries(entries: list[str]) -> tuple[list[str], bool]: + """Redact list entries (dynamically-generated shell commands) that look like they embed a secret.""" + redacted_any = False + result: list[str] = [] + for entry in entries: + if _contains_sensitive_pattern(entry): + result.append("***REDACTED***") + redacted_any = True + else: + result.append(entry) + return result, redacted_any + + def _is_binary_sentinel(value: Any) -> bool: return isinstance(value, str) and bool(_BINARY_SENTINEL_RE.match(value)) @@ -117,7 +148,7 @@ def _classify_preference_precheck( domain: PreferencesDomain, key: str, value: PreferenceValue ) -> ClassificationResult | None: """SEC-1/SEC-3 gating checks plus ephemeral-noise filtering, run before any tier routing.""" - if _is_sensitive_key(key): + if _contains_sensitive_pattern(key): return ClassificationResult( tier=ClassificationTier.MANUAL_REPORT, destination=f"manual report: key '{key}' in domain '{domain.domain_name}' matches a sensitive pattern", @@ -485,18 +516,27 @@ def classify_shell_setting(config: ShellConfig) -> list[ClassificationResult]: ) ) - for field_name in ("aliases", "env_vars", "path_components"): + for field_name in ENVIRONMENT_MAP: field_value = getattr(config, field_name) - if field_value: - destination = ENVIRONMENT_MAP[field_name] - results.append( - ClassificationResult( - tier=ClassificationTier.NATIVE, - destination=destination, - nix_path=destination, - metadata={field_name: field_value}, - ) + if not field_value: + continue + destination = ENVIRONMENT_MAP[field_name] + metadata: dict[str, Any] + if field_name == "path_components": + metadata = {field_name: field_value} + else: + redacted_value, redacted_any = _redact_sensitive_dict_entries(field_value) + metadata = {field_name: redacted_value} + if redacted_any: + metadata["potentially_sensitive_entries_redacted"] = True + results.append( + ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=destination, + nix_path=destination, + metadata=metadata, ) + ) if config.frameworks: results.append( @@ -508,11 +548,15 @@ def classify_shell_setting(config: ShellConfig) -> list[ClassificationResult]: ) if config.dynamic_commands: + redacted_commands, redacted_any = _redact_sensitive_list_entries(config.dynamic_commands) + dynamic_metadata: dict[str, Any] = {"dynamic_commands": redacted_commands} + if redacted_any: + dynamic_metadata["potentially_sensitive_entries_redacted"] = True results.append( ClassificationResult( tier=ClassificationTier.MANUAL_REPORT, destination="manual report: dynamically-generated shell commands have no direct nix-darwin equivalent", - metadata={"dynamic_commands": config.dynamic_commands}, + metadata=dynamic_metadata, ) ) diff --git a/tests/mappings/test_classifier.py b/tests/mappings/test_classifier.py index c101304..469cede 100644 --- a/tests/mappings/test_classifier.py +++ b/tests/mappings/test_classifier.py @@ -222,6 +222,30 @@ def test_droppable_keys_are_stripped_from_raw_plist_metadata(self) -> None: assert "LegacyTimers" not in cleaned assert cleaned["RunAtLoad"] is True + def test_nested_raw_plist_values_are_deep_copied_not_shared_with_source(self) -> None: + """A shallow copy would leave nested dicts/lists shared with entry.raw_plist, + so mutating the classifier's output would silently corrupt the scanned source data. + """ + entry = self._entry( + "com.example.myagent", + LaunchAgentSource.USER, + raw_plist={ + "Label": "com.example.myagent", + "KeepAlive": {"SuccessfulExit": False}, + "StartCalendarInterval": [{"Hour": 9}], + }, + ) + result = classify_launch_agent(entry) + assert result.metadata is not None + cleaned = result.metadata["raw_plist"] + assert cleaned["KeepAlive"] is not entry.raw_plist["KeepAlive"] + assert cleaned["StartCalendarInterval"] is not entry.raw_plist["StartCalendarInterval"] + + cleaned["KeepAlive"]["SuccessfulExit"] = True + cleaned["StartCalendarInterval"][0]["Hour"] = 17 + assert entry.raw_plist["KeepAlive"]["SuccessfulExit"] is False + assert entry.raw_plist["StartCalendarInterval"][0]["Hour"] == 9 + class TestClassifyApp: def test_nixpkgs_app_routes_to_native_system_packages(self) -> None: @@ -425,3 +449,69 @@ def test_frameworks_and_dynamic_commands_route_to_manual_report(self) -> None: assert len(manual_results) == 2 assert any("framework" in result.destination for result in manual_results) assert any("dynamic" in result.destination.lower() for result in manual_results) + + +class TestClassifyShellSettingSensitiveRedaction: + def test_alias_with_embedded_secret_token_in_body_is_redacted(self) -> None: + config = ShellConfig( + shell_type="zsh", aliases={"awsprod": "AWS_SECRET_ACCESS_KEY=AKIAabc123 aws --profile prod"} + ) + results = classify_shell_setting(config) + aliases_result = next(r for r in results if r.destination == "environment.shellAliases") + assert aliases_result.metadata is not None + assert aliases_result.metadata["aliases"]["awsprod"] == "***REDACTED***" + assert aliases_result.metadata["potentially_sensitive_entries_redacted"] is True + + def test_alias_with_sensitive_looking_name_is_redacted(self) -> None: + config = ShellConfig(shell_type="zsh", aliases={"show_api_token": "cat ~/.myapp/config"}) + results = classify_shell_setting(config) + aliases_result = next(r for r in results if r.destination == "environment.shellAliases") + assert aliases_result.metadata is not None + assert aliases_result.metadata["aliases"]["show_api_token"] == "***REDACTED***" + + def test_non_sensitive_alias_is_not_redacted(self) -> None: + config = ShellConfig(shell_type="zsh", aliases={"ll": "ls -la"}) + results = classify_shell_setting(config) + aliases_result = next(r for r in results if r.destination == "environment.shellAliases") + assert aliases_result.metadata == {"aliases": {"ll": "ls -la"}} + + def test_env_var_with_sensitive_looking_value_is_redacted_even_with_innocuous_name(self) -> None: + config = ShellConfig(shell_type="zsh", env_vars={"MY_APP_CONFIG": "AWS_SECRET_ACCESS_KEY=AKIAabc123"}) + results = classify_shell_setting(config) + env_result = next(r for r in results if r.destination == "environment.variables") + assert env_result.metadata is not None + assert env_result.metadata["env_vars"]["MY_APP_CONFIG"] == "***REDACTED***" + assert env_result.metadata["potentially_sensitive_entries_redacted"] is True + + def test_env_var_with_sensitive_looking_name_is_redacted(self) -> None: + config = ShellConfig(shell_type="zsh", env_vars={"GITHUB_TOKEN": "ghp_abc123"}) + results = classify_shell_setting(config) + env_result = next(r for r in results if r.destination == "environment.variables") + assert env_result.metadata is not None + assert env_result.metadata["env_vars"]["GITHUB_TOKEN"] == "***REDACTED***" + + def test_non_sensitive_env_var_is_not_redacted(self) -> None: + config = ShellConfig(shell_type="zsh", env_vars={"EDITOR": "nvim"}) + results = classify_shell_setting(config) + env_result = next(r for r in results if r.destination == "environment.variables") + assert env_result.metadata == {"env_vars": {"EDITOR": "nvim"}} + + def test_dynamic_command_with_embedded_api_key_is_redacted(self) -> None: + config = ShellConfig(shell_type="zsh", dynamic_commands=["eval $(some-cli --api_key=abc123 init)"]) + results = classify_shell_setting(config) + dynamic_result = next(r for r in results if "dynamic" in r.destination.lower()) + assert dynamic_result.metadata is not None + assert dynamic_result.metadata["dynamic_commands"] == ["***REDACTED***"] + assert dynamic_result.metadata["potentially_sensitive_entries_redacted"] is True + + def test_non_sensitive_dynamic_command_is_not_redacted(self) -> None: + config = ShellConfig(shell_type="zsh", dynamic_commands=["eval $(starship init zsh)"]) + results = classify_shell_setting(config) + dynamic_result = next(r for r in results if "dynamic" in r.destination.lower()) + assert dynamic_result.metadata == {"dynamic_commands": ["eval $(starship init zsh)"]} + + def test_path_components_are_never_redacted(self) -> None: + config = ShellConfig(shell_type="zsh", path_components=["/opt/homebrew/bin"]) + results = classify_shell_setting(config) + path_result = next(r for r in results if r.destination == "environment.systemPath") + assert path_result.metadata == {"path_components": ["/opt/homebrew/bin"]} From 408ca48c9ea76157983270c2031ed49db764b7c6 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 20 Jul 2026 13:04:22 -0400 Subject: [PATCH 15/35] fix(mappings): narrow ephemeral timestamp-range check, patch sensitive-key gaps, normalize whitespace - ephemeral_filter: exclude keys ending in a quantity suffix (size, limit, count, bytes, length, capacity, max, min, buffer, quota, threshold) from the numeric timestamp-range check, since byte counts/limits can coincide with the Unix/CFAbsoluteTime ranges (e.g. DiskCacheSize=1073741824) - scanners/_utils: add _PASSPHRASE and _BEARER to SENSITIVE_KEY_PATTERNS so values like DOCKER_..._PASSPHRASE=hunter2 get redacted - app_to_package: collapse any whitespace run (not just literal spaces) into a single hyphen in normalize_app_name, fixing tab/newline pass-through and double-space double-hyphen bugs --- src/mac2nix/mappings/app_to_package.py | 3 +- src/mac2nix/mappings/ephemeral_filter.py | 38 ++++++++++++++++++++++-- src/mac2nix/scanners/_utils.py | 4 ++- tests/mappings/test_app_to_package.py | 9 ++++++ tests/mappings/test_classifier.py | 11 +++++++ tests/mappings/test_ephemeral_filter.py | 11 +++++++ 6 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/mac2nix/mappings/app_to_package.py b/src/mac2nix/mappings/app_to_package.py index f1ac71e..fd1e809 100644 --- a/src/mac2nix/mappings/app_to_package.py +++ b/src/mac2nix/mappings/app_to_package.py @@ -6,6 +6,7 @@ from dataclasses import dataclass _VERSION_SUFFIX_RE = re.compile(r"\s+\d+(\.\d+)*$") +_WHITESPACE_RE = re.compile(r"\s+") _NAME_OVERRIDES: dict[str, str] = { "zoom.us": "zoom", @@ -34,7 +35,7 @@ def normalize_app_name(name: str) -> str: normalized = _VERSION_SUFFIX_RE.sub("", normalized).strip().lower() if normalized in _NAME_OVERRIDES: return _NAME_OVERRIDES[normalized] - return normalized.replace(" ", "-") + return _WHITESPACE_RE.sub("-", normalized) def classify_app(name: str) -> AppClassification | None: diff --git a/src/mac2nix/mappings/ephemeral_filter.py b/src/mac2nix/mappings/ephemeral_filter.py index 2ae3481..e713763 100644 --- a/src/mac2nix/mappings/ephemeral_filter.py +++ b/src/mac2nix/mappings/ephemeral_filter.py @@ -67,6 +67,27 @@ } ) +# CamelCase-tokenized (lowercased) suffixes indicating a numeric quantity +# rather than a timestamp. Keys ending in one of these are exempt from +# _is_timestamp_value_range even when the value happens to fall in a +# timestamp-like numeric range (e.g. DiskCacheSize=1073741824 is a 1GB byte +# count, not a date). +_QUANTITY_KEY_SUFFIXES: frozenset[str] = frozenset( + { + "size", + "limit", + "count", + "bytes", + "length", + "capacity", + "max", + "min", + "buffer", + "quota", + "threshold", + } +) + _UNIX_TIMESTAMP_MIN = 946_684_800 # 2000-01-01 _UNIX_TIMESTAMP_MAX = 2_208_988_800 # 2040-01-01 _CFABSOLUTE_TIME_MIN = 100_000_000 # ~2004, seconds since 2001-01-01 @@ -111,6 +132,11 @@ def _is_timestamp_key(key: str) -> bool: return any(len(tokens) >= width and "".join(tokens[-width:]) in _TIMESTAMP_KEY_SUFFIXES for width in widths) +def _is_quantity_key(key: str) -> bool: + tokens = _tokenize(key) + return bool(tokens) and tokens[-1] in _QUANTITY_KEY_SUFFIXES + + def _is_uuid_string(text: str) -> bool: return bool(_UUID_RE.match(text)) or bool(_HASHED_ID_RE.match(text)) @@ -170,7 +196,6 @@ def _is_ui_geometry_value(value: Any) -> bool: ) _VALUE_PREDICATES: tuple[Any, ...] = ( - _is_timestamp_value_range, _is_date_string_value, _is_uuid_value, _is_binary_data_sentinel, @@ -186,10 +211,19 @@ def is_ephemeral(key: str, value: Any) -> bool: ``WebKitCacheModel``, ``DiskCacheSize``, and ``CachePolicy`` would be wrongly filtered. A cache key is only caught here when its *value* is independently timestamp-like (via ``_is_timestamp_value_range`` or - ``_is_date_string_value``), never from the key name alone. + ``_is_date_string_value``), never from the key name alone. Conversely, a + key ending in a numeric-quantity word (see ``_QUANTITY_KEY_SUFFIXES``: + size, limit, count, bytes, length, capacity, max, min, buffer, quota, + threshold) is exempt from ``_is_timestamp_value_range`` even when its + value happens to fall in a timestamp-like numeric range, since that's a + byte count or limit landing in the Unix-epoch range by coincidence, not + an actual timestamp. Opaque keys without such a suffix are still caught + by the value-range check. """ if any(predicate(key) for predicate in _KEY_PREDICATES): return True + if _is_timestamp_value_range(value) and not _is_quantity_key(key): + return True return any(predicate(value) for predicate in _VALUE_PREDICATES) diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index d42d563..0dab082 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -288,7 +288,9 @@ def parallel_walk_dirs[T]( ] -SENSITIVE_KEY_PATTERNS = frozenset({"_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH"}) +SENSITIVE_KEY_PATTERNS = frozenset( + {"_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH", "_PASSPHRASE", "_BEARER"} +) def redact_sensitive_keys(data: dict[str, Any]) -> None: diff --git a/tests/mappings/test_app_to_package.py b/tests/mappings/test_app_to_package.py index 15b4f89..33c24bf 100644 --- a/tests/mappings/test_app_to_package.py +++ b/tests/mappings/test_app_to_package.py @@ -44,6 +44,15 @@ def test_strips_trailing_version_number(self) -> None: def test_leading_digit_in_name_is_preserved(self) -> None: assert normalize_app_name("1Password.app") == "1password" + def test_double_space_collapses_to_single_hyphen(self) -> None: + assert normalize_app_name("Google Chrome.app") == "google-chrome" + + def test_tab_whitespace_is_treated_as_space(self) -> None: + assert normalize_app_name("Fire\tfox.app") == "fire-fox" + + def test_newline_whitespace_is_treated_as_space(self) -> None: + assert normalize_app_name("Fire\nfox.app") == "fire-fox" + class TestClassifyAppNixpkgs: def test_firefox(self) -> None: diff --git a/tests/mappings/test_classifier.py b/tests/mappings/test_classifier.py index 469cede..ed9f314 100644 --- a/tests/mappings/test_classifier.py +++ b/tests/mappings/test_classifier.py @@ -462,6 +462,17 @@ def test_alias_with_embedded_secret_token_in_body_is_redacted(self) -> None: assert aliases_result.metadata["aliases"]["awsprod"] == "***REDACTED***" assert aliases_result.metadata["potentially_sensitive_entries_redacted"] is True + def test_alias_with_embedded_passphrase_in_body_is_redacted(self) -> None: + config = ShellConfig( + shell_type="zsh", + aliases={"deploy": "DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE=hunter2 docker push"}, + ) + results = classify_shell_setting(config) + aliases_result = next(r for r in results if r.destination == "environment.shellAliases") + assert aliases_result.metadata is not None + assert aliases_result.metadata["aliases"]["deploy"] == "***REDACTED***" + assert aliases_result.metadata["potentially_sensitive_entries_redacted"] is True + def test_alias_with_sensitive_looking_name_is_redacted(self) -> None: config = ShellConfig(shell_type="zsh", aliases={"show_api_token": "cat ~/.myapp/config"}) results = classify_shell_setting(config) diff --git a/tests/mappings/test_ephemeral_filter.py b/tests/mappings/test_ephemeral_filter.py index f5c92e2..0708e72 100644 --- a/tests/mappings/test_ephemeral_filter.py +++ b/tests/mappings/test_ephemeral_filter.py @@ -83,6 +83,17 @@ def test_bool_is_not_treated_as_numeric_timestamp(self) -> None: def test_out_of_range_large_int_is_not_filtered(self) -> None: assert is_ephemeral("FileSizeBytes", 5_000_000_000) is False + def test_disk_cache_size_in_timestamp_range_is_not_filtered(self) -> None: + """1GB byte count lands in the Unix timestamp range by coincidence.""" + assert is_ephemeral("DiskCacheSize", 1_073_741_824) is False + + def test_memory_limit_in_timestamp_range_is_not_filtered(self) -> None: + assert is_ephemeral("MemoryLimit", 1_073_741_824) is False + + def test_opaque_key_with_timestamp_value_is_still_filtered(self) -> None: + """Keys with no quantity or time-related suffix still rely on value-range detection.""" + assert is_ephemeral("SomeInternalField", 1_700_000_000) is True + class TestDateStringValue: def test_date_only_is_filtered(self) -> None: From 4b18c6e3b6faf77d553f19244fda671bcf21a00f Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 24 Jul 2026 14:37:14 -0400 Subject: [PATCH 16/35] fix(mappings): patch preference aliasing bug and broaden redaction Deep-copies the shared DEFAULTS_TO_NIX conditions dict before attaching it to a classification result's metadata, since a shared reference let one result's mutation corrupt every future lookup. Also fixes the tier-2 preferences destination check to use Path.is_relative_to() instead of a naive startswith() string comparison, which could match sibling directories with a shared prefix. Broadens sensitive-value detection with a camelCase/delimiter-aware tokenizer so redaction catches concepts split across boundaries (e.g. authToken, Bearer ...) in addition to underscore-delimited patterns, and extends the precheck to scan preference values (recursively for nested dict/list) in addition to keys. Adds missing app_to_package entries for Linear.app and Ice.app, which previously fell through to "unrecognized app" despite being named in the architect plan. --- src/mac2nix/mappings/app_to_package.py | 2 + src/mac2nix/mappings/classifier.py | 59 ++++++++++- tests/mappings/test_app_config_registry.py | 22 ++-- tests/mappings/test_app_to_package.py | 8 ++ tests/mappings/test_classifier.py | 111 +++++++++++++++++++++ tests/mappings/test_dotfile_to_hm.py | 8 ++ tests/mappings/test_non_defaults_to_nix.py | 31 ++++++ tests/scanners/test_library_scanner.py | 16 +++ 8 files changed, 241 insertions(+), 16 deletions(-) diff --git a/src/mac2nix/mappings/app_to_package.py b/src/mac2nix/mappings/app_to_package.py index fd1e809..cdf1c78 100644 --- a/src/mac2nix/mappings/app_to_package.py +++ b/src/mac2nix/mappings/app_to_package.py @@ -118,6 +118,8 @@ def classify_app(name: str) -> AppClassification | None: "alt-tab": AppClassification("cask", "alt-tab"), "hiddenbar": AppClassification("cask", "hiddenbar"), "keepingyouawake": AppClassification("cask", "keepingyouawake"), + "linear-linear": AppClassification("cask", "linear-linear"), + "jordanbaird-ice": AppClassification("cask", "jordanbaird-ice"), # appstore (Mac App Store exclusive) "xcode": AppClassification("appstore", "Xcode"), "bear": AppClassification("appstore", "Bear"), diff --git a/src/mac2nix/mappings/classifier.py b/src/mac2nix/mappings/classifier.py index 67961f7..e3e1fa0 100644 --- a/src/mac2nix/mappings/classifier.py +++ b/src/mac2nix/mappings/classifier.py @@ -93,9 +93,55 @@ class ClassificationResult: } +# Mirrors ephemeral_filter.py's camelCase/non-alnum tokenizer, duplicated locally so this +# module's only dependency on ephemeral_filter stays its public is_ephemeral() API. +_CAMEL_TOKEN_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z0-9]+|[A-Z0-9]+") +_NON_ALNUM_RE = re.compile(r"[^A-Za-z0-9]+") + +# Bare-word complement to SENSITIVE_KEY_PATTERNS' underscore-delimited substrings: catches +# the same concepts split across camelCase boundaries or plain-text delimiters instead of +# underscores (e.g. "authToken=...", "Authorization: Bearer ..."). "key" is deliberately +# excluded -- unlike the other words here, it's an overloaded, common word in identifiers +# that have nothing to do with secrets (sort key, primary key, hot key, dictionary key, +# "SomeUnknownKey" as a generic placeholder name, a preference literally named "Key"), and +# no tokenization heuristic reliably separates "ApiKey" from "PrimaryKey" or "SortKey". +# SENSITIVE_KEY_PATTERNS' underscore-anchored "_KEY" already covers the safe SNAKE_CASE +# form. Bare high-entropy secrets with no accompanying keyword at all are not detectable by +# any keyword list and are out of scope here. +_SENSITIVE_TOKENS: frozenset[str] = frozenset( + {"token", "secret", "password", "credential", "auth", "passphrase", "bearer"} +) + + +def _tokenize(text: str) -> list[str]: + tokens: list[str] = [] + for segment in _NON_ALNUM_RE.split(text): + if segment: + tokens.extend(match.lower() for match in _CAMEL_TOKEN_RE.findall(segment)) + return tokens + + def _contains_sensitive_pattern(text: str) -> bool: upper = text.upper() - return any(pattern in upper for pattern in SENSITIVE_KEY_PATTERNS) + if any(pattern in upper for pattern in SENSITIVE_KEY_PATTERNS): + return True + return any(token in _SENSITIVE_TOKENS for token in _tokenize(text)) + + +def _value_contains_sensitive_pattern(value: Any) -> bool: + """Recursively check a (possibly nested) preference value for a sensitive pattern. + + PreferenceValue permits arbitrarily nested dict/list structures (e.g. a plist value + that is itself a dict with a "refresh_token" key) -- a flat top-level check would miss + those entirely. + """ + if isinstance(value, str): + return _contains_sensitive_pattern(value) + if isinstance(value, dict): + return any(_contains_sensitive_pattern(k) or _value_contains_sensitive_pattern(v) for k, v in value.items()) + if isinstance(value, list): + return any(_value_contains_sensitive_pattern(item) for item in value) + return False def _redact_sensitive_dict_entries(entries: dict[str, str]) -> tuple[dict[str, str], bool]: @@ -139,7 +185,7 @@ def _preferences_tier2_destination(domain: PreferencesDomain) -> str: cfprefsd-only domains (source_path is None) default to CustomUserPreferences -- most such domains reflect per-user cached settings with no system-wide plist. """ - if domain.source_path is not None and not str(domain.source_path).startswith(str(Path.home())): + if domain.source_path is not None and not domain.source_path.is_relative_to(Path.home()): return "CustomSystemPreferences" return "CustomUserPreferences" @@ -148,13 +194,13 @@ def _classify_preference_precheck( domain: PreferencesDomain, key: str, value: PreferenceValue ) -> ClassificationResult | None: """SEC-1/SEC-3 gating checks plus ephemeral-noise filtering, run before any tier routing.""" - if _contains_sensitive_pattern(key): + if _contains_sensitive_pattern(key) or _value_contains_sensitive_pattern(value): return ClassificationResult( tier=ClassificationTier.MANUAL_REPORT, destination=f"manual report: key '{key}' in domain '{domain.domain_name}' matches a sensitive pattern", metadata={ "potentially_sensitive": True, - "reason": "key name matches sensitive pattern", + "reason": "key or value matches a sensitive pattern", "domain": domain.domain_name, "key": key, "value": "***REDACTED***", @@ -214,7 +260,10 @@ def classify_preference(domain: PreferencesDomain, key: str, value: PreferenceVa if tier_override is None: metadata: dict[str, Any] = {"nix_type": option.nix_type} if conditions: - metadata["conditions"] = conditions + # Deep-copy: `conditions` is the same dict object stored in the + # module-level DEFAULTS_TO_NIX table -- mutating this result's + # metadata in place would otherwise corrupt every future lookup. + metadata["conditions"] = copy.deepcopy(conditions) return ClassificationResult( tier=ClassificationTier.NATIVE, destination=option.nix_path, diff --git a/tests/mappings/test_app_config_registry.py b/tests/mappings/test_app_config_registry.py index f46c696..e682f82 100644 --- a/tests/mappings/test_app_config_registry.py +++ b/tests/mappings/test_app_config_registry.py @@ -9,56 +9,56 @@ class TestGetAppConfig: - def test_known_bundle_id(self): + def test_known_bundle_id(self) -> None: info = get_app_config("com.apple.Safari") assert info is not None assert info.file_type == ConfigFileType.DATABASE assert info.scannable is False - def test_unknown_bundle_id_returns_none(self): + def test_unknown_bundle_id_returns_none(self) -> None: assert get_app_config("com.example.NotARealApp") is None - def test_case_sensitive_lookup(self): + def test_case_sensitive_lookup(self) -> None: assert get_app_config("com.apple.safari") is None class TestAppConfigRegistry: - def test_registry_has_minimum_entries(self): + def test_registry_has_minimum_entries(self) -> None: assert len(APP_CONFIG_REGISTRY) >= 20 - def test_all_entries_are_app_config_info(self): + def test_all_entries_are_app_config_info(self) -> None: for info in APP_CONFIG_REGISTRY.values(): assert isinstance(info, AppConfigInfo) assert len(info.config_paths) > 0 - def test_safari_history_is_database_and_not_scannable(self): + def test_safari_history_is_database_and_not_scannable(self) -> None: info = APP_CONFIG_REGISTRY["com.apple.Safari"] assert info.file_type == ConfigFileType.DATABASE assert info.scannable is False assert info.config_paths == ("~/Library/Safari/History.db",) - def test_vscode_settings_are_json_and_scannable(self): + def test_vscode_settings_are_json_and_scannable(self) -> None: info = APP_CONFIG_REGISTRY["com.microsoft.VSCode"] assert info.file_type == ConfigFileType.JSON assert info.scannable is True assert "~/Library/Application Support/Code/User/settings.json" in info.config_paths - def test_spotify_prefs_are_conf(self): + def test_spotify_prefs_are_conf(self) -> None: info = APP_CONFIG_REGISTRY["com.spotify.client"] assert info.file_type == ConfigFileType.CONF assert info.scannable is True - def test_at_least_one_database_entry_not_scannable(self): + def test_at_least_one_database_entry_not_scannable(self) -> None: database_entries = [info for info in APP_CONFIG_REGISTRY.values() if info.file_type == ConfigFileType.DATABASE] assert len(database_entries) >= 1 assert all(not info.scannable for info in database_entries) - def test_docker_settings_are_json(self): + def test_docker_settings_are_json(self) -> None: info = APP_CONFIG_REGISTRY["com.docker.docker"] assert info.file_type == ConfigFileType.JSON assert info.config_paths == ("~/Library/Group Containers/group.com.docker/settings.json",) - def test_notes_default_to_none(self): + def test_notes_default_to_none(self) -> None: info = AppConfigInfo(config_paths=("~/foo",), file_type=ConfigFileType.UNKNOWN) assert info.notes is None assert info.scannable is True diff --git a/tests/mappings/test_app_to_package.py b/tests/mappings/test_app_to_package.py index 33c24bf..d89c4be 100644 --- a/tests/mappings/test_app_to_package.py +++ b/tests/mappings/test_app_to_package.py @@ -24,9 +24,17 @@ def test_github_desktop_gotcha(self) -> None: def test_linear_gotcha(self) -> None: assert normalize_app_name("Linear.app") == "linear-linear" + classification = classify_app("Linear.app") + assert classification is not None + assert classification.source == "cask" + assert classification.package_name == "linear-linear" def test_ice_gotcha(self) -> None: assert normalize_app_name("Ice.app") == "jordanbaird-ice" + classification = classify_app("Ice.app") + assert classification is not None + assert classification.source == "cask" + assert classification.package_name == "jordanbaird-ice" def test_parallels_desktop_gotcha(self) -> None: assert normalize_app_name("Parallels Desktop.app") == "parallels" diff --git a/tests/mappings/test_classifier.py b/tests/mappings/test_classifier.py index ed9f314..6f7758d 100644 --- a/tests/mappings/test_classifier.py +++ b/tests/mappings/test_classifier.py @@ -16,6 +16,7 @@ classify_shell_setting, classify_system_setting, ) +from mac2nix.mappings.defaults_to_nix import get_nix_option from mac2nix.models.application import AppSource, BrewFormula, InstalledApp from mac2nix.models.files import DotfileEntry, DotfileManager, FontEntry, FontSource from mac2nix.models.preferences import PreferencesDomain @@ -60,6 +61,25 @@ def test_dock_persistent_apps_routes_to_tier_override_not_native(self) -> None: assert result.metadata["native_nix_path_available"] == "system.defaults.dock.persistent-apps" +class TestClassifyPreferenceConditionsMetadata: + def test_conditions_metadata_is_deep_copied_not_shared_with_defaults_to_nix_table(self) -> None: + """A shallow reference would let mutating one result's metadata corrupt the + shared DEFAULTS_TO_NIX table for every future lookup of the same option. + """ + domain = _domain("com.apple.finder", {"NewWindowTargetPath": "/Users/foo"}) + result = classify_preference(domain, "NewWindowTargetPath", "/Users/foo") + assert result.tier == ClassificationTier.NATIVE + assert result.metadata is not None + conditions = result.metadata["conditions"] + assert conditions == {"requires": {"NewWindowTarget": "Other"}} + + conditions["requires"]["NewWindowTarget"] = "mutated" + + option = get_nix_option("com.apple.finder", "NewWindowTargetPath") + assert option is not None + assert option.conditions == {"requires": {"NewWindowTarget": "Other"}} + + class TestClassifyPreferenceUnmappedKey: def test_unmapped_key_in_user_domain_routes_to_custom_user_preferences(self) -> None: domain = _domain( @@ -87,6 +107,16 @@ def test_cfprefsd_domain_with_no_source_path_defaults_to_user_preferences(self) assert result.tier == ClassificationTier.CUSTOM_PREFS assert result.destination == "CustomUserPreferences" + def test_sibling_directory_sharing_home_string_prefix_is_not_misclassified_as_user(self) -> None: + """A naive str.startswith(home) check would treat /Users/X/... as + under $HOME purely because of the shared string prefix, not a real path boundary. + """ + sibling = Path.home().parent / (Path.home().name + "X") / "Library/Preferences/com.example.someapp.plist" + domain = _domain("com.example.someapp", {"SomeSetting": "value"}, source_path=sibling) + result = classify_preference(domain, "SomeSetting", "value") + assert result.tier == ClassificationTier.CUSTOM_PREFS + assert result.destination == "CustomSystemPreferences" + class TestClassifyPreferenceSensitiveKeyRedaction: def test_key_matching_sensitive_pattern_routes_to_manual_report_redacted(self) -> None: @@ -113,6 +143,59 @@ def test_non_sensitive_key_is_not_redacted(self) -> None: or (result.metadata or {}).get("potentially_sensitive") is not True ) + def test_bare_or_compound_key_word_is_not_redacted(self) -> None: + """ "Key" (bare or as part of a compound identifier) must not be flagged on its + own -- it's an overloaded word in non-sensitive identifiers too (sort key, primary + key, a generic placeholder name like "SomeUnknownKey"), unlike "token"/"secret"/ + "password"/etc. SENSITIVE_KEY_PATTERNS' underscore-anchored "_KEY" still covers the + unambiguous SNAKE_CASE form (e.g. "API_KEY"). + """ + for key in ("Key", "SomeUnknownKey", "ApiKey", "PrimaryKey"): + domain = _domain("com.example.someapp", {key: 1}) + result = classify_preference(domain, key, 1) + assert ( + result.tier != ClassificationTier.MANUAL_REPORT + or (result.metadata or {}).get("potentially_sensitive") is not True + ), f"{key!r} should not be flagged as sensitive" + + def test_sensitive_value_with_innocuous_key_is_redacted(self) -> None: + """SEC-1 must check the value, not just the key -- credentials routinely appear + inline in values under a generic key name. + """ + domain = _domain("com.example.someapp", {"LastRequestLog": "Authorization: Bearer ghp_abc123xyz"}) + result = classify_preference(domain, "LastRequestLog", "Authorization: Bearer ghp_abc123xyz") + assert result.tier == ClassificationTier.MANUAL_REPORT + assert result.metadata is not None + assert result.metadata["value"] == "***REDACTED***" + + def test_camelcase_compound_word_is_redacted(self) -> None: + domain = _domain("com.example.someapp", {"authToken": "sk-live-abc123"}) + result = classify_preference(domain, "authToken", "sk-live-abc123") + assert result.tier == ClassificationTier.MANUAL_REPORT + + def test_sensitive_value_nested_one_level_in_dict_is_redacted(self) -> None: + """A nested credential must not be invisible to the gate just because it sits + one level inside a dict-valued preference rather than at the top level. + """ + domain = _domain("com.example.someapp", {"AccountState": {"username": "alice", "refresh_token": "sk-live"}}) + result = classify_preference(domain, "AccountState", {"username": "alice", "refresh_token": "sk-live"}) + assert result.tier == ClassificationTier.MANUAL_REPORT + assert result.metadata is not None + assert result.metadata["value"] == "***REDACTED***" + + def test_sensitive_value_nested_in_list_is_redacted(self) -> None: + domain = _domain("com.example.someapp", {"History": [{"note": "ok"}, {"password": "hunter2"}]}) + result = classify_preference(domain, "History", [{"note": "ok"}, {"password": "hunter2"}]) + assert result.tier == ClassificationTier.MANUAL_REPORT + + def test_non_sensitive_nested_dict_is_not_redacted(self) -> None: + domain = _domain("com.example.someapp", {"AccountState": {"username": "alice", "displayName": "Alice"}}) + result = classify_preference(domain, "AccountState", {"username": "alice", "displayName": "Alice"}) + assert ( + result.tier != ClassificationTier.MANUAL_REPORT + or (result.metadata or {}).get("potentially_sensitive") is not True + ) + class TestClassifyPreferenceBinarySentinel: def test_binary_sentinel_routes_to_activation_script_not_dropped(self) -> None: @@ -135,6 +218,18 @@ def test_malformed_sentinel_like_string_is_not_treated_as_binary(self) -> None: result = classify_preference(domain, "Description", "128 bytes") assert result.tier != ClassificationTier.ACTIVATION_SCRIPT + def test_sensitive_key_precheck_wins_over_binary_sentinel(self) -> None: + """The sensitive-key check must run before the binary-sentinel check -- + otherwise a credential stored as a plist blob under a sensitive-looking + key would leak into an ACTIVATION_SCRIPT's metadata instead of being redacted. + """ + domain = _domain("com.example.someapp", {"API_TOKEN": ""}) + result = classify_preference(domain, "API_TOKEN", "") + assert result.tier == ClassificationTier.MANUAL_REPORT + assert result.metadata is not None + assert result.metadata["potentially_sensitive"] is True + assert result.metadata["value"] == "***REDACTED***" + class TestClassifyPreferenceEphemeralSkip: def test_ephemeral_value_is_skipped_not_reported(self) -> None: @@ -343,6 +438,22 @@ def test_formula_with_hm_module_gets_metadata_hint(self) -> None: assert result.metadata is not None assert result.metadata["hm_module_available"] == "programs.git" + def test_formula_with_note_gets_metadata_note(self) -> None: + formula = BrewFormula(name="docker") + result = classify_brew_formula(formula) + assert result.tier == ClassificationTier.NATIVE + assert result.nix_path == "pkgs.docker-client" + assert result.metadata is not None + assert result.metadata["note"] == ( + "Full pkgs.docker is Linux-only; docker-client is the Darwin client-only equivalent" + ) + + def test_formula_without_note_has_no_note_key(self) -> None: + formula = BrewFormula(name="node") + result = classify_brew_formula(formula) + assert result.metadata is not None + assert "note" not in result.metadata + class TestClassifyFont: def test_known_font_routes_to_native_fonts_packages(self) -> None: diff --git a/tests/mappings/test_dotfile_to_hm.py b/tests/mappings/test_dotfile_to_hm.py index 730e900..32e4f7f 100644 --- a/tests/mappings/test_dotfile_to_hm.py +++ b/tests/mappings/test_dotfile_to_hm.py @@ -73,3 +73,11 @@ def test_trailing_slash_is_stripped(self) -> None: def test_unrecognized_path_returns_none(self) -> None: assert get_hm_program("~/.config/does-not-exist/config.toml") is None assert get_hm_program("/some/unrelated/path") is None + + def test_home_directory_itself_returns_none(self, tmp_path: Path) -> None: + """The home directory itself normalizes to "~", which is never a DOTFILE_TO_HM + key -- included for completeness of the normalization path, not because this is + a realistic scanner input. + """ + with patch("mac2nix.mappings.dotfile_to_hm.Path.home", return_value=tmp_path): + assert get_hm_program(tmp_path) is None diff --git a/tests/mappings/test_non_defaults_to_nix.py b/tests/mappings/test_non_defaults_to_nix.py index 2c1e2ba..b9ccb8c 100644 --- a/tests/mappings/test_non_defaults_to_nix.py +++ b/tests/mappings/test_non_defaults_to_nix.py @@ -1,5 +1,8 @@ """Tests for non_defaults_to_nix mapping.""" +import pytest + +from mac2nix.mappings import non_defaults_to_nix from mac2nix.mappings.non_defaults_to_nix import ( ENVIRONMENT_MAP, LAUNCHD_KEYS_TO_DROP, @@ -73,6 +76,34 @@ def test_unmatched_label_returns_none(self) -> None: def test_all_patterns_are_registered(self) -> None: assert len(LAUNCHD_LABEL_TO_SERVICE) >= 25 + def test_first_matching_pattern_wins_when_patterns_overlap(self, monkeypatch: pytest.MonkeyPatch) -> None: + """LAUNCHD_LABEL_TO_SERVICE's own comment states specific patterns must be listed + before broad wildcards so the more precise match is tried first. No pair of + patterns in the production table currently overlaps in a way that would expose an + ordering bug, so this locks in get_launchd_service's underlying iteration-order + semantics directly, independent of the current (accidentally non-conflicting) data. + """ + monkeypatch.setattr( + non_defaults_to_nix, + "LAUNCHD_LABEL_TO_SERVICE", + [ + ("com.example.specific.exact", "services.specific"), + ("*example*", "services.broad-fallback"), + ], + ) + assert get_launchd_service("com.example.specific.exact") == "services.specific" + assert get_launchd_service("com.example.other") == "services.broad-fallback" + + monkeypatch.setattr( + non_defaults_to_nix, + "LAUNCHD_LABEL_TO_SERVICE", + [ + ("*example*", "services.broad-fallback"), + ("com.example.specific.exact", "services.specific"), + ], + ) + assert get_launchd_service("com.example.specific.exact") == "services.broad-fallback" + class TestLaunchdKeysToDrop: def test_known_unmappable_keys_are_droppable(self) -> None: diff --git a/tests/scanners/test_library_scanner.py b/tests/scanners/test_library_scanner.py index 16bbe58..bc222e5 100644 --- a/tests/scanners/test_library_scanner.py +++ b/tests/scanners/test_library_scanner.py @@ -856,6 +856,22 @@ def test_case_insensitive_match(self) -> None: redacted = "***REDACTED***" assert data["my_auth_header"] == redacted + def test_redacts_bearer_key(self) -> None: + data = {"SESSION_BEARER": "xyz", "name": "test"} + redact_sensitive_keys(data) + + redacted = "***REDACTED***" + assert data["SESSION_BEARER"] == redacted + assert data["name"] == "test" + + def test_redacts_passphrase_key(self) -> None: + data = {"VAULT_PASSPHRASE": "xyz", "name": "test"} + redact_sensitive_keys(data) + + redacted = "***REDACTED***" + assert data["VAULT_PASSPHRASE"] == redacted + assert data["name"] == "test" + def test_no_sensitive_keys(self) -> None: data = {"name": "test", "count": 42} redact_sensitive_keys(data) From d1678cdf8b63435d8be46c3d5eeee1b7d64ecb02 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 24 Jul 2026 15:12:41 -0400 Subject: [PATCH 17/35] feat(cli): add scan_report module for per-scanner status and log capture --- src/mac2nix/scan_report.py | 99 +++++++++++++++++++++++ tests/test_scan_report.py | 156 +++++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 src/mac2nix/scan_report.py create mode 100644 tests/test_scan_report.py diff --git a/src/mac2nix/scan_report.py b/src/mac2nix/scan_report.py new file mode 100644 index 0000000..6a88257 --- /dev/null +++ b/src/mac2nix/scan_report.py @@ -0,0 +1,99 @@ +"""Per-scanner status, log capture, and remediation hints for the scan CLI.""" + +from __future__ import annotations + +import enum +import logging +import re +import threading +from collections import defaultdict +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass + + +class ScannerStatus(enum.Enum): + SUCCESS = "success" + WARNING = "warning" + ERROR = "error" + SKIPPED = "skipped" + + +@dataclass(frozen=True, slots=True) +class ScannerOutcome: + name: str + status: ScannerStatus + elapsed: float + warnings: tuple[str, ...] = () + error: str | None = None + + +_current_scanner: ContextVar[str | None] = ContextVar("_current_scanner", default=None) + +_REMEDIATION_HINTS: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile(r"Permission denied reading plist"), + "Grant Full Disk Access to your terminal in System Settings → Privacy & " + "Security → Full Disk Access, then re-run the scan.", + ), + ( + re.compile(r"(?=.*brew)(?=.*(?:timed out|bundle dump))", re.IGNORECASE), + "Try running `brew bundle dump --file=-` manually to see what's slow or failing.", + ), +) + + +class _ScannerLogCapture(logging.Handler): + """Buffers WARNING+ log records, attributed to the scanner active when logged.""" + + def __init__(self) -> None: + super().__init__(level=logging.WARNING) + self.records: dict[str, list[str]] = defaultdict(list) + self.unattributed: list[str] = [] + self._lock = threading.Lock() + + def emit(self, record: logging.LogRecord) -> None: + scanner_name = _current_scanner.get() + message = self.format(record) + with self._lock: + if scanner_name is None: + self.unattributed.append(message) + else: + self.records[scanner_name].append(message) + + def pop_records(self, name: str) -> list[str]: + with self._lock: + return self.records.pop(name, []) + + +@contextmanager +def capture_scanner_logs() -> Iterator[_ScannerLogCapture]: + """Attach a `_ScannerLogCapture` to the `mac2nix` logger for the duration of a scan.""" + handler = _ScannerLogCapture() + logger = logging.getLogger("mac2nix") + logger.addHandler(handler) + logger.propagate = False + try: + yield handler + finally: + logger.removeHandler(handler) + logger.propagate = True + + +@contextmanager +def attribute_to_scanner(name: str) -> Iterator[None]: + """Attribute any WARNING+ log records emitted within this block to `name`.""" + token = _current_scanner.set(name) + try: + yield + finally: + _current_scanner.reset(token) + + +def get_remediation_hint(message: str) -> str | None: + """Return a remediation hint for a scanner warning/error message, if one applies.""" + for pattern, hint in _REMEDIATION_HINTS: + if pattern.search(message): + return hint + return None diff --git a/tests/test_scan_report.py b/tests/test_scan_report.py new file mode 100644 index 0000000..e8297e0 --- /dev/null +++ b/tests/test_scan_report.py @@ -0,0 +1,156 @@ +"""Tests for the scan_report module: outcome data model, log capture, remediation hints.""" + +from __future__ import annotations + +import logging +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from mac2nix.scan_report import ( + ScannerOutcome, + ScannerStatus, + attribute_to_scanner, + capture_scanner_logs, + get_remediation_hint, +) + + +class TestScannerOutcome: + def test_construction_with_defaults(self) -> None: + outcome = ScannerOutcome(name="shell", status=ScannerStatus.SUCCESS, elapsed=0.1) + + assert outcome.name == "shell" + assert outcome.status == ScannerStatus.SUCCESS + assert outcome.elapsed == 0.1 + assert outcome.warnings == () + assert outcome.error is None + + def test_construction_with_warnings_and_error(self) -> None: + outcome = ScannerOutcome( + name="homebrew", + status=ScannerStatus.ERROR, + elapsed=1.5, + warnings=("warn 1", "warn 2"), + error="RuntimeError: boom", + ) + + assert outcome.warnings == ("warn 1", "warn 2") + assert outcome.error == "RuntimeError: boom" + + def test_is_frozen(self) -> None: + outcome = ScannerOutcome(name="shell", status=ScannerStatus.SUCCESS, elapsed=0.1) + + with pytest.raises(AttributeError): + outcome.name = "other" # type: ignore[misc] + + +class TestCaptureScannerLogsConcurrency: + def test_isolates_concurrent_scanners_warnings(self) -> None: + barrier = threading.Barrier(2) + + def _run(name: str, message: str) -> None: + barrier.wait() + with attribute_to_scanner(name): + logging.getLogger(f"mac2nix.scanners.{name}").warning(message) + + with capture_scanner_logs() as handler, ThreadPoolExecutor(max_workers=2) as executor: + futures = [ + executor.submit(_run, "fake_a", "warn A"), + executor.submit(_run, "fake_b", "warn B"), + ] + for future in futures: + future.result() + + assert handler.pop_records("fake_a") == ["warn A"] + assert handler.pop_records("fake_b") == ["warn B"] + + +class TestUnattributedWarnings: + def test_warning_without_attribution_lands_in_unattributed(self) -> None: + with capture_scanner_logs() as handler: + logging.getLogger("mac2nix.orchestrator").warning("unattributed warning") + + assert handler.unattributed == ["unattributed warning"] + assert handler.records == {} + + def test_unattributed_warning_never_attributed_to_a_scanner(self) -> None: + with capture_scanner_logs() as handler: + with attribute_to_scanner("fake_a"): + logging.getLogger("mac2nix.scanners.fake_a").warning("warn A") + logging.getLogger("mac2nix.orchestrator").warning("unattributed warning") + + assert handler.unattributed == ["unattributed warning"] + assert handler.pop_records("fake_a") == ["warn A"] + + +class TestPopRecords: + def test_pop_records_clears_entry(self) -> None: + with capture_scanner_logs() as handler: + with attribute_to_scanner("fake_a"): + logging.getLogger("mac2nix.scanners.fake_a").warning("warn A") + + assert handler.pop_records("fake_a") == ["warn A"] + assert handler.pop_records("fake_a") == [] + + def test_pop_records_missing_scanner_returns_empty(self) -> None: + with capture_scanner_logs() as handler: + assert handler.pop_records("never_ran") == [] + + +class TestCaptureScannerLogsTeardown: + def test_propagate_restored_after_exception(self) -> None: + logger = logging.getLogger("mac2nix") + + def _raise_inside_capture() -> None: + with capture_scanner_logs(): + assert logger.propagate is False + msg = "boom" + raise RuntimeError(msg) + + with pytest.raises(RuntimeError, match="boom"): + _raise_inside_capture() + + assert logger.propagate is True + + def test_handler_removed_after_exception(self) -> None: + logger = logging.getLogger("mac2nix") + handlers_before = list(logger.handlers) + + def _raise_inside_capture() -> None: + with capture_scanner_logs(): + msg = "boom" + raise RuntimeError(msg) + + with pytest.raises(RuntimeError, match="boom"): + _raise_inside_capture() + + assert logger.handlers == handlers_before + + +class TestGetRemediationHint: + def test_plist_permission_denied_pattern(self) -> None: + hint = get_remediation_hint("Permission denied reading plist: /Library/Preferences/x.plist") + + assert hint is not None + assert "Full Disk Access" in hint + + def test_brew_timeout_message_from_run_command(self) -> None: + message = "Command timed out after 30s: ['brew', 'bundle', 'dump', '--file=-']" + + hint = get_remediation_hint(message) + + assert hint is not None + assert "brew bundle dump" in hint + + def test_brew_bundle_dump_message_from_homebrew_scanner(self) -> None: + message = "Unable to enumerate Homebrew state via 'brew bundle dump'" + + hint = get_remediation_hint(message) + + assert hint is not None + assert "brew bundle dump" in hint + + def test_negative_case_returns_none(self) -> None: + assert get_remediation_hint("some unrelated message") is None From 10fbaae9e61329cf1dd2eb2fcbd674b43b204356 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 24 Jul 2026 15:12:47 -0400 Subject: [PATCH 18/35] feat(cli): classify scanner outcomes as success/warning/error/skipped --- src/mac2nix/orchestrator.py | 62 ++++++++++-- tests/test_orchestrator.py | 190 +++++++++++++++++++++++++++++++++++- 2 files changed, 241 insertions(+), 11 deletions(-) diff --git a/src/mac2nix/orchestrator.py b/src/mac2nix/orchestrator.py index 46fb31b..452f413 100644 --- a/src/mac2nix/orchestrator.py +++ b/src/mac2nix/orchestrator.py @@ -8,6 +8,7 @@ import platform import shutil import socket +import time from collections.abc import Callable from pathlib import Path from typing import Any @@ -15,6 +16,7 @@ from pydantic import BaseModel from mac2nix.models.system_state import SystemState +from mac2nix.scan_report import ScannerOutcome, ScannerStatus, _ScannerLogCapture, attribute_to_scanner from mac2nix.scanners import get_all_scanners from mac2nix.scanners._utils import read_launchd_plists, run_command from mac2nix.scanners.audio import AudioScanner @@ -74,43 +76,80 @@ async def _run_scanner_async( scanner_name: str, scanner_cls: type, kwargs: dict[str, Any], - progress_callback: Callable[[str], None] | None, + progress_callback: Callable[[ScannerOutcome], None] | None, + log_handler: _ScannerLogCapture | None, ) -> tuple[str, BaseModel | None]: """Dispatch a single scanner in a thread and return (name, result).""" + start = time.monotonic() + # Pre-initialized in case a BaseException (e.g. asyncio.CancelledError) skips + # the `except Exception` clause below, leaving the finally block's read safe. + status = ScannerStatus.ERROR + warnings: tuple[str, ...] = () + error: str | None = None try: scanner = scanner_cls(**kwargs) if not scanner.is_available(): logger.info("Scanner '%s' not available — skipping", scanner_name) + status = ScannerStatus.SKIPPED return scanner_name, None - result: BaseModel = await asyncio.to_thread(scanner.scan) + with attribute_to_scanner(scanner_name): + result: BaseModel = await asyncio.to_thread(scanner.scan) logger.debug("Scanner '%s' completed", scanner_name) + + records = log_handler.pop_records(scanner_name) if log_handler is not None else [] + if records: + status = ScannerStatus.WARNING + warnings = tuple(records) + else: + status = ScannerStatus.SUCCESS return scanner_name, result - except Exception: + except Exception as exc: logger.exception("Scanner '%s' raised an exception", scanner_name) + status = ScannerStatus.ERROR + error = f"{type(exc).__name__}: {exc}" + if log_handler is not None: + warnings = tuple(log_handler.pop_records(scanner_name)) return scanner_name, None finally: + elapsed = time.monotonic() - start + outcome = ScannerOutcome( + name=scanner_name, + status=status, + elapsed=elapsed, + warnings=warnings, + error=error, + ) # Safe: this runs on the event loop thread (after the await), not the # worker thread, so the callback sees serialised access. if progress_callback is not None: try: - progress_callback(scanner_name) + progress_callback(outcome) except Exception: logger.debug("Progress callback failed for '%s'", scanner_name) async def run_scan( scanners: list[str] | None = None, - progress_callback: Callable[[str], None] | None = None, + progress_callback: Callable[[ScannerOutcome], None] | None = None, + log_handler: _ScannerLogCapture | None = None, ) -> SystemState: """Run all (or selected) scanners concurrently and return a SystemState. Args: scanners: List of scanner names to run. ``None`` runs all registered scanners. Unknown names are silently ignored. - progress_callback: Optional callable invoked with the scanner name after - each scanner completes (or is skipped). Suitable for updating a - progress bar. Called from the asyncio event loop thread. + progress_callback: Optional callable invoked with a ``ScannerOutcome`` + after each scanner completes, is skipped, or errors. Suitable for + updating a progress bar. Called from the asyncio event loop thread. + log_handler: Optional ``_ScannerLogCapture`` used to attribute each + scanner's WARNING+ log records to its ``ScannerOutcome``. This + function does not create or attach the handler itself — the caller + (``cli.py``) owns the ``capture_scanner_logs()`` context and passes + the handler in so it can also inspect ``.unattributed`` (warnings + logged during the pre-fetch phase, before any scanner started) once + this function returns. ``None`` disables warning attribution; + outcomes will never be classified as ``WARNING`` in that case. Returns: Populated :class:`~mac2nix.models.system_state.SystemState`. @@ -134,7 +173,7 @@ async def run_scan( continue tasks.append( asyncio.create_task( - _run_scanner_async(name, cls, {}, progress_callback), + _run_scanner_async(name, cls, {}, progress_callback, log_handler), name=f"scanner-{name}", ) ) @@ -156,6 +195,7 @@ async def run_scan( DisplayScanner, {"prefetched_data": batched_sp}, progress_callback, + log_handler, ), name="scanner-display", ) @@ -168,6 +208,7 @@ async def run_scan( AudioScanner, {"prefetched_data": batched_sp}, progress_callback, + log_handler, ), name="scanner-audio", ) @@ -180,6 +221,7 @@ async def run_scan( SystemScanner, {"prefetched_data": batched_sp}, progress_callback, + log_handler, ), name="scanner-system", ) @@ -192,6 +234,7 @@ async def run_scan( LaunchAgentsScanner, {"launchd_plists": launchd_plists}, progress_callback, + log_handler, ), name="scanner-launch_agents", ) @@ -204,6 +247,7 @@ async def run_scan( CronScanner, {"launchd_plists": launchd_plists}, progress_callback, + log_handler, ), name="scanner-cron", ) diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 84135d3..4843b0b 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -13,6 +14,7 @@ from mac2nix.models.services import LaunchAgentsResult, ScheduledTasks from mac2nix.models.system_state import SystemState from mac2nix.orchestrator import _fetch_system_profiler_batch, _get_system_metadata, run_scan +from mac2nix.scan_report import ScannerOutcome, ScannerStatus, capture_scanner_logs # --------------------------------------------------------------------------- # Helpers @@ -39,6 +41,27 @@ def scan(self) -> Any: return _MockScanner +def _make_warning_scanner(name: str, logger_name: str, message: str) -> type: + """Create a mock scanner whose scan() logs a warning under `logger_name` then succeeds.""" + + class _WarningScanner: + def __init__(self, **_kwargs: object) -> None: + pass + + @property + def name(self) -> str: + return name + + def is_available(self) -> bool: + return True + + def scan(self) -> _FakeResult: + logging.getLogger(logger_name).warning(message) + return _FakeResult() + + return _WarningScanner + + # --------------------------------------------------------------------------- # _get_system_metadata # --------------------------------------------------------------------------- @@ -222,7 +245,7 @@ def test_progress_callback_called_for_each_scanner(self) -> None: patch("mac2nix.orchestrator._fetch_system_profiler_batch", return_value={}), patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), ): - asyncio.run(run_scan(progress_callback=called.append)) + asyncio.run(run_scan(progress_callback=lambda outcome: called.append(outcome.name))) assert sorted(called) == sorted(["_fake_a", "_fake_b"]) @@ -237,7 +260,7 @@ def test_progress_callback_called_for_unavailable(self) -> None: patch("mac2nix.orchestrator._fetch_system_profiler_batch", return_value={}), patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), ): - asyncio.run(run_scan(progress_callback=called.append)) + asyncio.run(run_scan(progress_callback=lambda outcome: called.append(outcome.name))) assert "_fake" in called @@ -440,3 +463,166 @@ def scan(self) -> _FakeResult: assert "_fake_selected" in called assert "_fake_other" not in called + + +# --------------------------------------------------------------------------- +# ScannerOutcome classification (status, warnings, error attribution) +# --------------------------------------------------------------------------- + + +class TestOutcomeClassification: + def test_outcome_success_for_clean_scanner(self) -> None: + registry = {"_fake_clean": _make_minimal_scanner("_fake_clean", _FakeResult())} + outcomes: dict[str, ScannerOutcome] = {} + + with ( + patch("mac2nix.orchestrator.get_all_scanners", return_value=registry), + patch("mac2nix.orchestrator._get_system_metadata", return_value=("host", "14.0", "arm64")), + patch("mac2nix.orchestrator._fetch_system_profiler_batch", return_value={}), + patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), + capture_scanner_logs() as log_handler, + ): + asyncio.run( + run_scan( + progress_callback=lambda outcome: outcomes.__setitem__(outcome.name, outcome), + log_handler=log_handler, + ) + ) + + outcome = outcomes["_fake_clean"] + assert outcome.status == ScannerStatus.SUCCESS + assert outcome.warnings == () + assert outcome.error is None + + def test_outcome_warning_when_scanner_logs_warning(self) -> None: + registry = {"_fake_warn": _make_warning_scanner("_fake_warn", "mac2nix.scanners.fake", "disk full")} + outcomes: dict[str, ScannerOutcome] = {} + + with ( + patch("mac2nix.orchestrator.get_all_scanners", return_value=registry), + patch("mac2nix.orchestrator._get_system_metadata", return_value=("host", "14.0", "arm64")), + patch("mac2nix.orchestrator._fetch_system_profiler_batch", return_value={}), + patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), + capture_scanner_logs() as log_handler, + ): + asyncio.run( + run_scan( + progress_callback=lambda outcome: outcomes.__setitem__(outcome.name, outcome), + log_handler=log_handler, + ) + ) + + outcome = outcomes["_fake_warn"] + assert outcome.status == ScannerStatus.WARNING + assert any("disk full" in warning for warning in outcome.warnings) + + def test_outcome_error_when_scanner_raises(self) -> None: + class _CrashingScanner: + def __init__(self, **_kwargs: object) -> None: + pass + + @property + def name(self) -> str: + return "_fake_crash" + + def is_available(self) -> bool: + return True + + def scan(self) -> Any: + msg = "boom" + raise RuntimeError(msg) + + registry: dict[str, type] = {"_fake_crash": _CrashingScanner} + outcomes: dict[str, ScannerOutcome] = {} + + with ( + patch("mac2nix.orchestrator.get_all_scanners", return_value=registry), + patch("mac2nix.orchestrator._get_system_metadata", return_value=("host", "14.0", "arm64")), + patch("mac2nix.orchestrator._fetch_system_profiler_batch", return_value={}), + patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), + capture_scanner_logs() as log_handler, + ): + asyncio.run( + run_scan( + progress_callback=lambda outcome: outcomes.__setitem__(outcome.name, outcome), + log_handler=log_handler, + ) + ) + + outcome = outcomes["_fake_crash"] + assert outcome.status == ScannerStatus.ERROR + assert outcome.error is not None + assert "RuntimeError: boom" in outcome.error + + def test_outcome_skipped_for_unavailable_scanner(self) -> None: + registry = {"_fake_skip": _make_minimal_scanner("_fake_skip", _FakeResult(), available=False)} + outcomes: dict[str, ScannerOutcome] = {} + + with ( + patch("mac2nix.orchestrator.get_all_scanners", return_value=registry), + patch("mac2nix.orchestrator._get_system_metadata", return_value=("host", "14.0", "arm64")), + patch("mac2nix.orchestrator._fetch_system_profiler_batch", return_value={}), + patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), + capture_scanner_logs() as log_handler, + ): + asyncio.run( + run_scan( + progress_callback=lambda outcome: outcomes.__setitem__(outcome.name, outcome), + log_handler=log_handler, + ) + ) + + outcome = outcomes["_fake_skip"] + assert outcome.status == ScannerStatus.SKIPPED + + def test_outcome_warnings_isolated_between_concurrent_scanners(self) -> None: + """Regression test: contextvar attribution must not leak between concurrently-dispatched scanners.""" + registry = { + "_fake_x": _make_warning_scanner("_fake_x", "mac2nix.scanners.fake_x", "warning from x"), + "_fake_y": _make_warning_scanner("_fake_y", "mac2nix.scanners.fake_y", "warning from y"), + } + outcomes: dict[str, ScannerOutcome] = {} + + with ( + patch("mac2nix.orchestrator.get_all_scanners", return_value=registry), + patch("mac2nix.orchestrator._get_system_metadata", return_value=("host", "14.0", "arm64")), + patch("mac2nix.orchestrator._fetch_system_profiler_batch", return_value={}), + patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), + capture_scanner_logs() as log_handler, + ): + asyncio.run( + run_scan( + progress_callback=lambda outcome: outcomes.__setitem__(outcome.name, outcome), + log_handler=log_handler, + ) + ) + + outcome_x = outcomes["_fake_x"] + outcome_y = outcomes["_fake_y"] + assert outcome_x.status == ScannerStatus.WARNING + assert outcome_y.status == ScannerStatus.WARNING + assert any("warning from x" in warning for warning in outcome_x.warnings) + assert not any("warning from y" in warning for warning in outcome_x.warnings) + assert any("warning from y" in warning for warning in outcome_y.warnings) + assert not any("warning from x" in warning for warning in outcome_y.warnings) + + def test_unattributed_prefetch_warning_captured(self) -> None: + """Warnings logged during the prefetch phase (before any scanner is attributed) land in `.unattributed`.""" + + def _fake_fetch() -> dict[str, Any]: + logging.getLogger("mac2nix.orchestrator").warning("prefetch hiccup") + return {} + + registry = {"display": _make_minimal_scanner("display", DisplayConfig())} + + with ( + patch("mac2nix.orchestrator.get_all_scanners", return_value=registry), + patch("mac2nix.orchestrator.DisplayScanner", registry["display"]), + patch("mac2nix.orchestrator._get_system_metadata", return_value=("host", "14.0", "arm64")), + patch("mac2nix.orchestrator._fetch_system_profiler_batch", side_effect=_fake_fetch), + patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), + capture_scanner_logs() as log_handler, + ): + asyncio.run(run_scan(scanners=["display"], log_handler=log_handler)) + + assert any("prefetch hiccup" in warning for warning in log_handler.unattributed) From b1b4fe7e8c2e6746990761166d41b2575ea78620 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 24 Jul 2026 15:12:51 -0400 Subject: [PATCH 19/35] feat(cli): render live per-scanner table with inline warnings and remediation hints --- src/mac2nix/cli.py | 126 ++++++++++++++++++++++++++++------- tests/test_cli.py | 161 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 261 insertions(+), 26 deletions(-) diff --git a/src/mac2nix/cli.py b/src/mac2nix/cli.py index 107b2c6..563fcad 100644 --- a/src/mac2nix/cli.py +++ b/src/mac2nix/cli.py @@ -5,19 +5,82 @@ import asyncio import time import uuid +from collections.abc import Sequence from pathlib import Path import click from rich.console import Console -from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn +from rich.live import Live +from rich.spinner import Spinner +from rich.table import Table +from rich.text import Text from mac2nix.models.system_state import SystemState from mac2nix.orchestrator import run_scan +from mac2nix.scan_report import ScannerOutcome, ScannerStatus, capture_scanner_logs, get_remediation_hint from mac2nix.scanners import get_all_scanners from mac2nix.vm.discovery import DiscoveryRunner from mac2nix.vm.manager import TartVMManager from mac2nix.vm.validator import Validator +_STATUS_ICONS: dict[ScannerStatus, tuple[str, str]] = { + ScannerStatus.SUCCESS: ("✓", "green"), + ScannerStatus.WARNING: ("⚠", "yellow"), + ScannerStatus.ERROR: ("✗", "red"), + ScannerStatus.SKIPPED: ("⊘", "dim"), +} + +_TALLY_LABELS: dict[ScannerStatus, str] = { + ScannerStatus.SUCCESS: "success", + ScannerStatus.WARNING: "warning", + ScannerStatus.ERROR: "error", + ScannerStatus.SKIPPED: "skipped", +} + + +def _status_icon(status: ScannerStatus) -> tuple[str, str]: + """Return (icon, style) for a scanner's status.""" + return _STATUS_ICONS[status] + + +def _build_scan_table(outcomes: dict[str, ScannerOutcome], order: Sequence[str]) -> Table: + """Render one row per scanner in `order`, with sub-rows for warnings/errors.""" + table = Table(box=None, show_header=False, padding=(0, 1)) + table.add_column(width=2, justify="center") + table.add_column() + table.add_column(justify="right") + table.add_column() + + for name in order: + outcome = outcomes.get(name) + if outcome is None: + table.add_row(Spinner("dots"), name, "", "") + continue + + icon, style = _status_icon(outcome.status) + detail = "" + if outcome.status is ScannerStatus.WARNING: + detail = f"{len(outcome.warnings)} warning(s)" + elif outcome.status is ScannerStatus.ERROR: + detail = "error" + + table.add_row(Text(icon, style=style), name, f"{outcome.elapsed:.1f}s", detail) + + if outcome.status in (ScannerStatus.WARNING, ScannerStatus.ERROR) and outcome.warnings: + for warning in outcome.warnings: + table.add_row("", Text(f" ↳ {warning}", style="dim"), "", "") + hint = get_remediation_hint(warning) + if hint is not None: + table.add_row("", Text(f" → {hint}", style="dim italic"), "", "") + + if outcome.status is ScannerStatus.ERROR and outcome.error is not None: + table.add_row("", Text(f" ↳ {outcome.error}", style="dim"), "", "") + hint = get_remediation_hint(outcome.error) + if hint is not None: + table.add_row("", Text(f" → {hint}", style="dim italic"), "", "") + + return table + @click.group() @click.version_option() @@ -54,37 +117,50 @@ def scan(output: Path | None, selected_scanners: tuple[str, ...]) -> None: available = ", ".join(sorted(all_names)) raise click.UsageError(f"Unknown scanner(s): {', '.join(unknown)}. Available: {available}") - total = len(scanners) if scanners is not None else len(all_names) + scanner_names: list[str] = scanners if scanners is not None else all_names - completed: int = 0 + outcomes: dict[str, ScannerOutcome] = {} start = time.monotonic() - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - BarColumn(), - MofNCompleteColumn(), - TimeElapsedColumn(), - console=Console(stderr=True), - transient=True, - redirect_stdout=False, - redirect_stderr=False, - ) as progress: - task_id = progress.add_task("Scanning...", total=total) - - def progress_callback(name: str) -> None: - nonlocal completed - completed += 1 - progress.advance(task_id) - progress.update(task_id, description=f"[bold cyan]{name}[/] done") + with ( + capture_scanner_logs() as log_handler, + Live( + _build_scan_table(outcomes, scanner_names), + console=Console(stderr=True), + refresh_per_second=8, + transient=False, + ) as live, + ): + + def progress_callback(outcome: ScannerOutcome) -> None: + outcomes[outcome.name] = outcome + live.update(_build_scan_table(outcomes, scanner_names)) try: - state = asyncio.run(run_scan(scanners=scanners, progress_callback=progress_callback)) + state = asyncio.run( + run_scan(scanners=scanners, progress_callback=progress_callback, log_handler=log_handler) + ) except RuntimeError as e: raise click.ClickException(str(e)) from e elapsed = time.monotonic() - start - scanner_count = completed + + if log_handler.unattributed: + click.echo("General warnings:", err=True) + for warning in log_handler.unattributed: + click.echo(f" {warning}", err=True) + hint = get_remediation_hint(warning) + if hint is not None: + click.echo(f" → {hint}", err=True) + + tally_counts: dict[ScannerStatus, int] = {} + for outcome in outcomes.values(): + tally_counts[outcome.status] = tally_counts.get(outcome.status, 0) + 1 + tally = ", ".join( + f"{tally_counts[status]} {_TALLY_LABELS[status]}" + for status in (ScannerStatus.SUCCESS, ScannerStatus.WARNING, ScannerStatus.ERROR, ScannerStatus.SKIPPED) + if tally_counts.get(status, 0) > 0 + ) json_output = state.to_json() @@ -92,12 +168,12 @@ def progress_callback(name: str) -> None: output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json_output) click.echo( - f"Scanned {scanner_count} scanner(s) in {elapsed:.1f}s — wrote {output}", + f"Scanned {len(outcomes)} scanner(s) in {elapsed:.1f}s ({tally}) — wrote {output}", err=True, ) else: click.echo( - f"Scanned {scanner_count} scanner(s) in {elapsed:.1f}s", + f"Scanned {len(outcomes)} scanner(s) in {elapsed:.1f}s ({tally})", err=True, ) click.echo(json_output) diff --git a/tests/test_cli.py b/tests/test_cli.py index ffa132f..ecc80c3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,9 +9,11 @@ import pytest from click.testing import CliRunner +from rich.console import Console -from mac2nix.cli import main +from mac2nix.cli import _build_scan_table, _status_icon, main from mac2nix.models.system_state import SystemState +from mac2nix.scan_report import ScannerOutcome, ScannerStatus # --------------------------------------------------------------------------- # Helpers @@ -36,6 +38,12 @@ def _extract_json(output: str) -> str: return output[start:] +def _render_table(outcomes: dict[str, ScannerOutcome], order: list[str], width: int = 100) -> str: + console = Console(record=True, width=width) + console.print(_build_scan_table(outcomes, order)) + return console.export_text() + + # --------------------------------------------------------------------------- # CLI command registration # --------------------------------------------------------------------------- @@ -261,3 +269,154 @@ def test_summary_shown_after_writing_to_file(self, tmp_path: Path) -> None: assert result.exit_code == 0 assert output_file.exists() + + +# --------------------------------------------------------------------------- +# _status_icon / _build_scan_table +# --------------------------------------------------------------------------- + + +class TestStatusIcon: + def test_skipped_is_visually_distinct_from_success(self) -> None: + assert _status_icon(ScannerStatus.SKIPPED) != _status_icon(ScannerStatus.SUCCESS) + + +class TestBuildScanTablePending: + def test_scanner_with_no_outcome_yet_still_shows_its_name(self) -> None: + """Regression test: previously a pending scanner (e.g. "applications") never appeared.""" + text = _render_table({}, ["applications"]) + + assert "applications" in text + + +class TestBuildScanTableSuccess: + def test_success_row_has_no_warning_or_error_detail_and_no_sub_rows(self) -> None: + outcomes = {"shell": ScannerOutcome(name="shell", status=ScannerStatus.SUCCESS, elapsed=0.3)} + + text = _render_table(outcomes, ["shell"]) + + assert "shell" in text + assert "warning" not in text.lower() + assert "error" not in text.lower() + assert "↳" not in text + + +class TestBuildScanTableWarning: + def test_warning_row_shows_short_detail_with_full_warning_and_hint_as_sub_rows(self) -> None: + message = "Permission denied reading plist: /Library/Preferences/x.plist" + outcomes = { + "preferences": ScannerOutcome( + name="preferences", + status=ScannerStatus.WARNING, + elapsed=1.2, + warnings=(message,), + ) + } + + # wide console avoids line-wrapping the hint, so substring checks are exact + text = _render_table(outcomes, ["preferences"], width=200) + lines = text.splitlines() + main_row = next(line for line in lines if "preferences" in line) + + assert "1 warning(s)" in main_row + assert message not in main_row + assert message in text + assert "Grant Full Disk Access" in text + + +class TestBuildScanTableError: + def test_error_row_shows_short_detail_and_full_message_once_in_sub_row(self) -> None: + message = "RuntimeError: boom failure" + outcomes = { + "homebrew": ScannerOutcome( + name="homebrew", + status=ScannerStatus.ERROR, + elapsed=0.5, + error=message, + ) + } + + text = _render_table(outcomes, ["homebrew"], width=200) + lines = text.splitlines() + main_row = next(line for line in lines if "homebrew" in line) + + assert "error" in main_row + assert message not in main_row + assert text.count(message) == 1 + + +class TestBuildScanTableSkipped: + def test_skipped_row_renders_distinctly_from_success_row(self) -> None: + outcomes = { + "docker": ScannerOutcome(name="docker", status=ScannerStatus.SKIPPED, elapsed=0.0), + "shell": ScannerOutcome(name="shell", status=ScannerStatus.SUCCESS, elapsed=0.1), + } + + text = _render_table(outcomes, ["docker", "shell"]) + lines = text.splitlines() + docker_row = next(line for line in lines if "docker" in line) + shell_row = next(line for line in lines if "shell" in line) + + skipped_icon, _ = _status_icon(ScannerStatus.SKIPPED) + success_icon, _ = _status_icon(ScannerStatus.SUCCESS) + assert skipped_icon in docker_row + assert success_icon in shell_row + assert skipped_icon != success_icon + + +# --------------------------------------------------------------------------- +# Live table integration: summary tally and general warnings +# --------------------------------------------------------------------------- + + +class TestScanCommandSummaryTally: + def test_summary_line_includes_status_tally(self) -> None: + runner = CliRunner() + state = _make_state() + + async def fake_run_scan( + scanners: list[str] | None = None, + progress_callback: Any = None, + log_handler: Any = None, + ) -> SystemState: + if progress_callback is not None: + progress_callback(ScannerOutcome(name="shell", status=ScannerStatus.SUCCESS, elapsed=0.1)) + progress_callback( + ScannerOutcome( + name="preferences", + status=ScannerStatus.WARNING, + elapsed=0.2, + warnings=("some warning",), + ) + ) + return state + + with patch("mac2nix.cli.run_scan", new=fake_run_scan): + result = runner.invoke(main, ["scan"]) + + assert result.exit_code == 0 + assert "1 success" in result.output + assert "1 warning" in result.output + + +class TestScanCommandGeneralWarnings: + def test_unattributed_warnings_shown_as_general_warnings_section(self) -> None: + runner = CliRunner() + state = _make_state() + unattributed_message = "prefetch warning: system_profiler slow to respond" + + async def fake_run_scan( + scanners: list[str] | None = None, + progress_callback: Any = None, + log_handler: Any = None, + ) -> SystemState: + if log_handler is not None: + log_handler.unattributed.append(unattributed_message) + return state + + with patch("mac2nix.cli.run_scan", new=fake_run_scan): + result = runner.invoke(main, ["scan"]) + + assert result.exit_code == 0 + assert "General warnings" in result.output + assert unattributed_message in result.output From d05e3cfb1ceab0a7da2f59408451a6df8328e1e7 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 24 Jul 2026 15:13:04 -0400 Subject: [PATCH 20/35] fix(scanners): log non-zero exits in run_command, fix misleading homebrew message --- src/mac2nix/scanners/_utils.py | 7 +++++-- src/mac2nix/scanners/homebrew.py | 10 ++++++++-- tests/scanners/test_homebrew.py | 34 ++++++++++++++++++++++++++++++++ tests/scanners/test_utils.py | 16 +++++++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index 0dab082..7016103 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -333,7 +333,7 @@ def run_command( """Run a subprocess command safely. Validates that the executable exists before running. Never uses shell=True. - Returns None on any failure (command not found, non-zero exit, timeout). + Returns None if the executable is not found or on timeout. """ executable = cmd[0] if shutil.which(executable) is None: @@ -342,7 +342,10 @@ def run_command( logger.debug("Running command: %s", cmd) try: - return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) # noqa: S603 + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) # noqa: S603 + if result.returncode != 0: + logger.warning("Command exited %d: %s\nstderr: %s", result.returncode, cmd, result.stderr.strip()) + return result except subprocess.TimeoutExpired: logger.warning("Command timed out after %ds: %s", timeout, cmd) return None diff --git a/src/mac2nix/scanners/homebrew.py b/src/mac2nix/scanners/homebrew.py index f9c8db5..89e0659 100644 --- a/src/mac2nix/scanners/homebrew.py +++ b/src/mac2nix/scanners/homebrew.py @@ -68,8 +68,14 @@ def _parse_brewfile( mas_apps: list[MasApp] = [] result = run_command(["brew", "bundle", "dump", "--file=-"]) - if result is None or result.returncode != 0: - logger.warning("brew bundle dump failed or brew not available") + if result is None: + # run_command() already logged the specific cause (timeout or + # executable disappeared mid-run) -- nothing more to add here. + return taps, formulae, casks, mas_apps + if result.returncode != 0: + # run_command() already logged the exit code and stderr (Step 1) -- add + # homebrew-specific framing only. + logger.warning("Unable to enumerate Homebrew state via 'brew bundle dump'") return taps, formulae, casks, mas_apps for raw_line in result.stdout.splitlines(): diff --git a/tests/scanners/test_homebrew.py b/tests/scanners/test_homebrew.py index 10c748a..ba115be 100644 --- a/tests/scanners/test_homebrew.py +++ b/tests/scanners/test_homebrew.py @@ -1,9 +1,12 @@ """Tests for Homebrew scanner.""" import json +import logging from pathlib import Path from unittest.mock import patch +import pytest + from mac2nix.models.application import HomebrewState from mac2nix.scanners.homebrew import HomebrewScanner @@ -126,6 +129,37 @@ def test_brew_command_fails(self) -> None: assert result.formulae == [] assert result.casks == [] + def test_parse_brewfile_none_result_does_not_log_brew_not_available(self, caplog: pytest.LogCaptureFixture) -> None: + with ( + patch("mac2nix.scanners.homebrew.run_command", return_value=None), + caplog.at_level(logging.WARNING, logger="mac2nix.scanners.homebrew"), + ): + taps, formulae, casks, mas_apps = HomebrewScanner()._parse_brewfile() + + assert taps == [] + assert formulae == [] + assert casks == [] + assert mas_apps == [] + assert not any("brew not available" in r.message for r in caplog.records) + + def test_parse_brewfile_nonzero_exit_logs_corrected_message( + self, cmd_result, caplog: pytest.LogCaptureFixture + ) -> None: + with ( + patch( + "mac2nix.scanners.homebrew.run_command", + return_value=cmd_result("", stderr="boom", returncode=1), + ), + caplog.at_level(logging.WARNING, logger="mac2nix.scanners.homebrew"), + ): + taps, formulae, casks, mas_apps = HomebrewScanner()._parse_brewfile() + + assert taps == [] + assert formulae == [] + assert casks == [] + assert mas_apps == [] + assert any(r.message == "Unable to enumerate Homebrew state via 'brew bundle dump'" for r in caplog.records) + def test_skips_comments_and_blanks(self, cmd_result) -> None: brewfile = '# Comment line\n\ntap "homebrew/core"\n' with patch( diff --git a/tests/scanners/test_utils.py b/tests/scanners/test_utils.py index 094ed95..835227d 100644 --- a/tests/scanners/test_utils.py +++ b/tests/scanners/test_utils.py @@ -1,5 +1,6 @@ """Tests for scanner utility functions.""" +import logging import plistlib import subprocess from datetime import UTC, datetime @@ -7,6 +8,8 @@ from unittest.mock import patch from xml.etree import ElementTree +import pytest + from mac2nix.scanners._utils import ( _parse_xml_dict, _parse_xml_value, @@ -54,6 +57,19 @@ def test_run_command_timeout(self) -> None: assert result is None + def test_run_command_logs_warning_on_nonzero_exit(self, caplog: pytest.LogCaptureFixture) -> None: + with ( + patch("mac2nix.scanners._utils.shutil.which", return_value="/usr/bin/false"), + patch("mac2nix.scanners._utils.subprocess.run") as mock_run, + caplog.at_level(logging.WARNING, logger="mac2nix.scanners._utils"), + ): + mock_run.return_value = subprocess.CompletedProcess(args=["false"], returncode=1, stdout="", stderr="boom") + result = run_command(["false"]) + + assert result is not None + assert result.returncode == 1 + assert any("1" in r.message and "boom" in r.message for r in caplog.records) + def test_run_command_file_not_found(self) -> None: with ( patch("mac2nix.scanners._utils.shutil.which", return_value="/usr/bin/gone"), From 8b7ac33046a520d36f4da5452d57887d3b4899d7 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 24 Jul 2026 15:45:38 -0400 Subject: [PATCH 21/35] fix(cli): attribute crash logs correctly, suppress npm's benign nonzero-exit warning Widens attribute_to_scanner()'s scope in _run_scanner_async to cover the whole scanner lifecycle (construction, is_available, scan, and exception handling), not just the scan() call itself. Previously a crashing scanner's logger.exception() call fired after the attribution context had already exited, landing the traceback in the unattributed bucket and duplicating it under "General warnings" alongside the scanner's own error row. Also adds a warn_on_nonzero opt-out to run_command() (default True, preserving existing behavior everywhere else) and applies it to npm's package-listing call, which routinely exits non-zero on peer-dependency warnings while still producing valid output on stdout -- a known, benign case that would otherwise show as a false WARNING status. --- src/mac2nix/orchestrator.py | 48 ++++++++++--------- src/mac2nix/scanners/_utils.py | 7 ++- .../scanners/package_managers_scanner.py | 2 +- tests/scanners/test_package_managers.py | 24 ++++++++++ tests/scanners/test_utils.py | 13 +++++ tests/test_cli.py | 19 ++++++++ tests/test_orchestrator.py | 43 +++++++++++++++++ 7 files changed, 132 insertions(+), 24 deletions(-) diff --git a/src/mac2nix/orchestrator.py b/src/mac2nix/orchestrator.py index 452f413..6932491 100644 --- a/src/mac2nix/orchestrator.py +++ b/src/mac2nix/orchestrator.py @@ -87,30 +87,34 @@ async def _run_scanner_async( warnings: tuple[str, ...] = () error: str | None = None try: - scanner = scanner_cls(**kwargs) - if not scanner.is_available(): - logger.info("Scanner '%s' not available — skipping", scanner_name) - status = ScannerStatus.SKIPPED - return scanner_name, None - + # Covers the whole lifecycle (construction, is_available, scan, and the + # exception handler below) so any WARNING+ log emitted anywhere in it is + # attributed to this scanner, not left `unattributed` once the block exits. with attribute_to_scanner(scanner_name): - result: BaseModel = await asyncio.to_thread(scanner.scan) - logger.debug("Scanner '%s' completed", scanner_name) + try: + scanner = scanner_cls(**kwargs) + if not scanner.is_available(): + logger.info("Scanner '%s' not available — skipping", scanner_name) + status = ScannerStatus.SKIPPED + return scanner_name, None + + result: BaseModel = await asyncio.to_thread(scanner.scan) + logger.debug("Scanner '%s' completed", scanner_name) - records = log_handler.pop_records(scanner_name) if log_handler is not None else [] - if records: - status = ScannerStatus.WARNING - warnings = tuple(records) - else: - status = ScannerStatus.SUCCESS - return scanner_name, result - except Exception as exc: - logger.exception("Scanner '%s' raised an exception", scanner_name) - status = ScannerStatus.ERROR - error = f"{type(exc).__name__}: {exc}" - if log_handler is not None: - warnings = tuple(log_handler.pop_records(scanner_name)) - return scanner_name, None + records = log_handler.pop_records(scanner_name) if log_handler is not None else [] + if records: + status = ScannerStatus.WARNING + warnings = tuple(records) + else: + status = ScannerStatus.SUCCESS + return scanner_name, result + except Exception as exc: + logger.exception("Scanner '%s' raised an exception", scanner_name) + status = ScannerStatus.ERROR + error = f"{type(exc).__name__}: {exc}" + if log_handler is not None: + warnings = tuple(log_handler.pop_records(scanner_name)) + return scanner_name, None finally: elapsed = time.monotonic() - start outcome = ScannerOutcome( diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index 7016103..6061cdb 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -329,11 +329,16 @@ def run_command( cmd: list[str], *, timeout: int = 30, + warn_on_nonzero: bool = True, ) -> subprocess.CompletedProcess[str] | None: """Run a subprocess command safely. Validates that the executable exists before running. Never uses shell=True. Returns None if the executable is not found or on timeout. + + Set warn_on_nonzero=False for commands known to exit non-zero in benign + cases (e.g. npm's peer-dependency warnings) so callers can inspect stdout + themselves without generating a false-alarm WARNING log. """ executable = cmd[0] if shutil.which(executable) is None: @@ -343,7 +348,7 @@ def run_command( logger.debug("Running command: %s", cmd) try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) # noqa: S603 - if result.returncode != 0: + if warn_on_nonzero and result.returncode != 0: logger.warning("Command exited %d: %s\nstderr: %s", result.returncode, cmd, result.stderr.strip()) return result except subprocess.TimeoutExpired: diff --git a/src/mac2nix/scanners/package_managers_scanner.py b/src/mac2nix/scanners/package_managers_scanner.py index 1d6dc62..3474abd 100644 --- a/src/mac2nix/scanners/package_managers_scanner.py +++ b/src/mac2nix/scanners/package_managers_scanner.py @@ -352,7 +352,7 @@ def _detect_npm_global(self) -> NpmGlobalState: @staticmethod def _get_npm_global_packages() -> list[LanguagePackage]: - result = run_command(["npm", "list", "-g", "--json", "--depth=0"], timeout=15) + result = run_command(["npm", "list", "-g", "--json", "--depth=0"], timeout=15, warn_on_nonzero=False) if result is None: return [] if result.returncode != 0 and not result.stdout.strip(): diff --git a/tests/scanners/test_package_managers.py b/tests/scanners/test_package_managers.py index 87b891a..1c87d1f 100644 --- a/tests/scanners/test_package_managers.py +++ b/tests/scanners/test_package_managers.py @@ -739,6 +739,30 @@ def side_effect(cmd, **_kwargs): assert len(result.packages) == 1 assert result.packages[0].name == "eslint" + def test_list_command_disables_warn_on_nonzero(self, cmd_result) -> None: + """npm's non-zero-exit-on-peer-warnings is a known, benign case — the + list call must opt out of run_command's default WARNING log so it + doesn't falsely flag the scanner as ScannerStatus.WARNING.""" + calls: list[tuple[tuple, dict]] = [] + + def side_effect(*args, **kwargs): + calls.append((args, kwargs)) + cmd = args[0] + if cmd == ["npm", "--version"]: + return cmd_result("11.12.1\n") + if "list" in cmd: + return cmd_result(json.dumps({"dependencies": {}})) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/bin/npm"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + PackageManagersScanner()._detect_npm_global() + + list_call = next(call for call in calls if call[0][0] == ["npm", "list", "-g", "--json", "--depth=0"]) + assert list_call[1].get("warn_on_nonzero") is False + class TestGoDetection: def test_not_present(self) -> None: diff --git a/tests/scanners/test_utils.py b/tests/scanners/test_utils.py index 835227d..22dd322 100644 --- a/tests/scanners/test_utils.py +++ b/tests/scanners/test_utils.py @@ -70,6 +70,19 @@ def test_run_command_logs_warning_on_nonzero_exit(self, caplog: pytest.LogCaptur assert result.returncode == 1 assert any("1" in r.message and "boom" in r.message for r in caplog.records) + def test_run_command_no_warning_on_nonzero_exit_when_disabled(self, caplog: pytest.LogCaptureFixture) -> None: + with ( + patch("mac2nix.scanners._utils.shutil.which", return_value="/usr/bin/false"), + patch("mac2nix.scanners._utils.subprocess.run") as mock_run, + caplog.at_level(logging.WARNING, logger="mac2nix.scanners._utils"), + ): + mock_run.return_value = subprocess.CompletedProcess(args=["false"], returncode=1, stdout="", stderr="boom") + result = run_command(["false"], warn_on_nonzero=False) + + assert result is not None + assert result.returncode == 1 + assert caplog.records == [] + def test_run_command_file_not_found(self) -> None: with ( patch("mac2nix.scanners._utils.shutil.which", return_value="/usr/bin/gone"), diff --git a/tests/test_cli.py b/tests/test_cli.py index ecc80c3..b0aaaa6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -344,6 +344,25 @@ def test_error_row_shows_short_detail_and_full_message_once_in_sub_row(self) -> assert message not in main_row assert text.count(message) == 1 + def test_error_row_with_warnings_shows_both_warnings_before_error(self) -> None: + warning_message = "some warning" + error_message = "RuntimeError: boom" + outcomes = { + "homebrew": ScannerOutcome( + name="homebrew", + status=ScannerStatus.ERROR, + elapsed=0.5, + warnings=(warning_message,), + error=error_message, + ) + } + + text = _render_table(outcomes, ["homebrew"], width=200) + + assert warning_message in text + assert error_message in text + assert text.index(warning_message) < text.index(error_message) + class TestBuildScanTableSkipped: def test_skipped_row_renders_distinctly_from_success_row(self) -> None: diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 4843b0b..a0d0ec4 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -554,6 +554,49 @@ def scan(self) -> Any: assert outcome.error is not None assert "RuntimeError: boom" in outcome.error + def test_crash_log_is_attributed_not_unattributed(self) -> None: + """The logger.exception() call in the except block must fire while + `_current_scanner` is still set, so it lands on the scanner's own + outcome rather than in `log_handler.unattributed`.""" + + class _CrashingScanner: + def __init__(self, **_kwargs: object) -> None: + pass + + @property + def name(self) -> str: + return "_fake_crash" + + def is_available(self) -> bool: + return True + + def scan(self) -> Any: + msg = "boom" + raise RuntimeError(msg) + + registry: dict[str, type] = {"_fake_crash": _CrashingScanner} + outcomes: dict[str, ScannerOutcome] = {} + + with ( + patch("mac2nix.orchestrator.get_all_scanners", return_value=registry), + patch("mac2nix.orchestrator._get_system_metadata", return_value=("host", "14.0", "arm64")), + patch("mac2nix.orchestrator._fetch_system_profiler_batch", return_value={}), + patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), + capture_scanner_logs() as log_handler, + ): + asyncio.run( + run_scan( + progress_callback=lambda outcome: outcomes.__setitem__(outcome.name, outcome), + log_handler=log_handler, + ) + ) + + outcome = outcomes["_fake_crash"] + assert outcome.status == ScannerStatus.ERROR + assert outcome.error is not None + assert any("boom" in warning for warning in outcome.warnings) + assert log_handler.unattributed == [] + def test_outcome_skipped_for_unavailable_scanner(self) -> None: registry = {"_fake_skip": _make_minimal_scanner("_fake_skip", _FakeResult(), available=False)} outcomes: dict[str, ScannerOutcome] = {} From ef67d2a352f71c70c7b007ac1413edba1bd2b409 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 24 Jul 2026 15:45:46 -0400 Subject: [PATCH 22/35] fix(scanners): suppress false-warning on expected nix-daemon check misses _is_daemon_running() probes both known launchd labels and both known process names for the nix daemon, since a machine only ever runs one variant -- checking the other is expected to exit non-zero on every healthy install, and pgrep follows the same not-a-failure convention for "no match". Both call sites now opt out of run_command()'s non-zero-exit warning, matching the existing fix already applied to npm's equally benign non-zero-exit case. --- src/mac2nix/scanners/nix_state.py | 4 ++-- tests/scanners/test_nix_state.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/mac2nix/scanners/nix_state.py b/src/mac2nix/scanners/nix_state.py index 5e35d6e..912c363 100644 --- a/src/mac2nix/scanners/nix_state.py +++ b/src/mac2nix/scanners/nix_state.py @@ -131,7 +131,7 @@ def _get_install_type() -> NixInstallType: def _is_daemon_running() -> bool: # Try both official and Determinate installer service names via launchctl for service in ("org.nixos.nix-daemon", "systems.determinate.nix-daemon"): - result = run_command(["launchctl", "list", service]) + result = run_command(["launchctl", "list", service], warn_on_nonzero=False) if result is None or result.returncode != 0: continue # launchctl list output: PID\tStatus\tLabel @@ -149,7 +149,7 @@ def _is_daemon_running() -> bool: # Fallback: launchctl in user domain can't see system services, # so check for the process directly for proc_name in ("nix-daemon", "determinate-nixd"): - result = run_command(["pgrep", "-x", proc_name]) + result = run_command(["pgrep", "-x", proc_name], warn_on_nonzero=False) if result is not None and result.returncode == 0 and result.stdout.strip(): return True return False diff --git a/tests/scanners/test_nix_state.py b/tests/scanners/test_nix_state.py index 0e3ac30..c486c13 100644 --- a/tests/scanners/test_nix_state.py +++ b/tests/scanners/test_nix_state.py @@ -190,6 +190,27 @@ def test_daemon_all_commands_fail(self) -> None: with patch("mac2nix.scanners.nix_state.run_command", return_value=None): assert NixStateScanner._is_daemon_running() is False + def test_daemon_check_disables_warn_on_nonzero(self, cmd_result) -> None: + """launchctl exits non-zero whenever the checked label isn't loaded, and pgrep + exits non-zero when no process matches — both are expected, benign outcomes. + The daemon check must opt out of run_command's default WARNING log so it + doesn't falsely flag the scanner as ScannerStatus.WARNING.""" + calls: list[tuple[tuple, dict]] = [] + + def side_effect(*args, **kwargs): + calls.append((args, kwargs)) + return cmd_result("", returncode=1) + + with patch("mac2nix.scanners.nix_state.run_command", side_effect=side_effect): + NixStateScanner._is_daemon_running() + + launchctl_calls = [call for call in calls if call[0][0][0] == "launchctl"] + pgrep_calls = [call for call in calls if call[0][0][0] == "pgrep"] + assert len(launchctl_calls) == 2 + assert len(pgrep_calls) == 2 + assert all(call[1].get("warn_on_nonzero") is False for call in launchctl_calls) + assert all(call[1].get("warn_on_nonzero") is False for call in pgrep_calls) + # --------------------------------------------------------------------------- # Profile detection From 50b6e9fc96826fa47055aff0a74c8ec864f6dca9 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 25 Jul 2026 14:38:08 -0400 Subject: [PATCH 23/35] refactor(cli): remove redundant tally dict, extract shared row-render helper --- src/mac2nix/cli.py | 43 ++++++++++++++++--------------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/src/mac2nix/cli.py b/src/mac2nix/cli.py index 563fcad..b2c2ac3 100644 --- a/src/mac2nix/cli.py +++ b/src/mac2nix/cli.py @@ -30,19 +30,20 @@ ScannerStatus.SKIPPED: ("⊘", "dim"), } -_TALLY_LABELS: dict[ScannerStatus, str] = { - ScannerStatus.SUCCESS: "success", - ScannerStatus.WARNING: "warning", - ScannerStatus.ERROR: "error", - ScannerStatus.SKIPPED: "skipped", -} - def _status_icon(status: ScannerStatus) -> tuple[str, str]: """Return (icon, style) for a scanner's status.""" return _STATUS_ICONS[status] +def _add_message_row(table: Table, message: str) -> None: + """Add a sub-row for a warning/error message, plus a remediation hint if one applies.""" + table.add_row("", Text(f" ↳ {message}", style="dim"), "", "") + hint = get_remediation_hint(message) + if hint is not None: + table.add_row("", Text(f" → {hint}", style="dim italic"), "", "") + + def _build_scan_table(outcomes: dict[str, ScannerOutcome], order: Sequence[str]) -> Table: """Render one row per scanner in `order`, with sub-rows for warnings/errors.""" table = Table(box=None, show_header=False, padding=(0, 1)) @@ -66,18 +67,11 @@ def _build_scan_table(outcomes: dict[str, ScannerOutcome], order: Sequence[str]) table.add_row(Text(icon, style=style), name, f"{outcome.elapsed:.1f}s", detail) - if outcome.status in (ScannerStatus.WARNING, ScannerStatus.ERROR) and outcome.warnings: + if outcome.status in (ScannerStatus.WARNING, ScannerStatus.ERROR): for warning in outcome.warnings: - table.add_row("", Text(f" ↳ {warning}", style="dim"), "", "") - hint = get_remediation_hint(warning) - if hint is not None: - table.add_row("", Text(f" → {hint}", style="dim italic"), "", "") - - if outcome.status is ScannerStatus.ERROR and outcome.error is not None: - table.add_row("", Text(f" ↳ {outcome.error}", style="dim"), "", "") - hint = get_remediation_hint(outcome.error) - if hint is not None: - table.add_row("", Text(f" → {hint}", style="dim italic"), "", "") + _add_message_row(table, warning) + if outcome.status is ScannerStatus.ERROR and outcome.error is not None: + _add_message_row(table, outcome.error) return table @@ -157,25 +151,20 @@ def progress_callback(outcome: ScannerOutcome) -> None: for outcome in outcomes.values(): tally_counts[outcome.status] = tally_counts.get(outcome.status, 0) + 1 tally = ", ".join( - f"{tally_counts[status]} {_TALLY_LABELS[status]}" + f"{tally_counts[status]} {status.value}" for status in (ScannerStatus.SUCCESS, ScannerStatus.WARNING, ScannerStatus.ERROR, ScannerStatus.SKIPPED) if tally_counts.get(status, 0) > 0 ) json_output = state.to_json() + summary = f"Scanned {len(outcomes)} scanner(s) in {elapsed:.1f}s ({tally})" if output is not None: output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json_output) - click.echo( - f"Scanned {len(outcomes)} scanner(s) in {elapsed:.1f}s ({tally}) — wrote {output}", - err=True, - ) + click.echo(f"{summary} — wrote {output}", err=True) else: - click.echo( - f"Scanned {len(outcomes)} scanner(s) in {elapsed:.1f}s ({tally})", - err=True, - ) + click.echo(summary, err=True) click.echo(json_output) From 14896c03e3d91d88761fa868ec1618c77129b3c8 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 25 Jul 2026 14:58:20 -0400 Subject: [PATCH 24/35] fix(cli): sanitize control characters from scanner warning/error text Rich's Text() only parses its own markup syntax and click.echo() only strips ANSI on non-tty output -- neither neutralizes literal control bytes on a real terminal. Since scanner warnings/errors now embed raw subprocess stderr and exception text, a compromised or malicious local package's output could inject terminal escape sequences (clear-screen, cursor movement, OSC title/link spoofing) into the live table or the general-warnings section. Strips C0 control characters (preserving tab and newline) at the single point where log records are captured, plus at the orchestrator's exception-message construction site since that one bypasses the log-capture path entirely. Also pops any log records accumulated during a scanner's construction or is_available() check when it turns out to be skipped, matching the success/warning/error branches, which already do this. --- src/mac2nix/orchestrator.py | 12 +++++++++-- src/mac2nix/scan_report.py | 15 +++++++++++++- tests/test_orchestrator.py | 41 +++++++++++++++++++++++++++++++++++++ tests/test_scan_report.py | 18 ++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/mac2nix/orchestrator.py b/src/mac2nix/orchestrator.py index 6932491..ea2b91b 100644 --- a/src/mac2nix/orchestrator.py +++ b/src/mac2nix/orchestrator.py @@ -16,7 +16,13 @@ from pydantic import BaseModel from mac2nix.models.system_state import SystemState -from mac2nix.scan_report import ScannerOutcome, ScannerStatus, _ScannerLogCapture, attribute_to_scanner +from mac2nix.scan_report import ( + ScannerOutcome, + ScannerStatus, + _sanitize_for_display, + _ScannerLogCapture, + attribute_to_scanner, +) from mac2nix.scanners import get_all_scanners from mac2nix.scanners._utils import read_launchd_plists, run_command from mac2nix.scanners.audio import AudioScanner @@ -96,6 +102,8 @@ async def _run_scanner_async( if not scanner.is_available(): logger.info("Scanner '%s' not available — skipping", scanner_name) status = ScannerStatus.SKIPPED + if log_handler is not None: + log_handler.pop_records(scanner_name) return scanner_name, None result: BaseModel = await asyncio.to_thread(scanner.scan) @@ -111,7 +119,7 @@ async def _run_scanner_async( except Exception as exc: logger.exception("Scanner '%s' raised an exception", scanner_name) status = ScannerStatus.ERROR - error = f"{type(exc).__name__}: {exc}" + error = _sanitize_for_display(f"{type(exc).__name__}: {exc}") if log_handler is not None: warnings = tuple(log_handler.pop_records(scanner_name)) return scanner_name, None diff --git a/src/mac2nix/scan_report.py b/src/mac2nix/scan_report.py index 6a88257..47b55f4 100644 --- a/src/mac2nix/scan_report.py +++ b/src/mac2nix/scan_report.py @@ -31,6 +31,19 @@ class ScannerOutcome: _current_scanner: ContextVar[str | None] = ContextVar("_current_scanner", default=None) +_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0d\x0e-\x1f\x7f]") + + +def _sanitize_for_display(text: str) -> str: + """Strip control characters (e.g. ESC, CR) that could inject terminal escape sequences. + + Tab and newline are preserved; every other C0 control character and DEL are + removed. Applied to any subprocess-derived or exception-derived text before + it reaches the Rich table or click.echo, since neither strips raw bytes. + """ + return _CONTROL_CHAR_RE.sub("", text) + + _REMEDIATION_HINTS: tuple[tuple[re.Pattern[str], str], ...] = ( ( re.compile(r"Permission denied reading plist"), @@ -55,7 +68,7 @@ def __init__(self) -> None: def emit(self, record: logging.LogRecord) -> None: scanner_name = _current_scanner.get() - message = self.format(record) + message = _sanitize_for_display(self.format(record)) with self._lock: if scanner_name is None: self.unattributed.append(message) diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index a0d0ec4..920c0c1 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -649,6 +649,47 @@ def test_outcome_warnings_isolated_between_concurrent_scanners(self) -> None: assert any("warning from y" in warning for warning in outcome_y.warnings) assert not any("warning from x" in warning for warning in outcome_y.warnings) + def test_crash_error_message_is_sanitized(self) -> None: + """Control characters embedded in an exception message must not reach ScannerOutcome.error.""" + + class _CrashingScanner: + def __init__(self, **_kwargs: object) -> None: + pass + + @property + def name(self) -> str: + return "_fake_crash" + + def is_available(self) -> bool: + return True + + def scan(self) -> Any: + msg = "boom \x1b[2J\x1b[H injected" + raise RuntimeError(msg) + + registry: dict[str, type] = {"_fake_crash": _CrashingScanner} + outcomes: dict[str, ScannerOutcome] = {} + + with ( + patch("mac2nix.orchestrator.get_all_scanners", return_value=registry), + patch("mac2nix.orchestrator._get_system_metadata", return_value=("host", "14.0", "arm64")), + patch("mac2nix.orchestrator._fetch_system_profiler_batch", return_value={}), + patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), + capture_scanner_logs() as log_handler, + ): + asyncio.run( + run_scan( + progress_callback=lambda outcome: outcomes.__setitem__(outcome.name, outcome), + log_handler=log_handler, + ) + ) + + outcome = outcomes["_fake_crash"] + assert outcome.error is not None + assert "\x1b" not in outcome.error + assert "boom" in outcome.error + assert "injected" in outcome.error + def test_unattributed_prefetch_warning_captured(self) -> None: """Warnings logged during the prefetch phase (before any scanner is attributed) land in `.unattributed`.""" diff --git a/tests/test_scan_report.py b/tests/test_scan_report.py index e8297e0..a238e96 100644 --- a/tests/test_scan_report.py +++ b/tests/test_scan_report.py @@ -129,6 +129,24 @@ def _raise_inside_capture() -> None: assert logger.handlers == handlers_before +class TestSanitizeForDisplay: + def test_strips_ansi_escape_sequence(self) -> None: + with capture_scanner_logs() as handler, attribute_to_scanner("fake_a"): + logging.getLogger("mac2nix.scanners.fake_a").warning("clear screen: \x1b[2J\x1b[H done") + + records = handler.pop_records("fake_a") + assert len(records) == 1 + assert "\x1b" not in records[0] + assert "clear screen: [2J[H done" in records[0] + + def test_preserves_tab_and_newline(self) -> None: + with capture_scanner_logs() as handler, attribute_to_scanner("fake_a"): + logging.getLogger("mac2nix.scanners.fake_a").warning("col1\tcol2\nline2") + + records = handler.pop_records("fake_a") + assert records == ["col1\tcol2\nline2"] + + class TestGetRemediationHint: def test_plist_permission_denied_pattern(self) -> None: hint = get_remediation_hint("Permission denied reading plist: /Library/Preferences/x.plist") From fc7ade15d3a7c124ec34d9c399ef606d88688d40 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 25 Jul 2026 14:58:38 -0400 Subject: [PATCH 25/35] fix(scanners): suppress false warnings on 18 known-benign nonzero exits An exhaustive audit of all 116 run_command() call sites in scanners/ found 18 more instances of the same pattern already fixed for npm and nix-daemon detection: a non-zero exit that represents a normal, expected state (feature off, not installed, empty result, no crontab) rather than a command failure. Left unfixed, these would show as false ScannerStatus.WARNING in the live table on very common, healthy Mac configurations -- system_scanner.py alone (always dispatched, not opt-in) covered screen sharing off, file sharing off, Rosetta not installed, no printers configured, and not signed into iCloud. --- src/mac2nix/scanners/applications.py | 6 +++--- src/mac2nix/scanners/containers.py | 4 ++-- src/mac2nix/scanners/cron.py | 2 +- src/mac2nix/scanners/display.py | 4 +++- src/mac2nix/scanners/homebrew.py | 4 ++-- src/mac2nix/scanners/system_scanner.py | 20 ++++++++++---------- 6 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/mac2nix/scanners/applications.py b/src/mac2nix/scanners/applications.py index f079348..6b28275 100644 --- a/src/mac2nix/scanners/applications.py +++ b/src/mac2nix/scanners/applications.py @@ -274,12 +274,12 @@ def _get_xcode_info(self) -> tuple[str | None, str | None, str | None]: clt_version: str | None = None # xcode-select -p - result = run_command(["xcode-select", "-p"]) + result = run_command(["xcode-select", "-p"], warn_on_nonzero=False) if result is not None and result.returncode == 0: xcode_path = result.stdout.strip() or None # xcodebuild -version (only if full Xcode is installed) - result = run_command(["xcodebuild", "-version"], timeout=10) + result = run_command(["xcodebuild", "-version"], timeout=10, warn_on_nonzero=False) if result is not None and result.returncode == 0: for line in result.stdout.splitlines(): if line.startswith("Xcode"): @@ -287,7 +287,7 @@ def _get_xcode_info(self) -> tuple[str | None, str | None, str | None]: break # CLT version via pkgutil - result = run_command(["pkgutil", "--pkg-info=com.apple.pkg.CLTools_Executables"]) + result = run_command(["pkgutil", "--pkg-info=com.apple.pkg.CLTools_Executables"], warn_on_nonzero=False) if result is not None and result.returncode == 0: for line in result.stdout.splitlines(): if line.startswith("version:"): diff --git a/src/mac2nix/scanners/containers.py b/src/mac2nix/scanners/containers.py index 94d4344..dbe3159 100644 --- a/src/mac2nix/scanners/containers.py +++ b/src/mac2nix/scanners/containers.py @@ -137,7 +137,7 @@ def _detect_colima(self) -> ContainerRuntimeInfo | None: break running = False - status_result = run_command(["colima", "status"]) + status_result = run_command(["colima", "status"], warn_on_nonzero=False) if status_result and status_result.returncode == 0: running = True @@ -167,7 +167,7 @@ def _detect_orbstack(self) -> ContainerRuntimeInfo | None: if result and result.returncode == 0: version = result.stdout.strip().split()[-1] if result.stdout.strip() else None - status_result = run_command(["orbctl", "status"]) + status_result = run_command(["orbctl", "status"], warn_on_nonzero=False) if status_result and status_result.returncode == 0: running = True diff --git a/src/mac2nix/scanners/cron.py b/src/mac2nix/scanners/cron.py index 7677b7d..576c42a 100644 --- a/src/mac2nix/scanners/cron.py +++ b/src/mac2nix/scanners/cron.py @@ -39,7 +39,7 @@ def scan(self) -> ScheduledTasks: ) def _get_cron_entries(self) -> tuple[list[CronEntry], dict[str, str]]: - result = run_command(["crontab", "-l"]) + result = run_command(["crontab", "-l"], warn_on_nonzero=False) if result is None: return [], {} # crontab -l returns exit code 1 with 'no crontab for user' — not an error diff --git a/src/mac2nix/scanners/display.py b/src/mac2nix/scanners/display.py index a93d1f3..4b2da82 100644 --- a/src/mac2nix/scanners/display.py +++ b/src/mac2nix/scanners/display.py @@ -165,7 +165,9 @@ def _parse_night_shift(data: dict[str, Any]) -> NightShiftConfig | None: def _get_true_tone(self) -> bool | None: """Check True Tone (Color Adaptation) status.""" # Try defaults read first - result = run_command(["defaults", "read", "com.apple.CoreBrightness", "CBColorAdaptationEnabled"]) + result = run_command( + ["defaults", "read", "com.apple.CoreBrightness", "CBColorAdaptationEnabled"], warn_on_nonzero=False + ) if result is not None and result.returncode == 0: value = result.stdout.strip() if value == "1": diff --git a/src/mac2nix/scanners/homebrew.py b/src/mac2nix/scanners/homebrew.py index 89e0659..f170111 100644 --- a/src/mac2nix/scanners/homebrew.py +++ b/src/mac2nix/scanners/homebrew.py @@ -73,7 +73,7 @@ def _parse_brewfile( # executable disappeared mid-run) -- nothing more to add here. return taps, formulae, casks, mas_apps if result.returncode != 0: - # run_command() already logged the exit code and stderr (Step 1) -- add + # run_command() already logged the exit code and stderr -- add # homebrew-specific framing only. logger.warning("Unable to enumerate Homebrew state via 'brew bundle dump'") return taps, formulae, casks, mas_apps @@ -113,7 +113,7 @@ def _parse_brewfile_line( def _get_versions(self) -> dict[str, str]: """Parse brew list --versions output into name->version dict.""" - result = run_command(["brew", "list", "--versions"]) + result = run_command(["brew", "list", "--versions"], warn_on_nonzero=False) if result is None: return {} # Parse stdout even on non-zero exit — brew may report errors about diff --git a/src/mac2nix/scanners/system_scanner.py b/src/mac2nix/scanners/system_scanner.py index f63f51a..3b03578 100644 --- a/src/mac2nix/scanners/system_scanner.py +++ b/src/mac2nix/scanners/system_scanner.py @@ -219,7 +219,7 @@ def _get_additional_hostnames(self) -> tuple[str | None, str | None]: if result is not None and result.returncode == 0: local_hostname = result.stdout.strip() or None - result = run_command(["scutil", "--get", "HostName"]) + result = run_command(["scutil", "--get", "HostName"], warn_on_nonzero=False) if result is not None and result.returncode == 0: dns_hostname = result.stdout.strip() or None @@ -244,7 +244,7 @@ def _get_time_machine(self) -> TimeMachineConfig | None: return TimeMachineConfig(configured=False) latest_backup: datetime | None = None - result = run_command(["tmutil", "latestbackup"]) + result = run_command(["tmutil", "latestbackup"], warn_on_nonzero=False) if result is not None and result.returncode == 0: backup_path = result.stdout.strip() if backup_path: @@ -347,7 +347,7 @@ def _get_login_window(self) -> dict[str, Any]: def _get_startup_chime(self) -> bool | None: """Check startup chime setting via nvram.""" - result = run_command(["nvram", "SystemAudioVolume"]) + result = run_command(["nvram", "SystemAudioVolume"], warn_on_nonzero=False) if result is None or result.returncode != 0: # Missing/error typically means chime is on (default) return None @@ -375,7 +375,7 @@ def _get_network_time(self) -> tuple[bool | None, str | None]: # Fallback: check if timed process is running (admin-free) if ntp_enabled is None: - result = run_command(["pgrep", "-x", "timed"]) + result = run_command(["pgrep", "-x", "timed"], warn_on_nonzero=False) if result is not None: ntp_enabled = result.returncode == 0 @@ -396,7 +396,7 @@ def _get_network_time(self) -> tuple[bool | None, str | None]: def _get_printers(self) -> list[PrinterInfo]: """Discover installed printers.""" - result = run_command(["lpstat", "-a"]) + result = run_command(["lpstat", "-a"], warn_on_nonzero=False) if result is None or result.returncode != 0: return [] @@ -412,7 +412,7 @@ def _get_printers(self) -> list[PrinterInfo]: # Get default printer default_name: str | None = None - result = run_command(["lpstat", "-d"]) + result = run_command(["lpstat", "-d"], warn_on_nonzero=False) if result is not None and result.returncode == 0: output = result.stdout.strip() if ":" in output: @@ -452,11 +452,11 @@ def _get_remote_access(self) -> tuple[bool | None, bool | None, bool | None]: if result is not None and result.returncode == 0: remote_login = "on" in result.stdout.lower() - result = run_command(["launchctl", "list", "com.apple.screensharing"]) + result = run_command(["launchctl", "list", "com.apple.screensharing"], warn_on_nonzero=False) if result is not None: screen_sharing = result.returncode == 0 - result = run_command(["launchctl", "list", "com.apple.smbd"]) + result = run_command(["launchctl", "list", "com.apple.smbd"], warn_on_nonzero=False) if result is not None: file_sharing = result.returncode == 0 @@ -467,7 +467,7 @@ def _detect_rosetta(self) -> bool | None: if Path("/Library/Apple/usr/share/rosetta").is_dir(): return True # Fallback: try running arch command - result = run_command(["arch", "-x86_64", "/usr/bin/true"], timeout=5) + result = run_command(["arch", "-x86_64", "/usr/bin/true"], timeout=5, warn_on_nonzero=False) if result is not None: return result.returncode == 0 return None @@ -533,7 +533,7 @@ def _detect_icloud(self) -> ICloudState: desktop_sync = False documents_sync = False - result = run_command(["defaults", "read", "MobileMeAccounts", "Accounts"]) + result = run_command(["defaults", "read", "MobileMeAccounts", "Accounts"], warn_on_nonzero=False) if result is not None and result.returncode == 0: output = result.stdout.strip() signed_in = bool(output) and output != "(\n)" From fc336c35b8cb810adbd657028f7d06887ce07cff Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 25 Jul 2026 14:58:45 -0400 Subject: [PATCH 26/35] refactor(cli): use Counter and enum iteration for the scan tally --- src/mac2nix/cli.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/mac2nix/cli.py b/src/mac2nix/cli.py index b2c2ac3..fdf97de 100644 --- a/src/mac2nix/cli.py +++ b/src/mac2nix/cli.py @@ -5,6 +5,7 @@ import asyncio import time import uuid +from collections import Counter from collections.abc import Sequence from pathlib import Path @@ -147,13 +148,9 @@ def progress_callback(outcome: ScannerOutcome) -> None: if hint is not None: click.echo(f" → {hint}", err=True) - tally_counts: dict[ScannerStatus, int] = {} - for outcome in outcomes.values(): - tally_counts[outcome.status] = tally_counts.get(outcome.status, 0) + 1 + tally_counts = Counter(outcome.status for outcome in outcomes.values()) tally = ", ".join( - f"{tally_counts[status]} {status.value}" - for status in (ScannerStatus.SUCCESS, ScannerStatus.WARNING, ScannerStatus.ERROR, ScannerStatus.SKIPPED) - if tally_counts.get(status, 0) > 0 + f"{tally_counts[status]} {status.value}" for status in ScannerStatus if tally_counts.get(status, 0) > 0 ) json_output = state.to_json() From a0ae76af37fae5ce58fba87114778790078022c0 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 25 Jul 2026 15:09:27 -0400 Subject: [PATCH 27/35] fix(scanners): propagate contextvars into parallel_walk_dirs workers ThreadPoolExecutor.submit() does not copy contextvars into its worker threads the way asyncio.to_thread() does, so warnings logged inside a scanner's nested directory-walk pool were losing their scanner attribution and surfacing as unattributed general warnings instead of on the correct scanner's row. Copies a fresh context per submitted item (not one shared across concurrent workers, which raises "context already entered") so nested pool workers see the same _current_scanner value as the thread that created them. Also widens the control-character sanitizer to cover the C1 range (U+0080-U+009F, including the 8-bit CSI), closing a narrow defense-in-depth gap on top of the existing C0/DEL stripping. --- src/mac2nix/scan_report.py | 5 +++-- src/mac2nix/scanners/_utils.py | 3 ++- tests/scanners/test_parallel_walk.py | 24 ++++++++++++++++++++++++ tests/test_scan_report.py | 9 +++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/mac2nix/scan_report.py b/src/mac2nix/scan_report.py index 47b55f4..1626b91 100644 --- a/src/mac2nix/scan_report.py +++ b/src/mac2nix/scan_report.py @@ -31,13 +31,14 @@ class ScannerOutcome: _current_scanner: ContextVar[str | None] = ContextVar("_current_scanner", default=None) -_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0d\x0e-\x1f\x7f]") +_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0d\x0e-\x1f\x7f-\x9f]") def _sanitize_for_display(text: str) -> str: """Strip control characters (e.g. ESC, CR) that could inject terminal escape sequences. - Tab and newline are preserved; every other C0 control character and DEL are + Tab and newline are preserved; every other C0 control character, DEL, and + the C1 control range (U+0080-U+009F, e.g. the 8-bit CSI U+009B) are removed. Applied to any subprocess-derived or exception-derived text before it reaches the Rich table or click.echo, since neither strips raw bytes. """ diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index 6061cdb..91fe192 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextvars import errno import hashlib import logging @@ -270,7 +271,7 @@ def parallel_walk_dirs[T]( logger.exception("Failed to process directory: %s", d) else: with ThreadPoolExecutor(max_workers=min(max_workers, len(dirs))) as pool: - futures = {pool.submit(process_fn, d): d for d in dirs} + futures = {pool.submit(contextvars.copy_context().run, process_fn, d): d for d in dirs} for future in as_completed(futures): directory = futures[future] try: diff --git a/tests/scanners/test_parallel_walk.py b/tests/scanners/test_parallel_walk.py index 28959e3..a8b631e 100644 --- a/tests/scanners/test_parallel_walk.py +++ b/tests/scanners/test_parallel_walk.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from contextvars import ContextVar from pathlib import Path import pytest @@ -121,6 +122,29 @@ def test_results_order_not_guaranteed(self, tmp_path: Path) -> None: # Order not guaranteed, but sorted must match assert sorted(result) == sorted(f"z{i}" for i in range(5)) + def test_contextvar_propagates_into_pool_workers(self, tmp_path: Path) -> None: + """ContextVar set in the calling thread is visible inside pool workers. + + Uses >2 dirs to force the ThreadPoolExecutor path (the <=2 direct-call + path already runs in-thread and needs no propagation). + """ + test_var: ContextVar[str] = ContextVar("test_var", default="default") + token = test_var.set("caller-value") + try: + dirs = [tmp_path / f"c{i}" for i in range(5)] + for d in dirs: + d.mkdir() + + def read_var(d: Path) -> str: + return test_var.get() + + results = parallel_walk_dirs(dirs, read_var) + finally: + test_var.reset(token) + + assert len(results) == 5 + assert all(value == "caller-value" for value in results) + def test_walk_skip_dirs_contains_new_entries(self) -> None: """Verify key new entries added to WALK_SKIP_DIRS.""" assert "site-packages" in WALK_SKIP_DIRS diff --git a/tests/test_scan_report.py b/tests/test_scan_report.py index a238e96..7c87384 100644 --- a/tests/test_scan_report.py +++ b/tests/test_scan_report.py @@ -146,6 +146,15 @@ def test_preserves_tab_and_newline(self) -> None: records = handler.pop_records("fake_a") assert records == ["col1\tcol2\nline2"] + def test_strips_c1_control_character(self) -> None: + with capture_scanner_logs() as handler, attribute_to_scanner("fake_a"): + logging.getLogger("mac2nix.scanners.fake_a").warning("csi: \x9b2J done") + + records = handler.pop_records("fake_a") + assert len(records) == 1 + assert "\x9b" not in records[0] + assert "csi: 2J done" in records[0] + class TestGetRemediationHint: def test_plist_permission_denied_pattern(self) -> None: From ac11c38286e0eb4fbb9bd75cf4f4b224d0c985b8 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 25 Jul 2026 15:14:42 -0400 Subject: [PATCH 28/35] fix(scanners): propagate contextvars in package_managers_scanner pools Same gap as parallel_walk_dirs(): the two ThreadPoolExecutor usages here (parallel detector dispatch, parallel Go binary inspection) ran their workers without the calling thread's contextvars, so warnings from any of the 7 detectors or the go version -m check lost scanner attribution. Converts the Go inspection loop from pool.map() to explicit submit() so each item gets its own copy_context() call, same as detector dispatch. These were the last two ThreadPoolExecutor sites in the codebase. --- .../scanners/package_managers_scanner.py | 7 ++- tests/scanners/test_package_managers.py | 58 ++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/mac2nix/scanners/package_managers_scanner.py b/src/mac2nix/scanners/package_managers_scanner.py index 3474abd..ef6f9a1 100644 --- a/src/mac2nix/scanners/package_managers_scanner.py +++ b/src/mac2nix/scanners/package_managers_scanner.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextvars import json import logging import os @@ -51,7 +52,7 @@ def scan(self) -> PackageManagersResult: } results: dict[str, object] = {} with ThreadPoolExecutor(max_workers=len(detectors)) as pool: - futures = {pool.submit(fn): name for name, fn in detectors.items()} + futures = {pool.submit(contextvars.copy_context().run, fn): name for name, fn in detectors.items()} for future, name in futures.items(): try: results[name] = future.result() @@ -421,7 +422,9 @@ def _inspect(binary: Path) -> LanguagePackage | None: merged: dict[str, LanguagePackage] = {} with ThreadPoolExecutor(max_workers=min(8, len(binaries))) as pool: - for pkg in pool.map(_inspect, binaries): + futures = [pool.submit(contextvars.copy_context().run, _inspect, b) for b in binaries] + for future in futures: + pkg = future.result() if pkg is None: continue if pkg.name in merged: diff --git a/tests/scanners/test_package_managers.py b/tests/scanners/test_package_managers.py index 1c87d1f..bde2741 100644 --- a/tests/scanners/test_package_managers.py +++ b/tests/scanners/test_package_managers.py @@ -3,10 +3,11 @@ from __future__ import annotations import json +from contextvars import ContextVar from pathlib import Path from unittest.mock import patch -from mac2nix.models.package_managers import PackageManagersResult +from mac2nix.models.package_managers import MacPortsState, PackageManagersResult from mac2nix.scanners.package_managers_scanner import PackageManagersScanner _SCANNER_MODULE = "mac2nix.scanners.package_managers_scanner" @@ -43,6 +44,29 @@ def test_both_absent(self) -> None: assert result.macports.present is False assert result.conda.present is False + def test_scan_dispatch_propagates_contextvar(self) -> None: + """ContextVar set in the calling thread must be visible inside scan()'s + detector pool workers (regression test for missing copy_context().run wrapping).""" + test_var: ContextVar[str] = ContextVar("test_var", default="default") + token = test_var.set("caller-value") + captured: list[str] = [] + + def fake_macports(_self: PackageManagersScanner) -> MacPortsState: + captured.append(test_var.get()) + return MacPortsState(present=False) + + try: + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value=None), + patch.object(Path, "exists", return_value=False), + patch.object(PackageManagersScanner, "_detect_macports", fake_macports), + ): + PackageManagersScanner().scan() + finally: + test_var.reset(token) + + assert captured == ["caller-value"] + class TestMacPortsDetection: def test_not_present(self) -> None: @@ -942,6 +966,38 @@ def side_effect(cmd, **_kwargs): assert len(result.packages) == 1 assert result.packages[0].name == "example.com/tool" + def test_binary_inspection_propagates_contextvar(self, cmd_result, tmp_path: Path) -> None: + """ContextVar set in the calling thread must be visible inside the Go binary + inspection pool workers (regression test for missing copy_context().run wrapping).""" + go_bin = tmp_path / "go" / "bin" + go_bin.mkdir(parents=True) + (go_bin / "gopls").write_text("binary") + + test_var: ContextVar[str] = ContextVar("test_var", default="default") + token = test_var.set("caller-value") + captured: list[str] = [] + + def side_effect(cmd, **_kwargs): + if cmd == ["go", "version"]: + return cmd_result("go version go1.23.0 darwin/arm64\n") + if cmd[0] == "go" and "-m" in cmd: + captured.append(test_var.get()) + return cmd_result("gopls: go1.23.0\n\tmod\tgolang.org/x/tools/gopls\tv0.17.1\t(none)\n") + return None + + try: + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/go/bin/go"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + patch(f"{_SCANNER_MODULE}.Path.home", return_value=tmp_path), + patch.dict("os.environ", {}, clear=True), + ): + PackageManagersScanner()._detect_go() + finally: + test_var.reset(token) + + assert captured == ["caller-value"] + class TestGemDetection: def test_not_present(self) -> None: From 029cedb6d7f24d922090993c5c533fee16e7b77d Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 27 Jul 2026 09:59:36 -0400 Subject: [PATCH 29/35] fix(cli): correct misleading permission hints, wrap long rows, clarify tally Distinguishes the two distinct causes of a plist PermissionError per Apple's own DTS guidance: EACCES means a traditional BSD permission/ACL denial (e.g. a root-owned, 0600 system file, which Full Disk Access cannot override), while EPERM means TCC/sandboxing blocked it, which Full Disk Access genuinely fixes. The "Grant Full Disk Access" hint was previously shown for the EACCES case -- exactly backwards, since two real system files (com.apple.apsd.plist, com.apple.wifi.known-networks.plist) are root-owned 0600 and would never be readable regardless of Full Disk Access. The EACCES case now gets an accurate, non-actionable hint instead; the EPERM case is now surfaced (previously silent at DEBUG) with the Full Disk Access hint it actually deserves. Also caps the live table's message column width with text folding, so a single long warning/error line no longer stretches the whole table edge-to-edge in a wide terminal, and renames the tally's "warning" label to "completed with warnings" so the summary line doesn't read as ambiguous about whether a scanner actually finished. --- src/mac2nix/cli.py | 13 +++++++-- src/mac2nix/scan_report.py | 7 ++++- src/mac2nix/scanners/_utils.py | 9 ++++-- tests/scanners/test_utils.py | 32 ++++++++++++++++++-- tests/test_cli.py | 53 ++++++++++++++++++++++++++++++++-- tests/test_scan_report.py | 14 +++++++-- 6 files changed, 117 insertions(+), 11 deletions(-) diff --git a/src/mac2nix/cli.py b/src/mac2nix/cli.py index fdf97de..1629a0d 100644 --- a/src/mac2nix/cli.py +++ b/src/mac2nix/cli.py @@ -31,6 +31,15 @@ ScannerStatus.SKIPPED: ("⊘", "dim"), } +_TALLY_LABELS: dict[ScannerStatus, str] = { + ScannerStatus.SUCCESS: "success", + ScannerStatus.WARNING: "completed with warnings", + ScannerStatus.ERROR: "failed", + ScannerStatus.SKIPPED: "skipped", +} + +_MESSAGE_COLUMN_MAX_WIDTH = 88 + def _status_icon(status: ScannerStatus) -> tuple[str, str]: """Return (icon, style) for a scanner's status.""" @@ -49,7 +58,7 @@ def _build_scan_table(outcomes: dict[str, ScannerOutcome], order: Sequence[str]) """Render one row per scanner in `order`, with sub-rows for warnings/errors.""" table = Table(box=None, show_header=False, padding=(0, 1)) table.add_column(width=2, justify="center") - table.add_column() + table.add_column(max_width=_MESSAGE_COLUMN_MAX_WIDTH, overflow="fold") table.add_column(justify="right") table.add_column() @@ -150,7 +159,7 @@ def progress_callback(outcome: ScannerOutcome) -> None: tally_counts = Counter(outcome.status for outcome in outcomes.values()) tally = ", ".join( - f"{tally_counts[status]} {status.value}" for status in ScannerStatus if tally_counts.get(status, 0) > 0 + f"{tally_counts[status]} {_TALLY_LABELS[status]}" for status in ScannerStatus if tally_counts.get(status, 0) > 0 ) json_output = state.to_json() diff --git a/src/mac2nix/scan_report.py b/src/mac2nix/scan_report.py index 1626b91..a6561aa 100644 --- a/src/mac2nix/scan_report.py +++ b/src/mac2nix/scan_report.py @@ -47,10 +47,15 @@ def _sanitize_for_display(text: str) -> str: _REMEDIATION_HINTS: tuple[tuple[re.Pattern[str], str], ...] = ( ( - re.compile(r"Permission denied reading plist"), + re.compile(r"Permission denied reading plist \(TCC-protected\)"), "Grant Full Disk Access to your terminal in System Settings → Privacy & " "Security → Full Disk Access, then re-run the scan.", ), + ( + re.compile(r"Permission denied reading plist \(root-only"), + "This file is owned by root with no read access for other users — this is " + "expected macOS behavior, not something Full Disk Access can fix.", + ), ( re.compile(r"(?=.*brew)(?=.*(?:timed out|bundle dump))", re.IGNORECASE), "Try running `brew bundle dump --file=-` manually to see what's slow or failing.", diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index 91fe192..32c96db 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -370,10 +370,15 @@ def read_plist_safe(path: Path) -> dict[str, Any] | list[Any] | None: with path.open("rb") as f: data = plistlib.load(f) except PermissionError as exc: + # Per Apple DTS guidance: EACCES means a traditional BSD permission/ACL + # denial (e.g. a root-owned, 0600 system file -- Full Disk Access cannot + # override Unix file modes). EPERM means the block came from somewhere + # else -- TCC, sandboxing, or another security layer -- which Full Disk + # Access can actually resolve. if exc.errno == errno.EPERM: - logger.debug("Skipping TCC-protected plist: %s", path) + logger.warning("Permission denied reading plist (TCC-protected): %s", path) else: - logger.warning("Permission denied reading plist: %s", path) + logger.warning("Permission denied reading plist (root-only, not a Full Disk Access issue): %s", path) return None except (plistlib.InvalidFileException, ValueError, OverflowError): # plistlib can't handle NeXTStep-format plists, some newer binary plist diff --git a/tests/scanners/test_utils.py b/tests/scanners/test_utils.py index 22dd322..5f7215e 100644 --- a/tests/scanners/test_utils.py +++ b/tests/scanners/test_utils.py @@ -1,5 +1,6 @@ """Tests for scanner utility functions.""" +import errno import logging import plistlib import subprocess @@ -123,14 +124,41 @@ def test_read_plist_safe_missing(self, tmp_path: Path) -> None: assert result is None - def test_read_plist_safe_permission_denied(self, tmp_path: Path) -> None: + def test_read_plist_safe_permission_denied_eperm_is_tcc_protected( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: plist_file = tmp_path / "locked.plist" plist_file.write_bytes(plistlib.dumps({"key": "value"})) + denied = PermissionError("denied") + denied.errno = errno.EPERM - with patch.object(Path, "open", side_effect=PermissionError("denied")): + with ( + patch.object(Path, "open", side_effect=denied), + caplog.at_level(logging.WARNING, logger="mac2nix.scanners._utils"), + ): + result = read_plist_safe(plist_file) + + assert result is None + assert any("TCC-protected" in r.message for r in caplog.records) + assert not any("root-only" in r.message for r in caplog.records) + + def test_read_plist_safe_permission_denied_eacces_is_root_only( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + plist_file = tmp_path / "locked.plist" + plist_file.write_bytes(plistlib.dumps({"key": "value"})) + denied = PermissionError("denied") + denied.errno = errno.EACCES + + with ( + patch.object(Path, "open", side_effect=denied), + caplog.at_level(logging.WARNING, logger="mac2nix.scanners._utils"), + ): result = read_plist_safe(plist_file) assert result is None + assert any("root-only" in r.message for r in caplog.records) + assert not any("TCC-protected" in r.message for r in caplog.records) def test_read_plist_safe_invalid_falls_back_to_plutil(self, tmp_path: Path) -> None: plist_file = tmp_path / "nextstep.plist" diff --git a/tests/test_cli.py b/tests/test_cli.py index b0aaaa6..52dc8c1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -303,7 +303,7 @@ def test_success_row_has_no_warning_or_error_detail_and_no_sub_rows(self) -> Non class TestBuildScanTableWarning: def test_warning_row_shows_short_detail_with_full_warning_and_hint_as_sub_rows(self) -> None: - message = "Permission denied reading plist: /Library/Preferences/x.plist" + message = "Permission denied reading plist (TCC-protected): /Library/Preferences/x.plist" outcomes = { "preferences": ScannerOutcome( name="preferences", @@ -323,6 +323,55 @@ def test_warning_row_shows_short_detail_with_full_warning_and_hint_as_sub_rows(s assert message in text assert "Grant Full Disk Access" in text + def test_long_warning_wraps_within_column_instead_of_widening_table(self) -> None: + long_message = ( + "Command exited 1: ['plutil', '-convert', 'xml1', '-o', '-', " + "'/Users/example/Library/Preferences/net.screensolutions.something.plist'] " + "stderr: Property List error: Unexpected character W at line 1" + ) + outcomes = { + "preferences": ScannerOutcome( + name="preferences", + status=ScannerStatus.WARNING, + elapsed=1.2, + warnings=(long_message,), + ) + } + + # A console much wider than the column's max_width -- if the column weren't + # capped, Rich would render the whole message on one very long line. + text = _render_table(outcomes, ["preferences"], width=200) + lines = [line for line in text.splitlines() if line.strip()] + + assert not any(len(line) > 120 for line in lines), ( + "no rendered line should approach the full 200-column console width" + ) + assert not any(long_message in line for line in lines), "the full message must not fit on a single line" + + def test_root_only_permission_warning_does_not_suggest_full_disk_access(self) -> None: + message = ( + "Permission denied reading plist (root-only, not a Full Disk Access issue): " + "/Library/Preferences/com.apple.apsd.plist" + ) + outcomes = { + "preferences": ScannerOutcome( + name="preferences", + status=ScannerStatus.WARNING, + elapsed=1.2, + warnings=(message,), + ) + } + + # Message content may be word-wrapped within the message column's max width + # (see test_long_warning_wraps_within_column_instead_of_widening_table), so + # check for distinct short fragments rather than the whole string verbatim. + text = _render_table(outcomes, ["preferences"], width=200) + + assert "root-only" in text + assert "apsd.plist" in text + assert "Grant Full Disk Access" not in text + assert "owned by root" in text + class TestBuildScanTableError: def test_error_row_shows_short_detail_and_full_message_once_in_sub_row(self) -> None: @@ -415,7 +464,7 @@ async def fake_run_scan( assert result.exit_code == 0 assert "1 success" in result.output - assert "1 warning" in result.output + assert "1 completed with warnings" in result.output class TestScanCommandGeneralWarnings: diff --git a/tests/test_scan_report.py b/tests/test_scan_report.py index 7c87384..e7418ce 100644 --- a/tests/test_scan_report.py +++ b/tests/test_scan_report.py @@ -157,12 +157,22 @@ def test_strips_c1_control_character(self) -> None: class TestGetRemediationHint: - def test_plist_permission_denied_pattern(self) -> None: - hint = get_remediation_hint("Permission denied reading plist: /Library/Preferences/x.plist") + def test_plist_tcc_protected_pattern_suggests_full_disk_access(self) -> None: + hint = get_remediation_hint("Permission denied reading plist (TCC-protected): /Library/Preferences/x.plist") assert hint is not None assert "Full Disk Access" in hint + def test_plist_root_only_pattern_does_not_suggest_full_disk_access(self) -> None: + hint = get_remediation_hint( + "Permission denied reading plist (root-only, not a Full Disk Access issue): " + "/Library/Preferences/com.apple.apsd.plist" + ) + + assert hint is not None + assert "Grant Full Disk Access" not in hint + assert "root" in hint + def test_brew_timeout_message_from_run_command(self) -> None: message = "Command timed out after 30s: ['brew', 'bundle', 'dump', '--file=-']" From b998de72be24150010a5614e9da030af71223f80 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 29 Jul 2026 09:43:25 -0400 Subject: [PATCH 30/35] fix(scanners): readable command text, skip unreadable plists run_command()'s non-zero-exit and timeout warnings embedded the raw Python list repr of the command (e.g. "['plutil', '-convert', ...]"), which was needlessly hard to scan. Uses shlex.join() to render it as a plain, space-separated command line instead. Also adds a small skip-list to the preferences scanner for two system daemon plists (com.apple.apsd, com.apple.wifi.known-networks) that are root-owned with mode 0600 on every macOS install -- unreadable by any user process, Full Disk Access included. Previously the scanner attempted and logged a permission-denied warning for these on every single scan; now they're skipped outright, since the outcome never varies. --- src/mac2nix/scanners/_utils.py | 10 ++++++++-- src/mac2nix/scanners/preferences.py | 14 ++++++++++++++ tests/scanners/test_preferences.py | 21 +++++++++++++++++++++ tests/scanners/test_utils.py | 15 +++++++++++++++ tests/test_scan_report.py | 2 +- 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index 32c96db..0e176cf 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -7,6 +7,7 @@ import hashlib import logging import plistlib +import shlex import shutil import subprocess from collections.abc import Callable @@ -350,10 +351,15 @@ def run_command( try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) # noqa: S603 if warn_on_nonzero and result.returncode != 0: - logger.warning("Command exited %d: %s\nstderr: %s", result.returncode, cmd, result.stderr.strip()) + logger.warning( + 'Warning: "%s" failed (exit %d)\nstderr: %s', + shlex.join(cmd), + result.returncode, + result.stderr.strip(), + ) return result except subprocess.TimeoutExpired: - logger.warning("Command timed out after %ds: %s", timeout, cmd) + logger.warning('Warning: "%s" timed out after %ds', shlex.join(cmd), timeout) return None except FileNotFoundError: logger.warning("Executable not found during execution: %s", executable) diff --git a/src/mac2nix/scanners/preferences.py b/src/mac2nix/scanners/preferences.py index c5653cd..5bfda2d 100644 --- a/src/mac2nix/scanners/preferences.py +++ b/src/mac2nix/scanners/preferences.py @@ -20,6 +20,18 @@ (Path.home() / "Library" / "Containers", "*/Data/Library/Preferences/*.plist", "disk"), ] +# System daemon preference files that are always root-owned with mode 0600 on +# every macOS install -- no user process can ever read them, Full Disk Access +# included, since that's a traditional Unix permission wall, not a TCC gate. +# Skip attempting them entirely rather than generating a permission-denied +# warning on every single scan. +_KNOWN_INACCESSIBLE_DOMAINS: frozenset[str] = frozenset( + { + "com.apple.apsd", + "com.apple.wifi.known-networks", + } +) + @register("preferences") class PreferencesScanner(BaseScannerPlugin): @@ -37,6 +49,8 @@ def scan(self) -> PreferencesResult: for plist_path in sorted(base_dir.glob(pattern)): if not plist_path.is_file(): continue + if plist_path.stem in _KNOWN_INACCESSIBLE_DOMAINS: + continue data = read_plist_safe(plist_path) if not isinstance(data, dict): continue diff --git a/tests/scanners/test_preferences.py b/tests/scanners/test_preferences.py index 4673267..19be248 100644 --- a/tests/scanners/test_preferences.py +++ b/tests/scanners/test_preferences.py @@ -37,6 +37,27 @@ def test_reads_user_preferences(self, tmp_path: Path) -> None: assert result.domains[0].keys["autohide"] is True assert result.domains[0].keys["tilesize"] == 48 + def test_skips_known_inaccessible_domains_without_attempting_read(self, tmp_path: Path) -> None: + prefs_dir = tmp_path / "Library" / "Preferences" + prefs_dir.mkdir(parents=True) + (prefs_dir / "com.apple.dock.plist").write_bytes(plistlib.dumps({"autohide": True})) + (prefs_dir / "com.apple.apsd.plist").write_bytes(plistlib.dumps({"unreadable": "in practice"})) + (prefs_dir / "com.apple.wifi.known-networks.plist").write_bytes(plistlib.dumps({"unreadable": "too"})) + + with ( + patch("mac2nix.scanners.preferences._PREF_GLOBS", [(prefs_dir, "*.plist", "disk")]), + patch("mac2nix.scanners.preferences.run_command", side_effect=_no_cfprefsd), + patch("mac2nix.scanners.preferences.read_plist_safe") as mock_read, + ): + mock_read.return_value = {"autohide": True} + result = PreferencesScanner().scan() + + # read_plist_safe must never even be called for the known-inaccessible domains -- + # skipped entirely, not attempted-and-caught. + attempted_paths = {call.args[0].stem for call in mock_read.call_args_list} + assert attempted_paths == {"com.apple.dock"} + assert [d.domain_name for d in result.domains] == ["com.apple.dock"] + def test_reads_binary_plist(self, tmp_path: Path) -> None: prefs_dir = tmp_path / "Library" / "Preferences" prefs_dir.mkdir(parents=True) diff --git a/tests/scanners/test_utils.py b/tests/scanners/test_utils.py index 5f7215e..a192a00 100644 --- a/tests/scanners/test_utils.py +++ b/tests/scanners/test_utils.py @@ -71,6 +71,21 @@ def test_run_command_logs_warning_on_nonzero_exit(self, caplog: pytest.LogCaptur assert result.returncode == 1 assert any("1" in r.message and "boom" in r.message for r in caplog.records) + def test_run_command_warning_uses_readable_shell_joined_command(self, caplog: pytest.LogCaptureFixture) -> None: + cmd = ["plutil", "-convert", "xml1", "-o", "-", "/Library/Preferences/x.plist"] + with ( + patch("mac2nix.scanners._utils.shutil.which", return_value="/usr/bin/plutil"), + patch("mac2nix.scanners._utils.subprocess.run") as mock_run, + caplog.at_level(logging.WARNING, logger="mac2nix.scanners._utils"), + ): + mock_run.return_value = subprocess.CompletedProcess(args=cmd, returncode=1, stdout="", stderr="") + run_command(cmd) + + message = caplog.records[0].message + assert "plutil -convert xml1 -o - /Library/Preferences/x.plist" in message + assert "[" not in message + assert "'plutil'" not in message + def test_run_command_no_warning_on_nonzero_exit_when_disabled(self, caplog: pytest.LogCaptureFixture) -> None: with ( patch("mac2nix.scanners._utils.shutil.which", return_value="/usr/bin/false"), diff --git a/tests/test_scan_report.py b/tests/test_scan_report.py index e7418ce..c6c1a65 100644 --- a/tests/test_scan_report.py +++ b/tests/test_scan_report.py @@ -174,7 +174,7 @@ def test_plist_root_only_pattern_does_not_suggest_full_disk_access(self) -> None assert "root" in hint def test_brew_timeout_message_from_run_command(self) -> None: - message = "Command timed out after 30s: ['brew', 'bundle', 'dump', '--file=-']" + message = 'Warning: "brew bundle dump --file=-" timed out after 30s' hint = get_remediation_hint(message) From 29a34499180f2c8456bfc0a7d13016f4f4058a61 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 29 Jul 2026 09:44:44 -0400 Subject: [PATCH 31/35] refactor(cli): decouple summary row width from warning text width Rich Table columns share one width across every row, so capping the column that held both scanner names and warning/error text to keep long messages readable also padded every short scanner name out to that same width, undoing the earlier column-alignment fix. Replaces the single shared table with one small per-scanner grid for the compact summary line (icon, name, elapsed, detail) -- sized only by actual scanner names -- followed by standalone, unconstrained Text lines for any warnings/errors, so message text wraps at the console's full width instead of a narrow shared column. --- src/mac2nix/cli.py | 53 +++++++++++++++++++++++++++++----------------- tests/test_cli.py | 35 +++++++++++++++++++++--------- 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/src/mac2nix/cli.py b/src/mac2nix/cli.py index 1629a0d..c86d412 100644 --- a/src/mac2nix/cli.py +++ b/src/mac2nix/cli.py @@ -10,7 +10,7 @@ from pathlib import Path import click -from rich.console import Console +from rich.console import Console, Group, RenderableType from rich.live import Live from rich.spinner import Spinner from rich.table import Table @@ -38,34 +38,48 @@ ScannerStatus.SKIPPED: "skipped", } -_MESSAGE_COLUMN_MAX_WIDTH = 88 - def _status_icon(status: ScannerStatus) -> tuple[str, str]: """Return (icon, style) for a scanner's status.""" return _STATUS_ICONS[status] -def _add_message_row(table: Table, message: str) -> None: - """Add a sub-row for a warning/error message, plus a remediation hint if one applies.""" - table.add_row("", Text(f" ↳ {message}", style="dim"), "", "") +def _message_lines(message: str) -> list[Text]: + """Render a warning/error message plus its remediation hint (if any). + + Returned as standalone Text objects (not table cells) so they wrap at the + full console width instead of being squeezed into a narrow shared column. + """ + lines = [Text(f" ↳ {message}", style="dim")] hint = get_remediation_hint(message) if hint is not None: - table.add_row("", Text(f" → {hint}", style="dim italic"), "", "") + lines.append(Text(f" → {hint}", style="dim italic")) + return lines -def _build_scan_table(outcomes: dict[str, ScannerOutcome], order: Sequence[str]) -> Table: - """Render one row per scanner in `order`, with sub-rows for warnings/errors.""" - table = Table(box=None, show_header=False, padding=(0, 1)) - table.add_column(width=2, justify="center") - table.add_column(max_width=_MESSAGE_COLUMN_MAX_WIDTH, overflow="fold") - table.add_column(justify="right") - table.add_column() +def _build_scan_table(outcomes: dict[str, ScannerOutcome], order: Sequence[str]) -> Group: + """Render one compact summary line per scanner, with full-width lines for warnings/errors. + + Each scanner gets its own small grid for the summary line (status icon, name, + elapsed, detail) so that line's width is driven only by scanner names, never + by warning/error text -- that text is rendered separately, below, as + unconstrained lines so it wraps at the full console width instead of forcing + every summary line wide. + """ + name_width = max((len(name) for name in order), default=0) + renderables: list[RenderableType] = [] for name in order: + grid = Table.grid(padding=(0, 1)) + grid.add_column(width=2, justify="center") + grid.add_column(width=name_width) + grid.add_column(width=6, justify="right") + grid.add_column() + outcome = outcomes.get(name) if outcome is None: - table.add_row(Spinner("dots"), name, "", "") + grid.add_row(Spinner("dots"), name, "", "") + renderables.append(grid) continue icon, style = _status_icon(outcome.status) @@ -75,15 +89,16 @@ def _build_scan_table(outcomes: dict[str, ScannerOutcome], order: Sequence[str]) elif outcome.status is ScannerStatus.ERROR: detail = "error" - table.add_row(Text(icon, style=style), name, f"{outcome.elapsed:.1f}s", detail) + grid.add_row(Text(icon, style=style), name, f"{outcome.elapsed:.1f}s", detail) + renderables.append(grid) if outcome.status in (ScannerStatus.WARNING, ScannerStatus.ERROR): for warning in outcome.warnings: - _add_message_row(table, warning) + renderables.extend(_message_lines(warning)) if outcome.status is ScannerStatus.ERROR and outcome.error is not None: - _add_message_row(table, outcome.error) + renderables.extend(_message_lines(outcome.error)) - return table + return Group(*renderables) @click.group() diff --git a/tests/test_cli.py b/tests/test_cli.py index 52dc8c1..a9b9e04 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -323,10 +323,10 @@ def test_warning_row_shows_short_detail_with_full_warning_and_hint_as_sub_rows(s assert message in text assert "Grant Full Disk Access" in text - def test_long_warning_wraps_within_column_instead_of_widening_table(self) -> None: + def test_long_warning_does_not_wrap_in_a_wide_console(self) -> None: long_message = ( - "Command exited 1: ['plutil', '-convert', 'xml1', '-o', '-', " - "'/Users/example/Library/Preferences/net.screensolutions.something.plist'] " + 'Warning: "plutil -convert xml1 -o - ' + '/Users/example/Library/Preferences/net.screensolutions.something.plist" failed (exit 1)\n' "stderr: Property List error: Unexpected character W at line 1" ) outcomes = { @@ -338,15 +338,30 @@ def test_long_warning_wraps_within_column_instead_of_widening_table(self) -> Non ) } - # A console much wider than the column's max_width -- if the column weren't - # capped, Rich would render the whole message on one very long line. + # Warning text is no longer confined to a narrow shared column, so a console + # wide enough for the whole message renders it on one line, unwrapped. text = _render_table(outcomes, ["preferences"], width=200) - lines = [line for line in text.splitlines() if line.strip()] - assert not any(len(line) > 120 for line in lines), ( - "no rendered line should approach the full 200-column console width" - ) - assert not any(long_message in line for line in lines), "the full message must not fit on a single line" + assert long_message in text + + def test_summary_line_width_is_unaffected_by_warning_length(self) -> None: + short_outcomes = {"preferences": ScannerOutcome(name="preferences", status=ScannerStatus.SUCCESS, elapsed=1.2)} + long_message = "x" * 150 + long_warning_outcomes = { + "preferences": ScannerOutcome( + name="preferences", status=ScannerStatus.WARNING, elapsed=1.2, warnings=(long_message,) + ) + } + + short_text = _render_table(short_outcomes, ["preferences"], width=200) + long_text = _render_table(long_warning_outcomes, ["preferences"], width=200) + + short_summary_line = next(line for line in short_text.splitlines() if "preferences" in line) + long_summary_line = next(line for line in long_text.splitlines() if "preferences" in line) + + # A wildly long warning must not pad or widen the scanner's own summary + # line -- only its dedicated sub-line(s) should grow. + assert len(long_summary_line) - len(short_summary_line) < 20 def test_root_only_permission_warning_does_not_suggest_full_disk_access(self) -> None: message = ( From 9af245487e85e7965d1418ac41f5348554e3a054 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 29 Jul 2026 10:02:52 -0400 Subject: [PATCH 32/35] fix(cli): nest stderr continuation under its warning line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A warning message with an embedded "...\nstderr: ..." continuation (from run_command()'s failure format) rendered its second line flush against the left margin, looking less indented than the " ↳" line it belongs to. Splits the message on its embedded newlines and indents each continuation line to the same depth as a remediation hint, so it visibly nests under the warning instead of resetting outward. --- src/mac2nix/cli.py | 7 ++++++- tests/test_cli.py | 29 +++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/mac2nix/cli.py b/src/mac2nix/cli.py index c86d412..0879656 100644 --- a/src/mac2nix/cli.py +++ b/src/mac2nix/cli.py @@ -49,8 +49,13 @@ def _message_lines(message: str) -> list[Text]: Returned as standalone Text objects (not table cells) so they wrap at the full console width instead of being squeezed into a narrow shared column. + A message with embedded newlines (e.g. a "...\\nstderr: ..." continuation + from run_command()) gets each continuation line indented to nest under the + first, rather than resetting to the left margin. """ - lines = [Text(f" ↳ {message}", style="dim")] + first, *continuation = message.split("\n") + lines = [Text(f" ↳ {first}", style="dim")] + lines.extend(Text(f" {line}", style="dim") for line in continuation) hint = get_remediation_hint(message) if hint is not None: lines.append(Text(f" → {hint}", style="dim italic")) diff --git a/tests/test_cli.py b/tests/test_cli.py index a9b9e04..e9f6609 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -339,10 +339,15 @@ def test_long_warning_does_not_wrap_in_a_wide_console(self) -> None: } # Warning text is no longer confined to a narrow shared column, so a console - # wide enough for the whole message renders it on one line, unwrapped. + # wide enough for each of its lines renders them unwrapped (each embedded + # newline still starts a new, separately-indented sub-line -- see + # test_stderr_continuation_nests_deeper_than_warning_arrow). text = _render_table(outcomes, ["preferences"], width=200) + first_line, stderr_line = long_message.split("\n") - assert long_message in text + assert first_line in text + assert stderr_line in text + assert not any(len(line) > 150 for line in text.splitlines()), "lines shouldn't be forced to wrap" def test_summary_line_width_is_unaffected_by_warning_length(self) -> None: short_outcomes = {"preferences": ScannerOutcome(name="preferences", status=ScannerStatus.SUCCESS, elapsed=1.2)} @@ -363,6 +368,26 @@ def test_summary_line_width_is_unaffected_by_warning_length(self) -> None: # line -- only its dedicated sub-line(s) should grow. assert len(long_summary_line) - len(short_summary_line) < 20 + def test_stderr_continuation_nests_deeper_than_warning_arrow(self) -> None: + message = 'Warning: "plutil -convert xml1" failed (exit 1)\nstderr: Unexpected character W' + outcomes = { + "preferences": ScannerOutcome( + name="preferences", status=ScannerStatus.WARNING, elapsed=1.2, warnings=(message,) + ) + } + + text = _render_table(outcomes, ["preferences"], width=200) + lines = text.splitlines() + arrow_line = next(line for line in lines if "↳" in line) + stderr_line = next(line for line in lines if "stderr:" in line) + + arrow_indent = len(arrow_line) - len(arrow_line.lstrip()) + stderr_indent = len(stderr_line) - len(stderr_line.lstrip()) + + # The continuation must nest deeper than the "↳" line it belongs to, not + # reset to (or past) the left margin. + assert stderr_indent > arrow_indent + def test_root_only_permission_warning_does_not_suggest_full_disk_access(self) -> None: message = ( "Permission denied reading plist (root-only, not a Full Disk Access issue): " From d715201755ffa881eb5b77f4107255bb7b8f93b8 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 29 Jul 2026 10:53:04 -0400 Subject: [PATCH 33/35] fix(vm): moves VM base image to macos-tahoe-base macos-sequoia-base (macOS 15) was 2 versions behind both the real dev machine (macOS 26 Tahoe) and GitHub's macos-latest runner, which just migrated from Sequoia to Tahoe. make test-integration now auto-pulls the base image locally if missing, so the CI-only pull step is removed. --- .github/workflows/pr-checks.yaml | 5 +---- Makefile | 1 + tests/vm/conftest.py | 2 +- tests/vm/test_integration.py | 3 ++- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml index 93c686c..2143fcd 100644 --- a/.github/workflows/pr-checks.yaml +++ b/.github/workflows/pr-checks.yaml @@ -52,10 +52,7 @@ jobs: brew tap hudochenkov/sshpass brew install sshpass - - name: Pull base VM image - run: tart pull ghcr.io/cirruslabs/macos-sequoia-base@sha256:6d2fcc3b4f669e5fec2c567bd991cfb14b527532a288177ce29fc7eb1ee575c2 # latest - - name: Integration tests env: - MAC2NIX_BASE_VM: macos-sequoia-base + MAC2NIX_BASE_VM: macos-tahoe-base run: make test-integration diff --git a/Makefile b/Makefile index 4ef72d3..28c5ceb 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ test: uv run pytest test-integration: + tart list | grep -q "$${MAC2NIX_BASE_VM:-macos-tahoe-base}" || tart pull ghcr.io/cirruslabs/macos-tahoe-base@sha256:a8e1c8305758643f513fdccdd829c2243687c60791083dea42f73f0b7aeb435c # latest uv run pytest -m integration --tb=long test-quick: diff --git a/tests/vm/conftest.py b/tests/vm/conftest.py index 0f868f9..bffe5c2 100644 --- a/tests/vm/conftest.py +++ b/tests/vm/conftest.py @@ -13,7 +13,7 @@ from mac2nix.vm.manager import TartVMManager -BASE_VM = os.environ.get("MAC2NIX_BASE_VM", "macos-sequoia-base") +BASE_VM = os.environ.get("MAC2NIX_BASE_VM", "macos-tahoe-base") VM_USER = os.environ.get("MAC2NIX_VM_USER", "admin") VM_PASSWORD = os.environ.get("MAC2NIX_VM_PASSWORD", "admin") diff --git a/tests/vm/test_integration.py b/tests/vm/test_integration.py index f514bfc..a1b64e9 100644 --- a/tests/vm/test_integration.py +++ b/tests/vm/test_integration.py @@ -4,7 +4,8 @@ Run them explicitly via ``make test-integration`` or ``uv run pytest -m integration``. The base VM image is controlled by the ``MAC2NIX_BASE_VM`` environment variable -(default: ``macos-sequoia-base``). The CI workflow pulls the image before running. +(default: ``macos-tahoe-base``). ``make test-integration`` pulls the image +automatically if it isn't already present locally. The ``shared_vm`` fixture is provided by ``tests/vm/conftest.py`` and creates a single VM clone shared across the session for non-lifecycle tests. From 171b19bad9d8b9b36578cb9355443e73f1f55cf2 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 29 Jul 2026 10:53:34 -0400 Subject: [PATCH 34/35] fix(vm): quotes remote ssh commands as a single argv element OpenSSH concatenates its trailing command arguments with plain spaces, no re-quoting, before sending them to the remote shell. Passing a ['bash', '-c', 'multi word script'] cmd as separate argv elements let the remote shell re-tokenize the payload, so bash's -c only captured the first word (e.g. bare `comm` instead of `comm -13 file1 file2`) and silently dropped the rest as positional parameters. Collapsing cmd into one shlex.join()-quoted string before it reaches ssh fixes it. --- src/mac2nix/vm/_utils.py | 12 +++++++++- tests/vm/test_vm_utils.py | 48 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/mac2nix/vm/_utils.py b/src/mac2nix/vm/_utils.py index aa29183..69f6e6d 100644 --- a/src/mac2nix/vm/_utils.py +++ b/src/mac2nix/vm/_utils.py @@ -6,6 +6,7 @@ import contextlib import logging import os +import shlex import shutil logger = logging.getLogger(__name__) @@ -113,6 +114,15 @@ async def async_ssh_exec( Builds the argument list with sshpass + ssh options. For remote commands involving pipes or redirects, wrap in ``['bash', '-c', 'pipeline']``. + ``cmd`` is collapsed into a single shell-quoted string via ``shlex.join()`` + before being handed to ssh as one trailing argument. OpenSSH concatenates + multiple trailing command arguments with plain spaces (no re-quoting) to + build the single string it sends to the remote shell — passing ``cmd`` as + separate argv elements would let the remote shell re-tokenize a multi-word + ``-c`` payload, so bash's ``-c`` only captures the first word (e.g. running + bare ``comm`` instead of ``comm -13 file1 file2``) and silently discards + the rest as positional parameters. + Args: ip: IP address or hostname of the VM. user: SSH username. @@ -150,7 +160,7 @@ async def async_ssh_exec( f"ConnectTimeout={max(timeout // 2, 5)}", f"{user}@{ip}", "--", - *cmd, + shlex.join(cmd), ] logger.debug("Running SSH exec on %s@%s: %s", user, ip, cmd) diff --git a/tests/vm/test_vm_utils.py b/tests/vm/test_vm_utils.py index d08b0d1..f5412c7 100644 --- a/tests/vm/test_vm_utils.py +++ b/tests/vm/test_vm_utils.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import shlex from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -347,8 +348,10 @@ async def _run() -> None: "--", ] assert cmd[: len(expected_prefix)] == expected_prefix - # Remote command appended after -- - assert cmd[len(expected_prefix) :] == ["uname", "-a"] + # Remote command appended after -- as a single shell-joined argv + # element (see test_multiword_remote_command_survives_ssh_rejoin_as_one_token + # for why this must not be split across separate argv items). + assert cmd[len(expected_prefix) :] == ["uname -a"] # Password passed via env, not argv assert captured_env[0] == {"SSHPASS": "mypassword"} @@ -489,4 +492,43 @@ async def _run() -> None: assert "--" in cmd # -- must appear before the remote command dash_idx = cmd.index("--") - assert cmd[dash_idx + 1 :] == ["ls", "-la"] + assert cmd[dash_idx + 1 :] == ["ls -la"] + + def test_multiword_remote_command_survives_ssh_rejoin_as_one_token(self) -> None: + """A ['bash', '-c', 'multi word script'] cmd must collapse to ONE argv + element, not be left as separate items. + + OpenSSH concatenates its trailing command arguments with plain spaces + (no re-quoting) to build the single string it sends to the remote + shell. If cmd were passed as separate argv items, the remote shell + would re-tokenize a multi-word -c payload, so bash's -c only captures + the first word (e.g. bare `comm` instead of `comm -13 file1 file2`) + and silently discards the rest as positional parameters. + """ + captured_cmd: list[list[str]] = [] + + async def capturing_run( + cmd: list[str], *, timeout: int = 30, env: dict[str, str] | None = None + ) -> tuple[int, str, str]: + captured_cmd.append(cmd) + return (0, "", "") + + remote_script = "comm -13 '/tmp/before.txt' '/tmp/after.txt'" + + async def _run() -> None: + with ( + patch("mac2nix.vm._utils.is_sshpass_available", return_value=True), + patch("mac2nix.vm._utils.async_run_command", side_effect=capturing_run), + ): + await async_ssh_exec("10.0.0.1", "u", "p", ["bash", "-c", remote_script]) + + asyncio.run(_run()) + cmd = captured_cmd[0] + dash_idx = cmd.index("--") + remote_args = cmd[dash_idx + 1 :] + + # Collapsed into exactly one argv element -- ssh's own rejoin is a no-op. + assert len(remote_args) == 1 + # Reparsing it recovers the original 3-token command, proving the -c + # payload survives ssh's remote-command reconstruction intact. + assert shlex.split(remote_args[0]) == ["bash", "-c", remote_script] From 86aa3d6ac285e4505f53fdbec6cd24ffb47648ac Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 29 Jul 2026 10:53:51 -0400 Subject: [PATCH 35/35] fix(vm): fixes malformed find prune clause with empty exclude_dirs FileSystemComparator joined exclude_dirs into -or-separated -path predicates then always appended -or -name ".localized". An empty exclude_dirs list left a leading -or with no left-hand operand, which find rejects as a syntax error ("Expected a predicate") -- silently swallowed by the pipeline's 2>/dev/null, producing an empty snapshot. A shared _prune_predicates() helper now always includes .localized in the same list being joined, so an empty exclude_dirs still parses. --- src/mac2nix/vm/comparator.py | 19 +++++++++++++++---- tests/vm/test_comparator.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/mac2nix/vm/comparator.py b/src/mac2nix/vm/comparator.py index 2114b3a..06bec86 100644 --- a/src/mac2nix/vm/comparator.py +++ b/src/mac2nix/vm/comparator.py @@ -157,10 +157,22 @@ def __init__( # Internal helpers # ------------------------------------------------------------------ + def _prune_predicates(self) -> str: + """Join exclude-dir path predicates plus the standing .localized exclusion. + + Always appending the ``.localized`` predicate (rather than joining it + in via ``-or`` over a possibly-empty ``exclude_dirs``) avoids a + leading ``-or`` with no left-hand operand when ``exclude_dirs`` is + empty -- ``find`` rejects that as a syntax error ("Expected a + predicate"), which silently produced empty snapshots. + """ + predicates = [f'-path "*/{d}"' for d in self._exclude_dirs] + predicates.append('-name ".localized"') + return " -or ".join(predicates) + def _build_find_pipeline(self, save_path: str) -> str: """Return a shell pipeline string that snapshots the filesystem to *save_path*.""" - prune_parts = " -or ".join(f'-path "*/{d}"' for d in self._exclude_dirs) - prune_clause = f'\\( {prune_parts} -or -name ".localized" \\) -prune -or -print' + prune_clause = f"\\( {self._prune_predicates()} \\) -prune -or -print" quoted_root = shlex.quote(self._scan_root) quoted_save = shlex.quote(save_path) # Use awk substr to strip the scan_root prefix — no regex, no injection risk. @@ -224,8 +236,7 @@ async def get_modified_files( :param scan_root: Override the instance scan root for this call. """ root = scan_root if scan_root is not None else self._scan_root - prune_parts = " -or ".join(f'-path "*/{d}"' for d in self._exclude_dirs) - prune_clause = f'\\( {prune_parts} -or -name ".localized" \\) -prune -or' + prune_clause = f"\\( {self._prune_predicates()} \\) -prune -or" ts_iso = since.replace(microsecond=0).isoformat() cutoff = int(since.timestamp()) quoted_root = shlex.quote(root) diff --git a/tests/vm/test_comparator.py b/tests/vm/test_comparator.py index 8c244d2..73b69cb 100644 --- a/tests/vm/test_comparator.py +++ b/tests/vm/test_comparator.py @@ -440,6 +440,23 @@ async def _run() -> None: pipeline = vm.exec_command.call_args[0][0][2] assert "MySpecialDir" in pipeline + def test_empty_exclude_dirs_produces_valid_find_syntax(self) -> None: + """An empty exclude_dirs list must not leave a leading '-or' with no + left-hand operand -- `find` rejects that as a syntax error ("Expected + a predicate"), which silently produced empty snapshots (find exits + early, and stderr is redirected to /dev/null). + """ + vm = _make_vm((True, "", "")) + fc = FileSystemComparator(vm, exclude_dirs=[]) + + async def _run() -> None: + await fc.snapshot("/tmp/snap.txt") + + asyncio.run(_run()) + pipeline = vm.exec_command.call_args[0][0][2] + assert '\\( -or -name ".localized" \\)' not in pipeline + assert '\\( -name ".localized" \\)' in pipeline + # --------------------------------------------------------------------------- # get_created_files() @@ -689,3 +706,19 @@ async def _run() -> None: pipeline = vm.exec_command.call_args[0][0][2] assert "awk" in pipeline assert "cutoff" in pipeline + + def test_empty_exclude_dirs_produces_valid_find_syntax(self) -> None: + """Same leading-'-or' syntax bug as the snapshot pipeline (see + TestSnapshot.test_empty_exclude_dirs_produces_valid_find_syntax) -- + both build their prune clause from the shared _prune_predicates(). + """ + vm = _make_vm((True, "", "")) + fc = FileSystemComparator(vm, exclude_dirs=[]) + + async def _run() -> None: + await fc.get_modified_files(self._make_since()) + + asyncio.run(_run()) + pipeline = vm.exec_command.call_args[0][0][2] + assert '\\( -or -name ".localized" \\)' not in pipeline + assert '\\( -name ".localized" \\)' in pipeline