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/src/mac2nix/cli.py b/src/mac2nix/cli.py index 107b2c6..0879656 100644 --- a/src/mac2nix/cli.py +++ b/src/mac2nix/cli.py @@ -5,19 +5,106 @@ import asyncio import time import uuid +from collections import Counter +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.console import Console, Group, RenderableType +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: "completed with warnings", + ScannerStatus.ERROR: "failed", + ScannerStatus.SKIPPED: "skipped", +} + + +def _status_icon(status: ScannerStatus) -> tuple[str, str]: + """Return (icon, style) for a scanner's status.""" + return _STATUS_ICONS[status] + + +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. + 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. + """ + 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")) + return lines + + +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: + grid.add_row(Spinner("dots"), name, "", "") + renderables.append(grid) + 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" + + 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: + renderables.extend(_message_lines(warning)) + if outcome.status is ScannerStatus.ERROR and outcome.error is not None: + renderables.extend(_message_lines(outcome.error)) + + return Group(*renderables) + @click.group() @click.version_option() @@ -54,52 +141,56 @@ 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 = Counter(outcome.status for outcome in outcomes.values()) + tally = ", ".join( + f"{tally_counts[status]} {_TALLY_LABELS[status]}" for status in ScannerStatus 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 {scanner_count} scanner(s) in {elapsed:.1f}s — wrote {output}", - err=True, - ) + click.echo(f"{summary} — wrote {output}", err=True) else: - click.echo( - f"Scanned {scanner_count} scanner(s) in {elapsed:.1f}s", - err=True, - ) + click.echo(summary, err=True) click.echo(json_output) diff --git a/src/mac2nix/mappings/__init__.py b/src/mac2nix/mappings/__init__.py new file mode 100644 index 0000000..a160d14 --- /dev/null +++ b/src/mac2nix/mappings/__init__.py @@ -0,0 +1,112 @@ +"""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_app_config, + 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_app_config", + "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/app_config_registry.py b/src/mac2nix/mappings/app_config_registry.py new file mode 100644 index 0000000..e3cf99a --- /dev/null +++ b/src/mac2nix/mappings/app_config_registry.py @@ -0,0 +1,197 @@ +"""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: tuple[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/src/mac2nix/mappings/app_to_hm.py b/src/mac2nix/mappings/app_to_hm.py new file mode 100644 index 0000000..74df404 --- /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 + + +@dataclass(frozen=True, slots=True) +class HMModuleInfo: + """Metadata describing a home-manager ``programs.*`` module for a detected app or tool.""" + + module_path: str + config_paths: tuple[str, ...] = () + darwin_config_paths: tuple[str, ...] = () + 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/src/mac2nix/mappings/app_to_package.py b/src/mac2nix/mappings/app_to_package.py new file mode 100644 index 0000000..cdf1c78 --- /dev/null +++ b/src/mac2nix/mappings/app_to_package.py @@ -0,0 +1,176 @@ +"""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+)*$") +_WHITESPACE_RE = re.compile(r"\s+") + +_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 _WHITESPACE_RE.sub("-", normalized) + + +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-bin"), + "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"), + "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"), + "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/src/mac2nix/mappings/brew_to_nixpkgs.py b/src/mac2nix/mappings/brew_to_nixpkgs.py new file mode 100644 index 0000000..8ba0376 --- /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(frozen=True, slots=True) +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/src/mac2nix/mappings/classifier.py b/src/mac2nix/mappings/classifier.py new file mode 100644 index 0000000..e3e1fa0 --- /dev/null +++ b/src/mac2nix/mappings/classifier.py @@ -0,0 +1,612 @@ +"""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 copy +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_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, + 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", +} + + +# 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() + 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]: + """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)) + + +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 domain.source_path.is_relative_to(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 _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 or value matches a 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): + # 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", + 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: + # 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, + 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: 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' + 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_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) + 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: 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( + 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: 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: + 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}, + ) + ) + else: + results.append( + ClassificationResult( + tier=ClassificationTier.NATIVE, + destination=nix_path, + nix_path=nix_path, + metadata={"shell_type": config.shell_type}, + ) + ) + + for field_name in ENVIRONMENT_MAP: + field_value = getattr(config, field_name) + 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( + 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: + 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_metadata, + ) + ) + + return results 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/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/src/mac2nix/mappings/ephemeral_filter.py b/src/mac2nix/mappings/ephemeral_filter.py new file mode 100644 index 0000000..e713763 --- /dev/null +++ b/src/mac2nix/mappings/ephemeral_filter.py @@ -0,0 +1,232 @@ +"""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", + } +) + +# 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 +_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_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)) + + +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_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. 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) + + +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/src/mac2nix/mappings/non_defaults_to_nix.py b/src/mac2nix/mappings/non_defaults_to_nix.py new file mode 100644 index 0000000..f9ff33d --- /dev/null +++ b/src/mac2nix/mappings/non_defaults_to_nix.py @@ -0,0 +1,336 @@ +"""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", +} + + +# --------------------------------------------------------------------------- +# Environment (environment.shellAliases, environment.variables, environment.systemPath) +# --------------------------------------------------------------------------- + +ENVIRONMENT_MAP: dict[str, str] = { + "aliases": "environment.shellAliases", + "env_vars": "environment.variables", + "path_components": "environment.systemPath", +} + + +# --------------------------------------------------------------------------- +# Time +# --------------------------------------------------------------------------- + +TIMEZONE_NIX_OPTION = "time.timeZone" diff --git a/src/mac2nix/orchestrator.py b/src/mac2nix/orchestrator.py index 46fb31b..ea2b91b 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,13 @@ from pydantic import BaseModel from mac2nix.models.system_state import SystemState +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 @@ -74,43 +82,86 @@ 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) - return scanner_name, None - - result: BaseModel = await asyncio.to_thread(scanner.scan) - logger.debug("Scanner '%s' completed", scanner_name) - return scanner_name, result - except Exception: - logger.exception("Scanner '%s' raised an exception", scanner_name) - 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): + try: + scanner = scanner_cls(**kwargs) + 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) + 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 = _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 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 +185,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 +207,7 @@ async def run_scan( DisplayScanner, {"prefetched_data": batched_sp}, progress_callback, + log_handler, ), name="scanner-display", ) @@ -168,6 +220,7 @@ async def run_scan( AudioScanner, {"prefetched_data": batched_sp}, progress_callback, + log_handler, ), name="scanner-audio", ) @@ -180,6 +233,7 @@ async def run_scan( SystemScanner, {"prefetched_data": batched_sp}, progress_callback, + log_handler, ), name="scanner-system", ) @@ -192,6 +246,7 @@ async def run_scan( LaunchAgentsScanner, {"launchd_plists": launchd_plists}, progress_callback, + log_handler, ), name="scanner-launch_agents", ) @@ -204,6 +259,7 @@ async def run_scan( CronScanner, {"launchd_plists": launchd_plists}, progress_callback, + log_handler, ), name="scanner-cron", ) diff --git a/src/mac2nix/scan_report.py b/src/mac2nix/scan_report.py new file mode 100644 index 0000000..a6561aa --- /dev/null +++ b/src/mac2nix/scan_report.py @@ -0,0 +1,118 @@ +"""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) + +_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, 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. + """ + return _CONTROL_CHAR_RE.sub("", text) + + +_REMEDIATION_HINTS: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + 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.", + ), +) + + +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 = _sanitize_for_display(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/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index d42d563..0e176cf 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -2,10 +2,12 @@ from __future__ import annotations +import contextvars import errno import hashlib import logging import plistlib +import shlex import shutil import subprocess from collections.abc import Callable @@ -270,7 +272,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: @@ -288,7 +290,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: @@ -327,11 +331,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 on any failure (command not found, non-zero exit, timeout). + 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: @@ -340,9 +349,17 @@ 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 warn_on_nonzero and result.returncode != 0: + 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) @@ -359,10 +376,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/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 f9c8db5..f170111 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 -- 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(): @@ -107,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/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/src/mac2nix/scanners/package_managers_scanner.py b/src/mac2nix/scanners/package_managers_scanner.py index 1d6dc62..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() @@ -352,7 +353,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(): @@ -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/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/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)" 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/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/mappings/__init__.py b/tests/mappings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mappings/test_app_config_registry.py b/tests/mappings/test_app_config_registry.py new file mode 100644 index 0000000..e682f82 --- /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) -> 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) -> None: + assert get_app_config("com.example.NotARealApp") is None + + 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) -> None: + assert len(APP_CONFIG_REGISTRY) >= 20 + + 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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_hm.py b/tests/mappings/test_app_to_hm.py new file mode 100644 index 0000000..9f4e925 --- /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 diff --git a/tests/mappings/test_app_to_package.py b/tests/mappings/test_app_to_package.py new file mode 100644 index 0000000..d89c4be --- /dev/null +++ b/tests/mappings/test_app_to_package.py @@ -0,0 +1,177 @@ +"""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" + 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" + + 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" + + 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: + 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"} 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", + } diff --git a/tests/mappings/test_classifier.py b/tests/mappings/test_classifier.py new file mode 100644 index 0000000..6f7758d --- /dev/null +++ b/tests/mappings/test_classifier.py @@ -0,0 +1,639 @@ +"""Tests for the four-tier setting classifier.""" + +from pathlib import Path + +from mac2nix.mappings.classifier import ( + ClassificationTier, + classify_app, + classify_app_config, + 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 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 +from mac2nix.models.services import LaunchAgentEntry, LaunchAgentSource, ShellConfig, ShellFramework + + +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 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( + "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" + + 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: + 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 + ) + + 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: + 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 + + 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: + 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 + + 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: + 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 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) + 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" + + 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: + 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 + + 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: + 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 + + 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) + + +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_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) + 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"]} 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 diff --git a/tests/mappings/test_dotfile_to_hm.py b/tests/mappings/test_dotfile_to_hm.py new file mode 100644 index 0000000..32e4f7f --- /dev/null +++ b/tests/mappings/test_dotfile_to_hm.py @@ -0,0 +1,83 @@ +"""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 + + 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_ephemeral_filter.py b/tests/mappings/test_ephemeral_filter.py new file mode 100644 index 0000000..0708e72 --- /dev/null +++ b/tests/mappings/test_ephemeral_filter.py @@ -0,0 +1,213 @@ +"""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 + + 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: + 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({}) == {} 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..b9ccb8c --- /dev/null +++ b/tests/mappings/test_non_defaults_to_nix.py @@ -0,0 +1,200 @@ +"""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, + 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 + + 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: + 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 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" + + +class TestShellProgramMapContents: + def test_all_documented_shells_present(self) -> None: + assert set(SHELL_PROGRAM_MAP) == {"zsh", "bash", "fish", "tmux"} 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_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) 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 diff --git a/tests/scanners/test_package_managers.py b/tests/scanners/test_package_managers.py index 87b891a..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: @@ -739,6 +763,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: @@ -918,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: 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/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 094ed95..a192a00 100644 --- a/tests/scanners/test_utils.py +++ b/tests/scanners/test_utils.py @@ -1,5 +1,7 @@ """Tests for scanner utility functions.""" +import errno +import logging import plistlib import subprocess from datetime import UTC, datetime @@ -7,6 +9,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 +58,47 @@ 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_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"), + 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"), @@ -94,14 +139,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 ffa132f..e9f6609 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,262 @@ 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 (TCC-protected): /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 + + def test_long_warning_does_not_wrap_in_a_wide_console(self) -> None: + long_message = ( + '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 = { + "preferences": ScannerOutcome( + name="preferences", + status=ScannerStatus.WARNING, + elapsed=1.2, + warnings=(long_message,), + ) + } + + # Warning text is no longer confined to a narrow shared column, so a console + # 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 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)} + 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_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): " + "/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: + 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 + + 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: + 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 completed with warnings" 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 diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 84135d3..920c0c1 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,250 @@ 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_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] = {} + + 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_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`.""" + + 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) diff --git a/tests/test_scan_report.py b/tests/test_scan_report.py new file mode 100644 index 0000000..c6c1a65 --- /dev/null +++ b/tests/test_scan_report.py @@ -0,0 +1,193 @@ +"""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 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"] + + 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_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 = 'Warning: "brew bundle dump --file=-" timed out after 30s' + + 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 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_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 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. 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]