From 3802099236907a163051eb0b910963bf45d1748f Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 10 Mar 2026 14:19:21 -0400 Subject: [PATCH 01/17] =?UTF-8?q?feat(scanners):=20coverage=20gaps=20?= =?UTF-8?q?=E2=80=94=2057=20sub-tasks,=2016=20scanners,=20492=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement all scanner coverage gaps from the comprehensive plan: - 17 new model classes, 19 modified models - New library_audit scanner (~/Library + /Library audit) - Enhanced all 15 existing scanners with expanded discovery - 273 new tests (219→492), all passing Key additions per scanner: - preferences: SyncedPreferences, cfprefsd-only domains, source tracking - dotfiles: discovery mode, XDG support, sensitive paths, manager detection - shell: conf.d, completions, source following, frameworks, eval detection - app_config: Containers, recursive os.walk, volume safeguards - applications: PATH binaries, dev tool versions, Xcode/CLT - launch_agents: raw plist capture, sensitive env redaction, typed fields - homebrew: services, pinned formulae, prefix - cron: schedule parsing with trigger types, env vars - network: IPv6, VPN, SOCKS/FTP proxy, WiFi list, locations - security: firewall rules, Touch ID sudo, custom certificates - system: macOS version, hardware, Time Machine, sleep, printers, etc. - display: Night Shift, refresh rate, color profile, True Tone - audio: full volume settings parsing - fonts: font collections - library_audit: directory audit, content capture, workflow/keybinding handlers --- pyproject.toml | 2 +- src/mac2nix/models/__init__.py | 48 +- src/mac2nix/models/application.py | 32 ++ src/mac2nix/models/files.py | 75 +++- src/mac2nix/models/hardware.py | 12 + src/mac2nix/models/preferences.py | 3 +- src/mac2nix/models/services.py | 42 +- src/mac2nix/models/system.py | 59 +++ src/mac2nix/models/system_state.py | 3 +- src/mac2nix/scanners/__init__.py | 1 + src/mac2nix/scanners/_utils.py | 10 +- src/mac2nix/scanners/app_config.py | 104 ++++- src/mac2nix/scanners/applications.py | 166 ++++++- src/mac2nix/scanners/audio.py | 62 ++- src/mac2nix/scanners/base.py | 7 +- src/mac2nix/scanners/cron.py | 76 +++- src/mac2nix/scanners/display.py | 77 +++- src/mac2nix/scanners/dotfiles.py | 228 ++++++++-- src/mac2nix/scanners/fonts.py | 19 +- src/mac2nix/scanners/homebrew.py | 59 ++- src/mac2nix/scanners/launch_agents.py | 44 ++ src/mac2nix/scanners/library_audit.py | 518 ++++++++++++++++++++++ src/mac2nix/scanners/network.py | 165 +++++-- src/mac2nix/scanners/preferences.py | 68 ++- src/mac2nix/scanners/security.py | 102 ++++- src/mac2nix/scanners/shell.py | 213 ++++++++- src/mac2nix/scanners/system_scanner.py | 299 ++++++++++++- tests/models/test_preferences.py | 30 ++ tests/models/test_remaining.py | 586 ++++++++++++++++++++++++- tests/models/test_system_state.py | 30 +- tests/scanners/test_app_config.py | 156 +++++++ tests/scanners/test_applications.py | 231 +++++++++- tests/scanners/test_audio.py | 134 +++++- tests/scanners/test_cron.py | 132 +++++- tests/scanners/test_display.py | 283 ++++++++++++ tests/scanners/test_dotfiles.py | 254 ++++++++++- tests/scanners/test_fonts.py | 42 ++ tests/scanners/test_homebrew.py | 157 ++++++- tests/scanners/test_launch_agents.py | 168 +++++++ tests/scanners/test_library_audit.py | 512 +++++++++++++++++++++ tests/scanners/test_network.py | 237 +++++++++- tests/scanners/test_preferences.py | 162 ++++++- tests/scanners/test_security.py | 214 +++++++++ tests/scanners/test_shell.py | 368 ++++++++++++++++ tests/scanners/test_system_scanner.py | 456 +++++++++++++++++++ 45 files changed, 6434 insertions(+), 212 deletions(-) create mode 100644 src/mac2nix/scanners/library_audit.py create mode 100644 tests/scanners/test_library_audit.py diff --git a/pyproject.toml b/pyproject.toml index a2c41fa..728a161 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"**/tests/**/*.py" = ["S101", "SLF001"] # assert + private member access OK in tests +"**/tests/**/*.py" = ["S101", "S105", "S108", "SLF001"] # assert + test data + private access OK in tests [tool.ruff.lint.isort] known-first-party = ["mac2nix"] diff --git a/src/mac2nix/models/__init__.py b/src/mac2nix/models/__init__.py index a6fce2b..7a8cb69 100644 --- a/src/mac2nix/models/__init__.py +++ b/src/mac2nix/models/__init__.py @@ -3,34 +3,61 @@ from mac2nix.models.application import ( ApplicationsResult, AppSource, + BinarySource, BrewCask, BrewFormula, + BrewService, HomebrewState, InstalledApp, MasApp, + PathBinary, ) from mac2nix.models.files import ( AppConfigEntry, AppConfigResult, + BundleEntry, ConfigFileType, DotfileEntry, DotfileManager, DotfilesResult, + FontCollection, FontEntry, FontSource, FontsResult, + KeyBindingEntry, + LibraryAuditResult, + LibraryDirEntry, + LibraryFileEntry, + WorkflowEntry, +) +from mac2nix.models.hardware import ( + AudioConfig, + AudioDevice, + DisplayConfig, + Monitor, + NightShiftConfig, ) -from mac2nix.models.hardware import AudioConfig, AudioDevice, DisplayConfig, Monitor from mac2nix.models.preferences import PreferencesDomain, PreferencesResult, PreferenceValue from mac2nix.models.services import ( CronEntry, LaunchAgentEntry, LaunchAgentSource, LaunchAgentsResult, + LaunchdScheduledJob, ScheduledTasks, ShellConfig, + ShellFramework, +) +from mac2nix.models.system import ( + FirewallAppRule, + NetworkConfig, + NetworkInterface, + PrinterInfo, + SecurityState, + SystemConfig, + TimeMachineConfig, + VpnProfile, ) -from mac2nix.models.system import NetworkConfig, NetworkInterface, SecurityState, SystemConfig from mac2nix.models.system_state import SystemState __all__ = [ @@ -40,32 +67,49 @@ "ApplicationsResult", "AudioConfig", "AudioDevice", + "BinarySource", "BrewCask", "BrewFormula", + "BrewService", + "BundleEntry", "ConfigFileType", "CronEntry", "DisplayConfig", "DotfileEntry", "DotfileManager", "DotfilesResult", + "FirewallAppRule", + "FontCollection", "FontEntry", "FontSource", "FontsResult", "HomebrewState", "InstalledApp", + "KeyBindingEntry", "LaunchAgentEntry", "LaunchAgentSource", "LaunchAgentsResult", + "LaunchdScheduledJob", + "LibraryAuditResult", + "LibraryDirEntry", + "LibraryFileEntry", "MasApp", "Monitor", "NetworkConfig", "NetworkInterface", + "NightShiftConfig", + "PathBinary", "PreferenceValue", "PreferencesDomain", "PreferencesResult", + "PrinterInfo", "ScheduledTasks", "SecurityState", "ShellConfig", + "ShellFramework", "SystemConfig", "SystemState", + "TimeMachineConfig", + "VpnProfile", + "WorkflowEntry", ] diff --git a/src/mac2nix/models/application.py b/src/mac2nix/models/application.py index 0b0e2d6..58fc5b3 100644 --- a/src/mac2nix/models/application.py +++ b/src/mac2nix/models/application.py @@ -14,6 +14,17 @@ class AppSource(StrEnum): MANUAL = "manual" +class BinarySource(StrEnum): + BREW = "brew" + CARGO = "cargo" + GO = "go" + PIPX = "pipx" + NPM = "npm" + GEM = "gem" + SYSTEM = "system" + MANUAL = "manual" + + class InstalledApp(BaseModel): name: str bundle_id: str | None = None @@ -22,13 +33,25 @@ class InstalledApp(BaseModel): source: AppSource +class PathBinary(BaseModel): + name: str + path: Path + source: BinarySource + version: str | None = None + + class ApplicationsResult(BaseModel): apps: list[InstalledApp] + path_binaries: list[PathBinary] = [] + xcode_path: str | None = None + xcode_version: str | None = None + clt_version: str | None = None class BrewFormula(BaseModel): name: str version: str | None = None + pinned: bool = False class BrewCask(BaseModel): @@ -42,8 +65,17 @@ class MasApp(BaseModel): version: str | None = None +class BrewService(BaseModel): + name: str + status: str + user: str | None = None + plist_path: Path | None = None + + class HomebrewState(BaseModel): taps: list[str] = [] formulae: list[BrewFormula] = [] casks: list[BrewCask] = [] mas_apps: list[MasApp] = [] + services: list[BrewService] = [] + prefix: str | None = None diff --git a/src/mac2nix/models/files.py b/src/mac2nix/models/files.py index 20bfc4b..e1a022a 100644 --- a/src/mac2nix/models/files.py +++ b/src/mac2nix/models/files.py @@ -1,9 +1,11 @@ -"""Dotfile, app config, and font models.""" +"""Dotfile, app config, font, and library audit models.""" from __future__ import annotations +from datetime import datetime from enum import StrEnum from pathlib import Path +from typing import Any from pydantic import BaseModel @@ -11,6 +13,10 @@ class DotfileManager(StrEnum): GIT = "git" STOW = "stow" + CHEZMOI = "chezmoi" + YADM = "yadm" + HOME_MANAGER = "home_manager" + RCM = "rcm" MANUAL = "manual" UNKNOWN = "unknown" @@ -20,6 +26,9 @@ class DotfileEntry(BaseModel): content_hash: str | None = None managed_by: DotfileManager = DotfileManager.UNKNOWN symlink_target: Path | None = None + is_directory: bool = False + file_count: int | None = None + sensitive: bool = False class DotfilesResult(BaseModel): @@ -44,6 +53,7 @@ class AppConfigEntry(BaseModel): file_type: ConfigFileType = ConfigFileType.UNKNOWN content_hash: str | None = None scannable: bool = True # False for databases + modified_time: datetime | None = None class AppConfigResult(BaseModel): @@ -61,5 +71,68 @@ class FontEntry(BaseModel): source: FontSource +class FontCollection(BaseModel): + name: str + path: Path + + class FontsResult(BaseModel): entries: list[FontEntry] + collections: list[FontCollection] = [] + + +class LibraryDirEntry(BaseModel): + name: str + path: Path + file_count: int | None = None + total_size_bytes: int | None = None + covered_by_scanner: str | None = None + has_user_content: bool = False + newest_modification: datetime | None = None + + +class LibraryFileEntry(BaseModel): + path: Path + file_type: str | None = None + content_hash: str | None = None + plist_content: dict[str, Any] | None = None + text_content: str | None = None + migration_strategy: str | None = None + size_bytes: int | None = None + + +class WorkflowEntry(BaseModel): + name: str + path: Path + identifier: str | None = None + workflow_definition: dict[str, Any] | None = None + + +class BundleEntry(BaseModel): + name: str + path: Path + bundle_id: str | None = None + version: str | None = None + bundle_type: str | None = None + + +class KeyBindingEntry(BaseModel): + key: str + action: str | dict[str, Any] + + +class LibraryAuditResult(BaseModel): + bundles: list[BundleEntry] = [] + directories: list[LibraryDirEntry] = [] + uncovered_files: list[LibraryFileEntry] = [] + workflows: list[WorkflowEntry] = [] + key_bindings: list[KeyBindingEntry] = [] + spelling_words: list[str] = [] + spelling_dictionaries: list[str] = [] + input_methods: list[BundleEntry] = [] + keyboard_layouts: list[str] = [] + color_profiles: list[str] = [] + compositions: list[str] = [] + scripts: list[str] = [] + text_replacements: list[dict[str, str]] = [] + system_bundles: list[BundleEntry] = [] diff --git a/src/mac2nix/models/hardware.py b/src/mac2nix/models/hardware.py index 6e0745f..dd6bece 100644 --- a/src/mac2nix/models/hardware.py +++ b/src/mac2nix/models/hardware.py @@ -5,16 +5,25 @@ from pydantic import BaseModel +class NightShiftConfig(BaseModel): + enabled: bool | None = None + schedule: str | None = None + + class Monitor(BaseModel): name: str resolution: str | None = None # e.g. "3456x2234" scaling: float | None = None retina: bool = False arrangement_position: str | None = None # e.g. "primary", "left", "right" + refresh_rate: str | None = None + color_profile: str | None = None class DisplayConfig(BaseModel): monitors: list[Monitor] = [] + night_shift: NightShiftConfig | None = None + true_tone_enabled: bool | None = None class AudioDevice(BaseModel): @@ -28,3 +37,6 @@ class AudioConfig(BaseModel): default_input: str | None = None default_output: str | None = None alert_volume: float | None = None + output_volume: int | None = None + input_volume: int | None = None + output_muted: bool | None = None diff --git a/src/mac2nix/models/preferences.py b/src/mac2nix/models/preferences.py index 5f10aae..e3c4ffe 100644 --- a/src/mac2nix/models/preferences.py +++ b/src/mac2nix/models/preferences.py @@ -12,7 +12,8 @@ class PreferencesDomain(BaseModel): domain_name: str # e.g. "com.apple.dock" - source_path: Path # e.g. ~/Library/Preferences/com.apple.dock.plist + source_path: Path | None = None # e.g. ~/Library/Preferences/com.apple.dock.plist + source: str = "disk" # "disk", "synced", "cfprefsd" keys: dict[str, PreferenceValue] diff --git a/src/mac2nix/models/services.py b/src/mac2nix/models/services.py index d295e59..7d88717 100644 --- a/src/mac2nix/models/services.py +++ b/src/mac2nix/models/services.py @@ -4,6 +4,7 @@ from enum import StrEnum from pathlib import Path +from typing import Any from pydantic import BaseModel @@ -23,12 +24,34 @@ class LaunchAgentEntry(BaseModel): enabled: bool = True source: LaunchAgentSource plist_path: Path | None = None + raw_plist: dict[str, Any] = {} + working_directory: str | None = None + environment_variables: dict[str, str] | None = None + keep_alive: bool | dict[str, Any] | None = None + start_interval: int | None = None + start_calendar_interval: dict[str, int] | list[dict[str, int]] | None = None + watch_paths: list[str] = [] + queue_directories: list[str] = [] + stdout_path: str | None = None + stderr_path: str | None = None + throttle_interval: int | None = None + process_type: str | None = None + nice: int | None = None + user_name: str | None = None + group_name: str | None = None class LaunchAgentsResult(BaseModel): entries: list[LaunchAgentEntry] = [] +class ShellFramework(BaseModel): + name: str + path: Path | None = None + plugins: list[str] = [] + theme: str | None = None + + class ShellConfig(BaseModel): shell_type: str # fish, zsh, bash rc_files: list[Path] = [] @@ -36,6 +59,11 @@ class ShellConfig(BaseModel): aliases: dict[str, str] = {} functions: list[str] = [] env_vars: dict[str, str] = {} + conf_d_files: list[Path] = [] + completion_files: list[Path] = [] + sourced_files: list[Path] = [] + frameworks: list[ShellFramework] = [] + dynamic_commands: list[str] = [] class CronEntry(BaseModel): @@ -44,6 +72,18 @@ class CronEntry(BaseModel): user: str | None = None +class LaunchdScheduledJob(BaseModel): + label: str + schedule: list[dict[str, int]] = [] + program: str | None = None + program_arguments: list[str] = [] + watch_paths: list[str] = [] + queue_directories: list[str] = [] + start_interval: int | None = None + trigger_type: str = "calendar" + + class ScheduledTasks(BaseModel): cron_entries: list[CronEntry] = [] - launchd_scheduled: list[str] = [] # labels of launchd jobs with StartCalendarInterval + launchd_scheduled: list[LaunchdScheduledJob] = [] + cron_env: dict[str, str] = {} diff --git a/src/mac2nix/models/system.py b/src/mac2nix/models/system.py index 896feb9..d4eb8e7 100644 --- a/src/mac2nix/models/system.py +++ b/src/mac2nix/models/system.py @@ -2,6 +2,9 @@ from __future__ import annotations +from datetime import datetime +from typing import Any + from pydantic import BaseModel @@ -10,6 +13,15 @@ class NetworkInterface(BaseModel): hardware_port: str | None = None device: str | None = None ip_address: str | None = None + ipv6_address: str | None = None + is_active: bool | None = None + + +class VpnProfile(BaseModel): + name: str + protocol: str | None = None + status: str | None = None + remote_address: str | None = None class NetworkConfig(BaseModel): @@ -18,6 +30,15 @@ class NetworkConfig(BaseModel): search_domains: list[str] = [] proxy_settings: dict[str, str] = {} wifi_networks: list[str] = [] + vpn_profiles: list[VpnProfile] = [] + proxy_bypass_domains: list[str] = [] + locations: list[str] = [] + current_location: str | None = None + + +class FirewallAppRule(BaseModel): + app_path: str + allowed: bool class SecurityState(BaseModel): @@ -26,6 +47,24 @@ class SecurityState(BaseModel): firewall_enabled: bool | None = None gatekeeper_enabled: bool | None = None tcc_summary: dict[str, list[str]] = {} # service -> list of allowed apps + firewall_stealth_mode: bool | None = None + firewall_app_rules: list[FirewallAppRule] = [] + firewall_block_all_incoming: bool | None = None + touch_id_sudo: bool | None = None + custom_certificates: list[str] = [] + + +class TimeMachineConfig(BaseModel): + configured: bool = False + destination_name: str | None = None + destination_id: str | None = None + latest_backup: datetime | None = None + + +class PrinterInfo(BaseModel): + name: str + is_default: bool = False + options: dict[str, str] = {} class SystemConfig(BaseModel): @@ -34,3 +73,23 @@ class SystemConfig(BaseModel): locale: str | None = None power_settings: dict[str, str] = {} # pmset key-value pairs spotlight_indexing: bool | None = None + macos_version: str | None = None + macos_build: str | None = None + macos_product_name: str | None = None + hardware_model: str | None = None + hardware_chip: str | None = None + hardware_memory: str | None = None + hardware_serial: str | None = None + time_machine: TimeMachineConfig | None = None + software_update: dict[str, Any] = {} + sleep_settings: dict[str, str | int | None] = {} + login_window: dict[str, Any] = {} + startup_chime: bool | None = None + local_hostname: str | None = None + dns_hostname: str | None = None + network_time_enabled: bool | None = None + network_time_server: str | None = None + printers: list[PrinterInfo] = [] + remote_login: bool | None = None + screen_sharing: bool | None = None + file_sharing: bool | None = None diff --git a/src/mac2nix/models/system_state.py b/src/mac2nix/models/system_state.py index 7350b14..075fb1f 100644 --- a/src/mac2nix/models/system_state.py +++ b/src/mac2nix/models/system_state.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from mac2nix.models.application import ApplicationsResult, HomebrewState -from mac2nix.models.files import AppConfigResult, DotfilesResult, FontsResult +from mac2nix.models.files import AppConfigResult, DotfilesResult, FontsResult, LibraryAuditResult from mac2nix.models.hardware import AudioConfig, DisplayConfig from mac2nix.models.preferences import PreferencesResult from mac2nix.models.services import LaunchAgentsResult, ScheduledTasks, ShellConfig @@ -41,6 +41,7 @@ class SystemState(BaseModel): display: DisplayConfig | None = None audio: AudioConfig | None = None cron: ScheduledTasks | None = None + library_audit: LibraryAuditResult | None = None def to_json(self, path: Path | None = None) -> str: """Serialize to JSON string. Optionally write to file.""" diff --git a/src/mac2nix/scanners/__init__.py b/src/mac2nix/scanners/__init__.py index d103fb5..c673590 100644 --- a/src/mac2nix/scanners/__init__.py +++ b/src/mac2nix/scanners/__init__.py @@ -10,6 +10,7 @@ fonts, homebrew, launch_agents, + library_audit, network, preferences, security, diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index 337c705..a5c1d35 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -23,13 +23,15 @@ def _convert_datetimes(obj: Any) -> Any: - """Recursively convert datetime values to ISO 8601 strings. + """Recursively convert non-JSON-safe plist values. - plistlib returns datetime objects for NSDate values, but PreferenceValue - does not include datetime in its union type. + plistlib returns datetime objects (for NSDate) and bytes objects (for NSData) + that are not JSON-serializable. Convert them to strings. """ if isinstance(obj, datetime): return obj.isoformat() + if isinstance(obj, bytes): + return f"" if isinstance(obj, dict): return {k: _convert_datetimes(v) for k, v in obj.items()} if isinstance(obj, list): @@ -79,7 +81,7 @@ def read_plist_safe(path: Path) -> dict[str, Any] | None: logger.warning("Permission denied reading plist: %s", path) return None except plistlib.InvalidFileException: - logger.debug("Invalid plist file: %s", path) + logger.warning("Invalid plist file: %s", path) return None except (ValueError, OverflowError): # plistlib can't handle dates like year 0 (Apple's "no date" sentinel). diff --git a/src/mac2nix/scanners/app_config.py b/src/mac2nix/scanners/app_config.py index 61a3b9f..e24ef3d 100644 --- a/src/mac2nix/scanners/app_config.py +++ b/src/mac2nix/scanners/app_config.py @@ -4,6 +4,7 @@ import logging import os +from datetime import UTC, datetime from pathlib import Path from mac2nix.models.files import AppConfigEntry, AppConfigResult, ConfigFileType @@ -27,6 +28,33 @@ ".sqlite3": ConfigFileType.DATABASE, } +_SKIP_DIRS = frozenset({ + "Caches", + "Cache", + "Logs", + "logs", + "tmp", + "temp", + "__pycache__", + "node_modules", + ".git", + ".svn", + ".hg", + "DerivedData", + "Build", + ".build", + "IndexedDB", + "GPUCache", + "ShaderCache", + "Service Worker", + "Code Cache", + "CachedData", + "blob_storage", +}) + +_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB +_MAX_FILES_PER_APP = 500 + @register("app_config") class AppConfigScanner(BaseScannerPlugin): @@ -43,6 +71,17 @@ def scan(self) -> AppConfigResult: home / "Library" / "Group Containers", ] + # Add Containers app support dirs + containers_dir = home / "Library" / "Containers" + if containers_dir.is_dir(): + try: + for container in sorted(containers_dir.iterdir()): + app_support = container / "Data" / "Library" / "Application Support" + if app_support.is_dir() and os.access(app_support, os.R_OK): + scan_dirs.append(app_support) + except PermissionError: + logger.warning("Permission denied reading: %s", containers_dir) + for base_dir in scan_dirs: if not base_dir.is_dir(): continue @@ -63,28 +102,49 @@ def scan(self) -> AppConfigResult: def _scan_app_dir(self, app_dir: Path, entries: list[AppConfigEntry]) -> None: app_name = app_dir.name + file_count = 0 + try: - children = sorted(app_dir.iterdir()) + for dirpath, dirnames, filenames in os.walk(app_dir, followlinks=False): + # Prune skipped directories in-place + dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] + + for filename in filenames: + if file_count >= _MAX_FILES_PER_APP: + logger.warning( + "Reached %d file cap for app directory: %s", + _MAX_FILES_PER_APP, + app_dir, + ) + return + + filepath = Path(dirpath) / filename + try: + stat = filepath.stat() + except OSError: + continue + + # Skip files over 10MB + if stat.st_size > _MAX_FILE_SIZE: + continue + + ext = filepath.suffix.lower() + file_type = _EXTENSION_MAP.get(ext, ConfigFileType.UNKNOWN) + scannable = file_type != ConfigFileType.DATABASE + + content_hash = hash_file(filepath) if scannable else None + modified_time = datetime.fromtimestamp(stat.st_mtime, tz=UTC) + + entries.append( + AppConfigEntry( + app_name=app_name, + path=filepath, + file_type=file_type, + content_hash=content_hash, + scannable=scannable, + modified_time=modified_time, + ) + ) + file_count += 1 except PermissionError: logger.warning("Permission denied reading app config dir: %s", app_dir) - return - - for child in children: - if not child.is_file(): - continue - - ext = child.suffix.lower() - file_type = _EXTENSION_MAP.get(ext, ConfigFileType.UNKNOWN) - scannable = file_type != ConfigFileType.DATABASE - - content_hash = hash_file(child) if scannable else None - - entries.append( - AppConfigEntry( - app_name=app_name, - path=child, - file_type=file_type, - content_hash=content_hash, - scannable=scannable, - ) - ) diff --git a/src/mac2nix/scanners/applications.py b/src/mac2nix/scanners/applications.py index 4f0acfd..7ff9b7a 100644 --- a/src/mac2nix/scanners/applications.py +++ b/src/mac2nix/scanners/applications.py @@ -3,10 +3,18 @@ from __future__ import annotations import logging +import os +import re import shutil from pathlib import Path -from mac2nix.models.application import ApplicationsResult, AppSource, InstalledApp +from mac2nix.models.application import ( + ApplicationsResult, + AppSource, + BinarySource, + InstalledApp, + PathBinary, +) from mac2nix.scanners._utils import read_plist_safe, run_command from mac2nix.scanners.base import BaseScannerPlugin, register @@ -17,6 +25,18 @@ Path.home() / "Applications", ] +_SOURCE_PATTERNS: dict[str, BinarySource] = { + ".cargo/bin": BinarySource.CARGO, + "go/bin": BinarySource.GO, + ".local/bin": BinarySource.PIPX, + ".local/share/pipx": BinarySource.PIPX, + ".npm": BinarySource.NPM, + "node_modules/.bin": BinarySource.NPM, + ".gem": BinarySource.GEM, +} + +_SYSTEM_DIRS = frozenset({"/usr/bin", "/bin", "/usr/sbin", "/sbin"}) + @register("applications") class ApplicationsScanner(BaseScannerPlugin): @@ -57,7 +77,17 @@ def scan(self) -> ApplicationsResult: ) ) - return ApplicationsResult(apps=apps) + path_binaries = self._get_path_binaries() + self._enrich_dev_versions(path_binaries) + xcode_path, xcode_version, clt_version = self._get_xcode_info() + + return ApplicationsResult( + apps=apps, + path_binaries=path_binaries, + xcode_path=xcode_path, + xcode_version=xcode_version, + clt_version=clt_version, + ) def _get_mas_apps(self) -> dict[str, int]: """Get App Store app names (lowercased) from mas list.""" @@ -76,3 +106,135 @@ def _get_mas_apps(self) -> dict[str, int]: name_part = parts[1].rsplit("(", 1)[0].strip() apps[name_part.lower()] = app_id return apps + + def _get_path_binaries(self, brew_names: set[str] | None = None) -> list[PathBinary]: + """Walk PATH directories and collect executable binaries.""" + if brew_names is None: + brew_names = set() + + binaries: list[PathBinary] = [] + seen_names: set[str] = set() + path_dirs = os.environ.get("PATH", "").split(":") + + for dir_str in path_dirs: + if not dir_str: + continue + dir_path = Path(dir_str) + if not dir_path.is_dir(): + continue + try: + for entry in sorted(dir_path.iterdir()): + if not entry.is_file(): + continue + if not os.access(entry, os.X_OK): + continue + name = entry.name + if name in seen_names: + continue + seen_names.add(name) + + source = self._classify_binary_source(entry, brew_names) + binaries.append( + PathBinary( + name=name, + path=entry, + source=source, + ) + ) + except PermissionError: + logger.debug("Permission denied scanning PATH dir: %s", dir_path) + + return binaries + + @staticmethod + def _classify_binary_source(path: Path, brew_names: set[str]) -> BinarySource: + """Classify a binary's source based on its path.""" + path_str = str(path) + + # Check if it's a brew-installed binary + if path.name in brew_names: + return BinarySource.BREW + + # Check for brew prefix paths + if "/homebrew/" in path_str.lower() or "/Cellar/" in path_str: + return BinarySource.BREW + + # Check known source patterns + for pattern, source in _SOURCE_PATTERNS.items(): + if pattern in path_str: + return source + + # Check system dirs + parent = str(path.parent) + if parent in _SYSTEM_DIRS: + return BinarySource.SYSTEM + + return BinarySource.MANUAL + + def _enrich_dev_versions(self, binaries: list[PathBinary]) -> None: + """Populate version for known dev tools found in PATH.""" + version_commands: dict[str, list[str]] = { + "python3": ["python3", "--version"], + "ruby": ["ruby", "--version"], + "node": ["node", "--version"], + "go": ["go", "version"], + "rustc": ["rustc", "--version"], + "swift": ["swift", "--version"], + "git": ["git", "--version"], + } + binary_map = {b.name: b for b in binaries} + for tool_name, cmd in version_commands.items(): + if tool_name not in binary_map: + continue + if binary_map[tool_name].source == BinarySource.SYSTEM: + continue + result = run_command(cmd, timeout=5) + if result is None or result.returncode != 0: + continue + version = self._extract_version(result.stdout.strip()) + if version: + binary_map[tool_name].version = version + + # java -version writes to stderr + if "java" in binary_map and binary_map["java"].source != BinarySource.SYSTEM: + result = run_command(["java", "-version"], timeout=5) + if result is not None and result.returncode == 0: + output = result.stderr.strip() if result.stderr else result.stdout.strip() + version = self._extract_version(output) + if version: + binary_map["java"].version = version + + @staticmethod + def _extract_version(output: str) -> str | None: + """Extract a version string from command output.""" + match = re.search(r"(\d+\.\d+[\.\d]*)", output) + return match.group(1) if match else None + + def _get_xcode_info(self) -> tuple[str | None, str | None, str | None]: + """Detect Xcode and Command Line Tools installation.""" + xcode_path: str | None = None + xcode_version: str | None = None + clt_version: str | None = None + + # xcode-select -p + result = run_command(["xcode-select", "-p"]) + 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) + if result is not None and result.returncode == 0: + for line in result.stdout.splitlines(): + if line.startswith("Xcode"): + xcode_version = line.split(None, 1)[1].strip() if " " in line else None + break + + # CLT version via pkgutil + result = run_command(["pkgutil", "--pkg-info=com.apple.pkg.CLTools_Executables"]) + if result is not None and result.returncode == 0: + for line in result.stdout.splitlines(): + if line.startswith("version:"): + clt_version = line.split(":", 1)[1].strip() + break + + return xcode_path, xcode_version, clt_version diff --git a/src/mac2nix/scanners/audio.py b/src/mac2nix/scanners/audio.py index 07ad5ef..729ea00 100644 --- a/src/mac2nix/scanners/audio.py +++ b/src/mac2nix/scanners/audio.py @@ -13,6 +13,20 @@ logger = logging.getLogger(__name__) +def _parse_int(value: str) -> int | None: + try: + return int(value) + except ValueError: + return None + + +def _parse_float(value: str) -> float | None: + try: + return float(value) + except ValueError: + return None + + @register("audio") class AudioScanner(BaseScannerPlugin): @property @@ -24,7 +38,7 @@ def is_available(self) -> bool: def scan(self) -> AudioConfig: input_devices, output_devices, default_input, default_output = self._get_audio_devices() - alert_volume = self._get_alert_volume() + alert_volume, output_volume, input_volume, output_muted = self._get_volume_settings() return AudioConfig( input_devices=input_devices, @@ -32,6 +46,9 @@ def scan(self) -> AudioConfig: default_input=default_input, default_output=default_output, alert_volume=alert_volume, + output_volume=output_volume, + input_volume=input_volume, + output_muted=output_muted, ) def _get_audio_devices( @@ -90,12 +107,39 @@ def _classify_device(device_data: dict[str, object]) -> tuple[bool, bool]: is_output = True return is_input, is_output - def _get_alert_volume(self) -> float | None: - result = run_command(["osascript", "-e", "alert volume of (get volume settings)"]) + def _get_volume_settings( + self, + ) -> tuple[float | None, int | None, int | None, bool | None]: + """Parse all volume settings from osascript 'get volume settings'. + + Output format: "output volume:50, input volume:75, alert volume:100, output muted:false" + Returns: (alert_volume, output_volume, input_volume, output_muted) + """ + result = run_command(["osascript", "-e", "get volume settings"]) if result is None or result.returncode != 0: - return None - try: - return float(result.stdout.strip()) - except ValueError: - logger.warning("Failed to parse alert volume: %s", result.stdout) - return None + return None, None, None, None + + alert_volume: float | None = None + output_volume: int | None = None + input_volume: int | None = None + output_muted: bool | None = None + output = result.stdout.strip() + + for raw_part in output.split(","): + segment = raw_part.strip() + if ":" not in segment: + continue + key, _, value = segment.partition(":") + key = key.strip() + value = value.strip() + + if key == "output volume": + output_volume = _parse_int(value) + elif key == "input volume": + input_volume = _parse_int(value) + elif key == "alert volume": + alert_volume = _parse_float(value) + elif key == "output muted": + output_muted = value.lower() == "true" + + return alert_volume, output_volume, input_volume, output_muted diff --git a/src/mac2nix/scanners/base.py b/src/mac2nix/scanners/base.py index 611b8c6..7282ae4 100644 --- a/src/mac2nix/scanners/base.py +++ b/src/mac2nix/scanners/base.py @@ -4,11 +4,14 @@ from abc import ABC, abstractmethod from collections.abc import Callable +from typing import TypeVar from pydantic import BaseModel SCANNER_REGISTRY: dict[str, type[BaseScannerPlugin]] = {} +_T = TypeVar("_T", bound="BaseScannerPlugin") + class BaseScannerPlugin(ABC): """Abstract base class for all scanner plugins.""" @@ -32,13 +35,13 @@ def is_available(self) -> bool: return True -def register(name: str) -> Callable[[type[BaseScannerPlugin]], type[BaseScannerPlugin]]: +def register(name: str) -> Callable[[type[_T]], type[_T]]: """Class decorator factory to register a scanner plugin by name. Usage: @register("scanner_name") """ - def decorator(cls: type[BaseScannerPlugin]) -> type[BaseScannerPlugin]: + def decorator(cls: type[_T]) -> type[_T]: SCANNER_REGISTRY[name] = cls return cls diff --git a/src/mac2nix/scanners/cron.py b/src/mac2nix/scanners/cron.py index 0773e1e..1aaf8c7 100644 --- a/src/mac2nix/scanners/cron.py +++ b/src/mac2nix/scanners/cron.py @@ -4,7 +4,7 @@ import logging -from mac2nix.models.services import CronEntry, ScheduledTasks +from mac2nix.models.services import CronEntry, LaunchdScheduledJob, ScheduledTasks from mac2nix.scanners._utils import read_launchd_plists, run_command from mac2nix.scanners.base import BaseScannerPlugin, register @@ -18,27 +18,39 @@ def name(self) -> str: return "cron" def scan(self) -> ScheduledTasks: - cron_entries = self._get_cron_entries() + cron_entries, cron_env = self._get_cron_entries() launchd_scheduled = self._get_launchd_scheduled() - return ScheduledTasks(cron_entries=cron_entries, launchd_scheduled=launchd_scheduled) + return ScheduledTasks( + cron_entries=cron_entries, + launchd_scheduled=launchd_scheduled, + cron_env=cron_env, + ) - def _get_cron_entries(self) -> list[CronEntry]: + def _get_cron_entries(self) -> tuple[list[CronEntry], dict[str, str]]: result = run_command(["crontab", "-l"]) if result is None: - return [] + return [], {} # crontab -l returns exit code 1 with 'no crontab for user' — not an error if result.returncode != 0: if "no crontab" in result.stderr.lower(): - return [] + return [], {} logger.warning("crontab -l failed: %s", result.stderr) - return [] + return [], {} entries: list[CronEntry] = [] + cron_env: dict[str, str] = {} for raw_line in result.stdout.splitlines(): stripped = raw_line.strip() if not stripped or stripped.startswith("#"): continue + # Parse environment variable assignments (KEY=value) + if "=" in stripped and not stripped[0].isdigit() and not stripped.startswith("@"): + key, _, value = stripped.partition("=") + if key.isidentifier(): + cron_env[key] = value + continue + # Handle special schedule strings (@reboot, @daily, etc.) if stripped.startswith("@"): parts = stripped.split(None, 1) @@ -52,14 +64,48 @@ def _get_cron_entries(self) -> list[CronEntry]: command = parts[5] entries.append(CronEntry(schedule=schedule, command=command)) - return entries + return entries, cron_env - def _get_launchd_scheduled(self) -> list[str]: - """Find launchd plists with StartCalendarInterval keys.""" - labels: list[str] = [] + def _get_launchd_scheduled(self) -> list[LaunchdScheduledJob]: + """Find launchd plists with scheduling keys.""" + jobs: list[LaunchdScheduledJob] = [] for _plist_path, _source_key, data in read_launchd_plists(): + label = data.get("Label") + if not label: + continue + + trigger_type: str | None = None if "StartCalendarInterval" in data: - label = data.get("Label") - if label: - labels.append(str(label)) - return labels + trigger_type = "calendar" + elif "WatchPaths" in data: + trigger_type = "watch" + elif "QueueDirectories" in data: + trigger_type = "queue" + elif "StartInterval" in data: + trigger_type = "interval" + + if trigger_type is None: + continue + + # Normalize StartCalendarInterval to list form + schedule_raw = data.get("StartCalendarInterval") + if isinstance(schedule_raw, dict): + schedule = [schedule_raw] + elif isinstance(schedule_raw, list): + schedule = schedule_raw + else: + schedule = [] + + jobs.append( + LaunchdScheduledJob( + label=str(label), + schedule=schedule, + program=data.get("Program"), + program_arguments=data.get("ProgramArguments", []), + watch_paths=data.get("WatchPaths", []), + queue_directories=data.get("QueueDirectories", []), + start_interval=data.get("StartInterval"), + trigger_type=trigger_type, + ) + ) + return jobs diff --git a/src/mac2nix/scanners/display.py b/src/mac2nix/scanners/display.py index a7ccff4..be787a2 100644 --- a/src/mac2nix/scanners/display.py +++ b/src/mac2nix/scanners/display.py @@ -5,9 +5,10 @@ import json import logging import shutil +from pathlib import Path -from mac2nix.models.hardware import DisplayConfig, Monitor -from mac2nix.scanners._utils import run_command +from mac2nix.models.hardware import DisplayConfig, Monitor, NightShiftConfig +from mac2nix.scanners._utils import read_plist_safe, run_command from mac2nix.scanners.base import BaseScannerPlugin, register logger = logging.getLogger(__name__) @@ -45,7 +46,14 @@ def scan(self) -> DisplayConfig: monitor = self._parse_monitor(display) monitors.append(monitor) - return DisplayConfig(monitors=monitors) + night_shift = self._get_night_shift() + true_tone = self._get_true_tone() + + return DisplayConfig( + monitors=monitors, + night_shift=night_shift, + true_tone_enabled=true_tone, + ) def _parse_monitor(self, display: dict[str, object]) -> Monitor: name = str(display.get("_name", "Unknown")) @@ -58,9 +66,72 @@ def _parse_monitor(self, display: dict[str, object]) -> Monitor: if display.get("spdisplays_main") == "spdisplays_yes": arrangement = "primary" + # Refresh rate (12b) + refresh_rate = display.get("_spdisplays_refresh", display.get("spdisplays_refresh")) + refresh_str = str(refresh_rate) if refresh_rate is not None else None + + # Color profile (12c) + color_profile = display.get("spdisplays_color_profile", display.get("_spdisplays_color_profile")) + color_str = str(color_profile) if color_profile is not None else None + return Monitor( name=name, resolution=resolution_str, retina=retina, arrangement_position=arrangement, + refresh_rate=refresh_str, + color_profile=color_str, ) + + def _get_night_shift(self) -> NightShiftConfig | None: + """Detect Night Shift settings from CoreBrightness preferences.""" + for plist_path in [ + Path.home() / "Library" / "Preferences" / "com.apple.CoreBrightness.plist", + Path("/private/var/root/Library/Preferences/com.apple.CoreBrightness.plist"), + ]: + data = read_plist_safe(plist_path) + if data is None: + continue + + # Night Shift data is nested under CBBlueReductionStatus + ns_data = data.get("CBBlueReductionStatus", {}) + if not isinstance(ns_data, dict): + # Sometimes the top-level keys vary + for val in data.values(): + if isinstance(val, dict) and "CBBlueReductionStatus" in val: + ns_data = val["CBBlueReductionStatus"] + break + + if not ns_data: + continue + + enabled = ns_data.get("BlueReductionEnabled") + mode = ns_data.get("BlueReductionMode") + schedule: str | None = None + if mode == 1: + schedule = "sunset-to-sunrise" + elif mode == 2: + schedule = "custom" + elif enabled is False or enabled == 0: + schedule = "off" + + return NightShiftConfig( + enabled=bool(enabled) if enabled is not None else None, + schedule=schedule, + ) + + return None + + def _get_true_tone(self) -> bool | None: + """Check True Tone (Color Adaptation) status.""" + result = run_command( + ["defaults", "read", "com.apple.CoreBrightness", "CBColorAdaptationEnabled"] + ) + if result is None or result.returncode != 0: + return None + value = result.stdout.strip() + if value == "1": + return True + if value == "0": + return False + return None diff --git a/src/mac2nix/scanners/dotfiles.py b/src/mac2nix/scanners/dotfiles.py index b00f4e0..551c7d0 100644 --- a/src/mac2nix/scanners/dotfiles.py +++ b/src/mac2nix/scanners/dotfiles.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import os from pathlib import Path from mac2nix.models.files import DotfileEntry, DotfileManager, DotfilesResult @@ -11,25 +12,52 @@ logger = logging.getLogger(__name__) -_KNOWN_DOTFILES = [ - ".zshrc", - ".bashrc", - ".bash_profile", - ".profile", - ".gitconfig", - ".gitignore_global", - ".ssh/config", - ".hushlogin", - ".vimrc", - ".tmux.conf", - ".editorconfig", -] +_EXCLUDED_DOTFILES = frozenset({ + ".Trash", + ".cache", + ".DS_Store", + ".CFUserTextEncoding", + ".bash_history", + ".zsh_history", + ".python_history", + ".node_repl_history", + ".psql_history", + ".sqlite_history", + ".lesshst", + ".wget-hsts", +}) _SCAN_DIRS = [ ".config", ".local/share", + ".local/state", ] +_SENSITIVE_DIRS = frozenset({ + ".ssh", + ".gnupg", + ".aws", + ".docker", + ".kube", + ".azure", +}) + +_SENSITIVE_DIR_PATHS = frozenset({ + ".config/gcloud", +}) + +_SENSITIVE_FILES = frozenset({ + ".netrc", + ".npmrc", + ".pypirc", +}) + +_SENSITIVE_FILE_PATHS = frozenset({ + ".gem/credentials", + ".config/gh/hosts.yml", + ".config/hub", +}) + @register("dotfiles") class DotfilesScanner(BaseScannerPlugin): @@ -41,35 +69,92 @@ def scan(self) -> DotfilesResult: home = Path.home() entries: list[DotfileEntry] = [] - # Known dotfiles - for dotfile in _KNOWN_DOTFILES: - path = home / dotfile - if path.exists(): - entry = self._make_entry(path, home) - if entry is not None: - entries.append(entry) - - # Scan directories (first-level entries only) - for scan_dir in _SCAN_DIRS: - dir_path = home / scan_dir - if not dir_path.is_dir(): - continue - try: - children = sorted(dir_path.iterdir()) - except PermissionError: - logger.warning("Permission denied reading directory: %s", dir_path) - continue - for child in children: - if child.is_file(): - entry = self._make_entry(child, home) - if entry is not None: - entries.append(entry) + self._discover_home_dotfiles(home, entries) + + # Scan XDG directories (first-level entries only) + for dir_path in self._get_xdg_scan_dirs(home): + self._scan_directory_children(dir_path, home, entries) + + # Apply global manager as fallback for UNKNOWN entries + global_mgr = self._detect_global_manager(home) + if global_mgr is not None: + for entry in entries: + if entry.managed_by == DotfileManager.UNKNOWN: + entry.managed_by = global_mgr return DotfilesResult(entries=entries) + def _discover_home_dotfiles(self, home: Path, entries: list[DotfileEntry]) -> None: + """Discover all ~/.* files and directories.""" + try: + for child in sorted(home.iterdir()): + if not child.name.startswith("."): + continue + if child.name in _EXCLUDED_DOTFILES: + continue + self._classify_and_append(child, home, entries) + except PermissionError: + logger.warning("Permission denied reading home directory: %s", home) + + def _scan_directory_children(self, dir_path: Path, home: Path, entries: list[DotfileEntry]) -> None: + """Scan first-level children of a directory.""" + if not dir_path.is_dir(): + return + try: + children = sorted(dir_path.iterdir()) + except PermissionError: + logger.warning("Permission denied reading directory: %s", dir_path) + return + for child in children: + self._classify_and_append(child, home, entries) + + def _classify_and_append(self, child: Path, home: Path, entries: list[DotfileEntry]) -> None: + """Classify a path as file or directory and append the entry.""" + if child.is_dir(): + entry = self._make_dir_entry(child) + elif child.is_file() or child.is_symlink(): + entry = self._make_entry(child, home) + else: + return + if entry is not None: + entries.append(entry) + + @staticmethod + def _get_xdg_scan_dirs(home: Path) -> list[Path]: + """Get XDG-based directories to scan, honoring env overrides.""" + dirs: list[Path] = [] + for env_var, default_rel in [ + ("XDG_CONFIG_HOME", ".config"), + ("XDG_DATA_HOME", ".local/share"), + ("XDG_STATE_HOME", ".local/state"), + ]: + env_val = os.environ.get(env_var) + candidate = Path(env_val) if env_val else home / default_rel + if candidate.is_dir(): + dirs.append(candidate) + return dirs + + def _make_dir_entry(self, path: Path) -> DotfileEntry | None: + """Create a DotfileEntry for a directory.""" + file_count: int | None = None + try: + file_count = len(list(path.iterdir())) + except PermissionError: + logger.debug("Permission denied counting files in: %s", path) + + sensitive = self._is_sensitive_path(path) + + return DotfileEntry( + path=path, + is_directory=True, + file_count=file_count, + sensitive=sensitive, + ) + def _make_entry(self, path: Path, home: Path) -> DotfileEntry | None: symlink_target: Path | None = None managed_by = DotfileManager.MANUAL + sensitive = self._is_sensitive_path(path) try: if path.is_symlink(): @@ -81,27 +166,50 @@ def _make_entry(self, path: Path, home: Path) -> DotfileEntry | None: logger.warning("Error reading symlink %s: %s", path, exc) managed_by = DotfileManager.UNKNOWN - content_hash = hash_file(path) + content_hash = None if sensitive else hash_file(path) return DotfileEntry( path=path, content_hash=content_hash, managed_by=managed_by, symlink_target=symlink_target, + sensitive=sensitive, ) + @staticmethod + def _is_sensitive_path(path: Path) -> bool: + """Check if a path is a known sensitive directory or file.""" + name = path.name + if name in _SENSITIVE_DIRS or name in _SENSITIVE_FILES: + return True + # Check relative paths for nested sensitive locations + try: + home = Path.home() + rel = path.relative_to(home) + rel_str = str(rel) + return rel_str in _SENSITIVE_DIR_PATHS or rel_str in _SENSITIVE_FILE_PATHS + except ValueError: + return False + def _detect_manager(self, target: Path, home: Path) -> DotfileManager: # Check for GNU Stow - try: - stow_ignore = target.parent / ".stow-local-ignore" - if stow_ignore.exists(): - return DotfileManager.STOW - # Check if 'stow' appears in parent chain - for parent in target.parents: - if "stow" in parent.name.lower(): - return DotfileManager.STOW - except OSError: - pass + if self._is_stow_managed(target): + return DotfileManager.STOW + + # Check for chezmoi + chezmoi_dir = home / ".local" / "share" / "chezmoi" + if chezmoi_dir.is_dir() and target.is_relative_to(chezmoi_dir): + return DotfileManager.CHEZMOI + + # Check for yadm + for yadm_dir in [home / ".local" / "share" / "yadm", home / ".config" / "yadm"]: + if yadm_dir.is_dir() and target.is_relative_to(yadm_dir): + return DotfileManager.YADM + + # Check for home-manager + for hm_path in [home / ".config" / "home-manager", home / ".config" / "nixpkgs" / "home.nix"]: + if hm_path.exists() and target.is_relative_to(hm_path.parent if hm_path.is_file() else hm_path): + return DotfileManager.HOME_MANAGER # Check for git-managed dotfiles for dotfiles_dir in [home / ".dotfiles", home / "dotfiles"]: @@ -109,3 +217,29 @@ def _detect_manager(self, target: Path, home: Path) -> DotfileManager: return DotfileManager.GIT return DotfileManager.UNKNOWN + + @staticmethod + def _detect_global_manager(home: Path) -> DotfileManager | None: + """Detect if a global dotfile manager is in use (non-symlink detection).""" + try: + if (home / ".local" / "share" / "chezmoi").is_dir() or (home / ".chezmoiroot").is_file(): + return DotfileManager.CHEZMOI + if (home / ".local" / "share" / "yadm").is_dir() or (home / ".config" / "yadm").is_dir(): + return DotfileManager.YADM + if (home / ".config" / "home-manager").is_dir() or (home / ".config" / "nixpkgs" / "home.nix").is_file(): + return DotfileManager.HOME_MANAGER + if (home / ".rcrc").is_file(): + return DotfileManager.RCM + except PermissionError: + logger.debug("Permission denied detecting global dotfile manager") + return None + + @staticmethod + def _is_stow_managed(target: Path) -> bool: + """Check if a symlink target is managed by GNU Stow.""" + try: + if (target.parent / ".stow-local-ignore").exists(): + return True + return any("stow" in parent.name.lower() for parent in target.parents) + except OSError: + return False diff --git a/src/mac2nix/scanners/fonts.py b/src/mac2nix/scanners/fonts.py index 5886063..08c1d13 100644 --- a/src/mac2nix/scanners/fonts.py +++ b/src/mac2nix/scanners/fonts.py @@ -5,7 +5,7 @@ import logging from pathlib import Path -from mac2nix.models.files import FontEntry, FontSource, FontsResult +from mac2nix.models.files import FontCollection, FontEntry, FontSource, FontsResult from mac2nix.scanners.base import BaseScannerPlugin, register logger = logging.getLogger(__name__) @@ -45,4 +45,19 @@ def scan(self) -> FontsResult: ) ) - return FontsResult(entries=entries) + collections = self._get_font_collections() + return FontsResult(entries=entries, collections=collections) + + def _get_font_collections(self) -> list[FontCollection]: + """Scan ~/Library/FontCollections/ for font collection files.""" + collections_dir = Path.home() / "Library" / "FontCollections" + if not collections_dir.is_dir(): + return [] + collections: list[FontCollection] = [] + try: + for path in sorted(collections_dir.iterdir()): + if path.is_file() and path.suffix.lower() == ".collection": + collections.append(FontCollection(name=path.stem, path=path)) + except PermissionError: + logger.warning("Permission denied reading font collections: %s", collections_dir) + return collections diff --git a/src/mac2nix/scanners/homebrew.py b/src/mac2nix/scanners/homebrew.py index 7c1f6a6..91eb301 100644 --- a/src/mac2nix/scanners/homebrew.py +++ b/src/mac2nix/scanners/homebrew.py @@ -5,8 +5,9 @@ import logging import re import shutil +from pathlib import Path -from mac2nix.models.application import BrewCask, BrewFormula, HomebrewState, MasApp +from mac2nix.models.application import BrewCask, BrewFormula, BrewService, HomebrewState, MasApp from mac2nix.scanners._utils import run_command from mac2nix.scanners.base import BaseScannerPlugin, register @@ -37,7 +38,22 @@ def scan(self) -> HomebrewState: formulae = [f.model_copy(update={"version": versions.get(f.name, f.version)}) for f in formulae] casks = [c.model_copy(update={"version": versions.get(c.name, c.version)}) for c in casks] - return HomebrewState(taps=taps, formulae=formulae, casks=casks, mas_apps=mas_apps) + # Mark pinned formulae + pinned_names = self._get_pinned() + if pinned_names: + formulae = [f.model_copy(update={"pinned": f.name in pinned_names}) for f in formulae] + + services = self._get_services() + prefix = self._get_prefix() + + return HomebrewState( + taps=taps, + formulae=formulae, + casks=casks, + mas_apps=mas_apps, + services=services, + prefix=prefix, + ) def _parse_brewfile( self, @@ -96,3 +112,42 @@ def _get_versions(self) -> dict[str, str]: if len(parts) >= 2: versions[parts[0]] = parts[-1] return versions + + def _get_pinned(self) -> set[str]: + """Get set of pinned formula names.""" + result = run_command(["brew", "list", "--pinned"]) + if result is None or result.returncode != 0: + return set() + return {line.strip() for line in result.stdout.splitlines() if line.strip()} + + def _get_services(self) -> list[BrewService]: + """Parse brew services list output.""" + result = run_command(["brew", "services", "list"]) + if result is None or result.returncode != 0: + return [] + + services: list[BrewService] = [] + for line in result.stdout.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("Name"): + continue + parts = stripped.split() + if len(parts) < 2: + continue + name = parts[0] + status = parts[1] + user = parts[2] if len(parts) >= 3 and parts[2] != "none" else None + plist_str = parts[3] if len(parts) >= 4 and parts[3] != "none" else None + plist_path = Path(plist_str) if plist_str else None + services.append( + BrewService(name=name, status=status, user=user, plist_path=plist_path) + ) + return services + + def _get_prefix(self) -> str | None: + """Get Homebrew prefix path.""" + result = run_command(["brew", "--prefix"]) + if result is None or result.returncode != 0: + return None + prefix = result.stdout.strip() + return prefix or None diff --git a/src/mac2nix/scanners/launch_agents.py b/src/mac2nix/scanners/launch_agents.py index b5345d7..f13e0a3 100644 --- a/src/mac2nix/scanners/launch_agents.py +++ b/src/mac2nix/scanners/launch_agents.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import logging import os import re @@ -22,6 +23,8 @@ "daemon": LaunchAgentSource.DAEMON, } +_SENSITIVE_ENV_PATTERNS = {"_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH"} + @register("launch_agents") class LaunchAgentsScanner(BaseScannerPlugin): @@ -60,6 +63,22 @@ def _parse_agent_data( program_arguments = data.get("ProgramArguments", []) run_at_load = data.get("RunAtLoad", False) + # Deep copy data for raw_plist to avoid mutating the shared cache + raw_plist = copy.deepcopy(data) + # Redact sensitive environment variables in raw_plist + self._redact_sensitive_env(raw_plist) + + # Extract filtered environment variables + env_vars = data.get("EnvironmentVariables") + filtered_env: dict[str, str] | None = None + if isinstance(env_vars, dict): + filtered_env = {} + for key, val in env_vars.items(): + if any(p in key.upper() for p in _SENSITIVE_ENV_PATTERNS): + filtered_env[key] = "***REDACTED***" + else: + filtered_env[key] = str(val) + return LaunchAgentEntry( label=label, program=program, @@ -67,8 +86,33 @@ def _parse_agent_data( run_at_load=run_at_load, source=source, plist_path=plist_path, + raw_plist=raw_plist, + working_directory=data.get("WorkingDirectory"), + environment_variables=filtered_env, + keep_alive=data.get("KeepAlive"), + start_interval=data.get("StartInterval"), + start_calendar_interval=data.get("StartCalendarInterval"), + watch_paths=data.get("WatchPaths", []), + queue_directories=data.get("QueueDirectories", []), + stdout_path=data.get("StandardOutPath"), + stderr_path=data.get("StandardErrorPath"), + throttle_interval=data.get("ThrottleInterval"), + process_type=data.get("ProcessType"), + nice=data.get("Nice"), + user_name=data.get("UserName"), + group_name=data.get("GroupName"), ) + @staticmethod + def _redact_sensitive_env(plist: dict[str, Any]) -> None: + """Redact sensitive keys from EnvironmentVariables in the plist dict.""" + env_vars = plist.get("EnvironmentVariables") + if not isinstance(env_vars, dict): + return + for key in list(env_vars.keys()): + if any(p in key.upper() for p in _SENSITIVE_ENV_PATTERNS): + env_vars[key] = "***REDACTED***" + def _get_login_items(self) -> list[LaunchAgentEntry]: """Parse login items from sfltool dumpbtm text output. diff --git a/src/mac2nix/scanners/library_audit.py b/src/mac2nix/scanners/library_audit.py new file mode 100644 index 0000000..e043c54 --- /dev/null +++ b/src/mac2nix/scanners/library_audit.py @@ -0,0 +1,518 @@ +"""Library audit scanner — discovers uncovered ~/Library and /Library content.""" + +from __future__ import annotations + +import logging +import os +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from mac2nix.models.files import ( + BundleEntry, + KeyBindingEntry, + LibraryAuditResult, + LibraryDirEntry, + LibraryFileEntry, + WorkflowEntry, +) +from mac2nix.scanners._utils import hash_file, read_plist_safe, run_command +from mac2nix.scanners.base import BaseScannerPlugin, register + +logger = logging.getLogger(__name__) + +_COVERED_DIRS: dict[str, str] = { + "Preferences": "preferences", + "Application Support": "app_config", + "Fonts": "fonts", + "LaunchAgents": "launch_agents", + "Containers": "preferences+app_config", + "Group Containers": "app_config", + "FontCollections": "fonts", + "SyncedPreferences": "preferences", +} + +_TRANSIENT_DIRS = frozenset({ + "Caches", + "Logs", + "Saved Application State", + "Cookies", + "HTTPStorages", + "WebKit", + "Messages", + "Calendars", + "Reminders", + "Metadata", + "Updates", + "Autosave Information", +}) + +_SENSITIVE_KEY_PATTERNS = {"_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH"} + +_MAX_FILES_PER_DIR = 200 + +_SYSTEM_COVERED_DIRS = frozenset({ + "Preferences", + "LaunchAgents", + "LaunchDaemons", + "Fonts", +}) + +_SYSTEM_SCAN_PATTERNS: dict[str, str] = { + "Extensions": "*.kext", + "PreferencePanes": "*.prefPane", + "Screen Savers": "*.saver", + "QuickLook": "*.qlgenerator", +} + +_BUNDLE_EXTENSIONS = frozenset({ + ".component", + ".vst", + ".saver", + ".prefPane", + ".qlgenerator", + ".plugin", + ".kext", +}) + + +def _redact_sensitive_keys(data: dict[str, Any]) -> None: + """Recursively redact sensitive keys from a plist dict.""" + for key in list(data.keys()): + if any(p in key.upper() for p in _SENSITIVE_KEY_PATTERNS): + data[key] = "***REDACTED***" + elif isinstance(data[key], dict): + _redact_sensitive_keys(data[key]) + elif isinstance(data[key], list): + for item in data[key]: + if isinstance(item, dict): + _redact_sensitive_keys(item) + + +@register("library_audit") +class LibraryAuditScanner(BaseScannerPlugin): + @property + def name(self) -> str: + return "library_audit" + + def scan(self) -> LibraryAuditResult: + home_lib = Path.home() / "Library" + directories = self._audit_directories(home_lib) + uncovered_files: list[LibraryFileEntry] = [] + workflows: list[WorkflowEntry] = [] + key_bindings = self._scan_key_bindings(home_lib) + spelling_words, spelling_dicts = self._scan_spelling(home_lib) + text_replacements = self._scan_text_replacements(home_lib) + input_methods = self._scan_bundles_in_dir(home_lib / "Input Methods") + keyboard_layouts = self._scan_file_hashes(home_lib / "Keyboard Layouts", ".keylayout") + color_profiles = self._scan_file_hashes(home_lib / "ColorSync" / "Profiles", ".icc", ".icm") + compositions = self._scan_file_hashes(home_lib / "Compositions", ".qtz") + scripts = self._scan_scripts(home_lib) + + # Capture uncovered files and workflows from uncovered directories + for d in directories: + if d.covered_by_scanner is None and d.name not in _TRANSIENT_DIRS: + files, wf = self._capture_uncovered_dir(d.path) + uncovered_files.extend(files) + workflows.extend(wf) + + # Scan workflows from known Workflows/Services dirs + for wf_dir_name in ["Workflows", "Services"]: + wf_dir = home_lib / wf_dir_name + if wf_dir.is_dir(): + workflows.extend(self._scan_workflows(wf_dir)) + + system_bundles = self._scan_system_library() + + return LibraryAuditResult( + directories=directories, + uncovered_files=uncovered_files, + workflows=workflows, + key_bindings=key_bindings, + spelling_words=spelling_words, + spelling_dictionaries=spelling_dicts, + input_methods=input_methods, + keyboard_layouts=keyboard_layouts, + color_profiles=color_profiles, + compositions=compositions, + scripts=scripts, + text_replacements=text_replacements, + system_bundles=system_bundles, + ) + + def _audit_directories(self, lib_path: Path) -> list[LibraryDirEntry]: + """Walk top-level ~/Library directories and collect metadata.""" + if not lib_path.is_dir(): + return [] + + entries: list[LibraryDirEntry] = [] + try: + for child in sorted(lib_path.iterdir()): + if not child.is_dir(): + continue + covered = _COVERED_DIRS.get(child.name) + file_count, total_size, newest_mod = self._dir_stats(child) + entries.append( + LibraryDirEntry( + name=child.name, + path=child, + file_count=file_count, + total_size_bytes=total_size, + covered_by_scanner=covered, + has_user_content=covered is None and child.name not in _TRANSIENT_DIRS, + newest_modification=newest_mod, + ) + ) + except PermissionError: + logger.warning("Permission denied reading: %s", lib_path) + + return entries + + @staticmethod + def _dir_stats(path: Path) -> tuple[int | None, int | None, datetime | None]: + """Get file count, total size, and newest modification for a directory.""" + try: + file_count = 0 + total_size = 0 + newest = 0.0 + for entry in path.iterdir(): + try: + st = entry.stat() + file_count += 1 + total_size += st.st_size + newest = max(newest, st.st_mtime) + except OSError: + continue + newest_dt = datetime.fromtimestamp(newest, tz=UTC) if newest > 0 else None + return file_count, total_size, newest_dt + except PermissionError: + return None, None, None + + def _capture_uncovered_dir( + self, dir_path: Path + ) -> tuple[list[LibraryFileEntry], list[WorkflowEntry]]: + """Capture files from an uncovered directory (capped).""" + files: list[LibraryFileEntry] = [] + workflows: list[WorkflowEntry] = [] + count = 0 + + try: + for dirpath, dirnames, filenames in os.walk(dir_path, followlinks=False): + for filename in filenames: + if count >= _MAX_FILES_PER_DIR: + logger.warning( + "Reached %d file cap for directory: %s", + _MAX_FILES_PER_DIR, + dir_path, + ) + return files, workflows + filepath = Path(dirpath) / filename + entry = self._classify_file(filepath) + if entry is not None: + files.append(entry) + count += 1 + # Check dirnames for workflow bundles (they're directories, not files) + for dirname in list(dirnames): + if dirname.endswith(".workflow"): + wf_path = Path(dirpath) / dirname + wf = self._parse_workflow(wf_path) + if wf is not None: + workflows.append(wf) + # Remove from dirnames to prevent walking into the bundle + dirnames.remove(dirname) + # Skip transient/cache subdirectories + dirnames[:] = [ + d for d in dirnames + if d not in {"Caches", "Cache", "Logs", "tmp", "__pycache__"} + ] + except PermissionError: + logger.warning("Permission denied walking: %s", dir_path) + + return files, workflows + + def _classify_file(self, filepath: Path) -> LibraryFileEntry | None: + """Classify and capture a file from an uncovered directory.""" + try: + stat = filepath.stat() + except OSError: + return None + + size = stat.st_size + suffix = filepath.suffix.lower() + file_type = suffix.lstrip(".") if suffix else "unknown" + content_hash = hash_file(filepath) + plist_content: dict[str, Any] | None = None + text_content: str | None = None + strategy = "hash_only" + + if suffix == ".plist": + plist_content = read_plist_safe(filepath) + if plist_content is not None: + _redact_sensitive_keys(plist_content) + strategy = "plist_capture" + elif suffix in {".txt", ".md", ".cfg", ".conf", ".ini", ".yaml", ".yml", ".json", ".xml"}: + if size < 65536: + try: + text_content = filepath.read_text(errors="replace") + strategy = "text_capture" + except OSError: + pass + elif suffix in _BUNDLE_EXTENSIONS: + strategy = "bundle" + + return LibraryFileEntry( + path=filepath, + file_type=file_type, + content_hash=content_hash, + plist_content=plist_content, + text_content=text_content, + migration_strategy=strategy, + size_bytes=size, + ) + + def _scan_key_bindings(self, lib_path: Path) -> list[KeyBindingEntry]: + """Read DefaultKeyBinding.dict from KeyBindings directory.""" + kb_file = lib_path / "KeyBindings" / "DefaultKeyBinding.dict" + if not kb_file.is_file(): + return [] + + data = read_plist_safe(kb_file) + if not isinstance(data, dict): + return [] + + entries: list[KeyBindingEntry] = [] + for key, action in data.items(): + if isinstance(action, (str, dict)): + entries.append(KeyBindingEntry(key=key, action=action)) + return entries + + def _scan_spelling(self, lib_path: Path) -> tuple[list[str], list[str]]: + """Read user spelling words and dictionaries.""" + words: list[str] = [] + dicts: list[str] = [] + spelling_dir = lib_path / "Spelling" + if not spelling_dir.is_dir(): + return words, dicts + + local_dict = spelling_dir / "LocalDictionary" + if local_dict.is_file(): + try: + content = local_dict.read_text() + words = [w.strip() for w in content.splitlines() if w.strip()] + except OSError: + pass + + try: + for f in sorted(spelling_dir.iterdir()): + if f.is_file() and f.name != "LocalDictionary": + dicts.append(f.name) + except PermissionError: + pass + + return words, dicts + + def _scan_text_replacements(self, lib_path: Path) -> list[dict[str, str]]: + """Read text replacements from TextReplacements.db.""" + db_path = lib_path / "KeyboardServices" / "TextReplacements.db" + if not db_path.is_file(): + return [] + + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro&immutable=1", uri=True) + try: + cursor = conn.execute( + "SELECT ZSHORTCUT, ZPHRASE FROM ZTEXTREPLACEMENTENTRY" + ) + return [ + {"shortcut": row[0], "phrase": row[1]} + for row in cursor.fetchall() + if row[0] and row[1] + ] + finally: + conn.close() + except (sqlite3.OperationalError, sqlite3.DatabaseError) as exc: + logger.warning("Failed to read TextReplacements.db: %s", exc) + return [] + + def _scan_workflows(self, wf_dir: Path) -> list[WorkflowEntry]: + """Scan .workflow bundles in a directory.""" + workflows: list[WorkflowEntry] = [] + if not wf_dir.is_dir(): + return workflows + try: + for item in sorted(wf_dir.iterdir()): + if item.suffix == ".workflow" and item.is_dir(): + wf = self._parse_workflow(item) + if wf is not None: + workflows.append(wf) + except PermissionError: + pass + return workflows + + @staticmethod + def _parse_workflow(wf_path: Path) -> WorkflowEntry | None: + """Parse a .workflow bundle.""" + info_plist = wf_path / "Contents" / "Info.plist" + identifier: str | None = None + definition: dict[str, Any] | None = None + + if info_plist.is_file(): + data = read_plist_safe(info_plist) + if isinstance(data, dict): + identifier = data.get("CFBundleIdentifier") + + doc_plist = wf_path / "Contents" / "document.wflow" + if doc_plist.is_file(): + definition = read_plist_safe(doc_plist) + + return WorkflowEntry( + name=wf_path.stem, + path=wf_path, + identifier=identifier, + workflow_definition=definition, + ) + + def _scan_bundles_in_dir(self, dir_path: Path) -> list[BundleEntry]: + """Scan bundles (by reading Info.plist) in a directory.""" + if not dir_path.is_dir(): + return [] + bundles: list[BundleEntry] = [] + try: + for item in sorted(dir_path.iterdir()): + if not item.is_dir(): + continue + info_plist = item / "Contents" / "Info.plist" + if not info_plist.is_file(): + info_plist = item / "Info.plist" + bundle_id: str | None = None + version: str | None = None + if info_plist.is_file(): + data = read_plist_safe(info_plist) + if isinstance(data, dict): + bundle_id = data.get("CFBundleIdentifier") + version = data.get("CFBundleShortVersionString") + bundles.append( + BundleEntry( + name=item.name, + path=item, + bundle_id=bundle_id, + version=version, + bundle_type=item.suffix.lstrip(".") if item.suffix else None, + ) + ) + except PermissionError: + logger.debug("Permission denied reading: %s", dir_path) + return bundles + + @staticmethod + def _scan_file_hashes(dir_path: Path, *extensions: str) -> list[str]: + """Scan files in a directory and return their names.""" + if not dir_path.is_dir(): + return [] + results: list[str] = [] + try: + for f in sorted(dir_path.iterdir()): + if f.is_file() and (not extensions or f.suffix.lower() in extensions): + results.append(f.name) + except PermissionError: + pass + return results + + def _scan_scripts(self, lib_path: Path) -> list[str]: + """Scan Scripts directory for script files.""" + scripts_dir = lib_path / "Scripts" + if not scripts_dir.is_dir(): + return [] + + scripts: list[str] = [] + try: + for f in sorted(scripts_dir.iterdir()): + if f.is_file(): + if f.suffix == ".scpt": + # Try to decompile AppleScript + result = run_command( + ["osadecompile", str(f)], timeout=10 + ) + if result is not None and result.returncode == 0: + scripts.append(f"{f.name}: {result.stdout[:200]}") + else: + scripts.append(f.name) + else: + scripts.append(f.name) + except PermissionError: + pass + return scripts + + def _scan_system_library(self) -> list[BundleEntry]: + """Scan /Library/ for user-installed items.""" + system_lib = Path("/Library") + if not system_lib.is_dir(): + return [] + + bundles: list[BundleEntry] = [] + + # Scan specific directories for bundles + for dir_name, pattern in _SYSTEM_SCAN_PATTERNS.items(): + scan_dir = system_lib / dir_name + if not scan_dir.is_dir(): + continue + try: + for item in sorted(scan_dir.glob(pattern)): + if item.is_dir(): + bundle = self._parse_system_bundle(item) + if bundle is not None: + bundles.append(bundle) + except PermissionError: + logger.debug("Permission denied reading: %s", scan_dir) + + bundles.extend(self._scan_audio_plugins(system_lib / "Audio" / "Plug-Ins")) + + # Input Methods and Keyboard Layouts + for dir_name in ["Input Methods", "Keyboard Layouts"]: + scan_dir = system_lib / dir_name + if scan_dir.is_dir(): + bundles.extend(self._scan_bundles_in_dir(scan_dir)) + + return bundles + + def _scan_audio_plugins(self, audio_plugins: Path) -> list[BundleEntry]: + """Scan /Library/Audio/Plug-Ins for audio component bundles.""" + if not audio_plugins.is_dir(): + return [] + bundles: list[BundleEntry] = [] + try: + for subdir in sorted(audio_plugins.iterdir()): + if subdir.is_dir(): + for item in sorted(subdir.iterdir()): + if item.is_dir() and item.suffix in _BUNDLE_EXTENSIONS: + bundle = self._parse_system_bundle(item) + if bundle is not None: + bundles.append(bundle) + except PermissionError: + pass + return bundles + + @staticmethod + def _parse_system_bundle(item: Path) -> BundleEntry | None: + """Parse a system-level bundle.""" + info_plist = item / "Contents" / "Info.plist" + if not info_plist.is_file(): + info_plist = item / "Info.plist" + + bundle_id: str | None = None + version: str | None = None + + if info_plist.is_file(): + data = read_plist_safe(info_plist) + if isinstance(data, dict): + bundle_id = data.get("CFBundleIdentifier") + version = data.get("CFBundleShortVersionString") + + return BundleEntry( + name=item.name, + path=item, + bundle_id=bundle_id, + version=version, + bundle_type=item.suffix.lstrip(".") if item.suffix else None, + ) diff --git a/src/mac2nix/scanners/network.py b/src/mac2nix/scanners/network.py index 9b48444..aa0dadf 100644 --- a/src/mac2nix/scanners/network.py +++ b/src/mac2nix/scanners/network.py @@ -6,12 +6,14 @@ import re import shutil -from mac2nix.models.system import NetworkConfig, NetworkInterface +from mac2nix.models.system import NetworkConfig, NetworkInterface, VpnProfile from mac2nix.scanners._utils import run_command from mac2nix.scanners.base import BaseScannerPlugin, register logger = logging.getLogger(__name__) +_FLAGS_PATTERN = re.compile(r"flags=\w+<([^>]*)>") + @register("network") class NetworkScanner(BaseScannerPlugin): @@ -23,10 +25,14 @@ def is_available(self) -> bool: return shutil.which("networksetup") is not None def scan(self) -> NetworkConfig: - interfaces = self._get_interfaces() + ip_map, ipv6_map, active_map = self._parse_ifconfig() + interfaces = self._get_interfaces(ip_map, ipv6_map, active_map) dns_servers, search_domains = self._get_dns() proxy_settings = self._get_proxy_settings(interfaces) + proxy_bypass_domains = self._get_proxy_bypass_domains(interfaces) wifi_networks = self._get_wifi_networks(interfaces) + vpn_profiles = self._get_vpn_profiles() + locations, current_location = self._get_locations() return NetworkConfig( interfaces=interfaces, @@ -34,17 +40,24 @@ def scan(self) -> NetworkConfig: search_domains=search_domains, proxy_settings=proxy_settings, wifi_networks=wifi_networks, + vpn_profiles=vpn_profiles, + proxy_bypass_domains=proxy_bypass_domains, + locations=locations, + current_location=current_location, ) - def _get_interfaces(self) -> list[NetworkInterface]: + def _get_interfaces( + self, + ip_map: dict[str, str], + ipv6_map: dict[str, str], + active_map: dict[str, bool], + ) -> list[NetworkInterface]: """Get all network interfaces in a single subprocess call.""" result = run_command(["networksetup", "-listallhardwareports"]) if result is None or result.returncode != 0: return [] interfaces: list[NetworkInterface] = [] - ip_map = self._get_ip_addresses() - current_port: str | None = None current_device: str | None = None @@ -55,14 +68,16 @@ def _get_interfaces(self) -> list[NetworkInterface]: elif stripped.startswith("Device:"): current_device = stripped.split(":", 1)[1].strip() elif stripped.startswith("Ethernet Address:"): - # End of this interface block — emit the entry if current_port: + dev = current_device or "" interfaces.append( NetworkInterface( name=current_port, hardware_port=current_port, device=current_device, - ip_address=ip_map.get(current_device or ""), + ip_address=ip_map.get(dev), + ipv6_address=ipv6_map.get(dev), + is_active=active_map.get(dev), ) ) current_port = None @@ -70,24 +85,39 @@ def _get_interfaces(self) -> list[NetworkInterface]: return interfaces - def _get_ip_addresses(self) -> dict[str, str]: - """Get IP addresses for all interfaces via ifconfig (single call).""" + def _parse_ifconfig(self) -> tuple[dict[str, str], dict[str, str], dict[str, bool]]: + """Parse ifconfig output for IPv4, IPv6, and active status.""" result = run_command(["ifconfig"]) if result is None or result.returncode != 0: - return {} + return {}, {}, {} ip_map: dict[str, str] = {} + ipv6_map: dict[str, str] = {} + active_map: dict[str, bool] = {} current_iface = "" + for raw_line in result.stdout.splitlines(): - # Interface header lines start at column 0 if raw_line and not raw_line[0].isspace() and ":" in raw_line: current_iface = raw_line.split(":")[0] - elif "inet " in raw_line and current_iface: - match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", raw_line) - if match and match.group(1) != "127.0.0.1": - ip_map[current_iface] = match.group(1) + # Parse flags for UP status + flags_match = _FLAGS_PATTERN.search(raw_line) + if flags_match: + flags = flags_match.group(1) + active_map[current_iface] = "UP" in flags.split(",") + elif current_iface: + if "inet " in raw_line: + match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", raw_line) + if match and match.group(1) != "127.0.0.1": + ip_map[current_iface] = match.group(1) + elif "inet6 " in raw_line: + match = re.search(r"inet6\s+(\S+)", raw_line) + if match: + addr = match.group(1).split("%")[0] + # Skip link-local addresses + if not addr.startswith("fe80:"): + ipv6_map[current_iface] = addr - return ip_map + return ip_map, ipv6_map, active_map def _get_dns(self) -> tuple[list[str], list[str]]: result = run_command(["scutil", "--dns"]) @@ -115,12 +145,14 @@ def _get_dns(self) -> tuple[list[str], list[str]]: def _get_proxy_settings(self, interfaces: list[NetworkInterface]) -> dict[str, str]: proxy: dict[str, str] = {} - # Try Wi-Fi service first, fall back to first interface - service = "Wi-Fi" - if not any(i.name == "Wi-Fi" for i in interfaces) and interfaces: - service = interfaces[0].name + service = self._get_proxy_service(interfaces) - for proxy_type, flag in [("http", "-getwebproxy"), ("https", "-getsecurewebproxy")]: + for proxy_type, flag in [ + ("http", "-getwebproxy"), + ("https", "-getsecurewebproxy"), + ("socks", "-getsocksfirewallproxy"), + ("ftp", "-getftpproxy"), + ]: result = run_command(["networksetup", flag, service]) if result is None or result.returncode != 0: continue @@ -139,26 +171,103 @@ def _get_proxy_settings(self, interfaces: list[NetworkInterface]) -> dict[str, s return proxy + def _get_proxy_bypass_domains(self, interfaces: list[NetworkInterface]) -> list[str]: + """Get proxy bypass domains.""" + service = self._get_proxy_service(interfaces) + result = run_command(["networksetup", "-getproxybypassdomains", service]) + if result is None or result.returncode != 0: + return [] + domains = [] + for line in result.stdout.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("There"): + domains.append(stripped) + return domains + + @staticmethod + def _get_proxy_service(interfaces: list[NetworkInterface]) -> str: + """Determine which network service to query for proxy settings.""" + if any(i.name == "Wi-Fi" for i in interfaces): + return "Wi-Fi" + if interfaces: + return interfaces[0].name + return "Wi-Fi" + def _get_wifi_networks(self, interfaces: list[NetworkInterface]) -> list[str]: - networks: list[str] = [] - # Find Wi-Fi device name + """Get all saved WiFi networks.""" wifi_device = None for iface in interfaces: if iface.name == "Wi-Fi" and iface.device: wifi_device = iface.device break - - # Fallback: en0 is typically Wi-Fi on laptops but may be Ethernet on - # Mac Pro/Mac Studio — the query will just return empty in that case. if wifi_device is None: wifi_device = "en0" + # Try preferred networks list first (gets all saved networks) + result = run_command( + ["networksetup", "-listpreferredwirelessnetworks", wifi_device] + ) + if result is not None and result.returncode == 0: + networks = [] + for line in result.stdout.splitlines(): + stripped = line.strip() + # Skip header line + if stripped.startswith("Preferred networks"): + continue + if stripped: + networks.append(stripped) + if networks: + return networks + + # Fallback to current network only result = run_command(["networksetup", "-getairportnetwork", wifi_device]) if result is not None and result.returncode == 0: output = result.stdout.strip() if "Current Wi-Fi Network:" in output: network = output.split(":", 1)[1].strip() if network: - networks.append(network) + return [network] + + return [] + + def _get_vpn_profiles(self) -> list[VpnProfile]: + """Get VPN profiles from scutil --nc list.""" + result = run_command(["scutil", "--nc", "list"]) + if result is None or result.returncode != 0: + return [] + + profiles: list[VpnProfile] = [] + # Lines like: * (Connected) UUID "VPN Name" [IPSec] + vpn_pattern = re.compile( + r'^\*\s+\((\w+)\)\s+\S+\s+"([^"]+)"\s+\[(\w+)\]' + ) + for line in result.stdout.splitlines(): + match = vpn_pattern.match(line.strip()) + if match: + profiles.append( + VpnProfile( + name=match.group(2), + status=match.group(1), + protocol=match.group(3), + ) + ) + return profiles + + def _get_locations(self) -> tuple[list[str], str | None]: + """Get network locations and current location.""" + locations: list[str] = [] + result = run_command(["networksetup", "-listlocations"]) + if result is not None and result.returncode == 0: + for line in result.stdout.splitlines(): + stripped = line.strip() + if stripped: + locations.append(stripped) + + current_location: str | None = None + result = run_command(["networksetup", "-getcurrentlocation"]) + if result is not None and result.returncode == 0: + loc = result.stdout.strip() + if loc: + current_location = loc - return networks + return locations, current_location diff --git a/src/mac2nix/scanners/preferences.py b/src/mac2nix/scanners/preferences.py index 56805ee..c1a1d34 100644 --- a/src/mac2nix/scanners/preferences.py +++ b/src/mac2nix/scanners/preferences.py @@ -3,22 +3,25 @@ from __future__ import annotations import logging +import plistlib from pathlib import Path -from mac2nix.models.preferences import PreferencesDomain, PreferencesResult -from mac2nix.scanners._utils import read_plist_safe +from mac2nix.models.preferences import PreferencesDomain, PreferencesResult, PreferenceValue +from mac2nix.scanners._utils import _convert_datetimes, read_plist_safe, run_command from mac2nix.scanners.base import BaseScannerPlugin, register logger = logging.getLogger(__name__) -_PREF_GLOBS: list[tuple[Path, str]] = [ - (Path.home() / "Library" / "Preferences", "*.plist"), - (Path("/Library/Preferences"), "*.plist"), - (Path.home() / "Library" / "Preferences" / "ByHost", "*.plist"), - (Path.home() / "Library" / "Containers", "*/Data/Library/Preferences/*.plist"), +_PREF_GLOBS: list[tuple[Path, str, str]] = [ + (Path.home() / "Library" / "Preferences", "*.plist", "disk"), + (Path("/Library/Preferences"), "*.plist", "disk"), + (Path.home() / "Library" / "Preferences" / "ByHost", "*.plist", "disk"), + (Path.home() / "Library" / "SyncedPreferences", "*.plist", "synced"), + (Path.home() / "Library" / "Containers", "*/Data/Library/Preferences/*.plist", "disk"), ] + @register("preferences") class PreferencesScanner(BaseScannerPlugin): @property @@ -27,8 +30,9 @@ def name(self) -> str: def scan(self) -> PreferencesResult: domains: list[PreferencesDomain] = [] + seen_domains: set[str] = set() - for base_dir, pattern in _PREF_GLOBS: + for base_dir, pattern, source in _PREF_GLOBS: if not base_dir.exists(): continue for plist_path in sorted(base_dir.glob(pattern)): @@ -37,15 +41,61 @@ def scan(self) -> PreferencesResult: data = read_plist_safe(plist_path) if not isinstance(data, dict): continue + domain_name = plist_path.stem + seen_domains.add(domain_name) domains.append( PreferencesDomain( - domain_name=plist_path.stem, + domain_name=domain_name, source_path=plist_path, + source=source, keys=data, ) ) + # Discover cfprefsd-only domains + self._discover_cfprefsd_domains(domains, seen_domains) + if len(domains) > 500: logger.info("Large number of preference domains found: %d", len(domains)) return PreferencesResult(domains=domains) + + def _discover_cfprefsd_domains( + self, domains: list[PreferencesDomain], seen: set[str] + ) -> None: + """Find domains registered in cfprefsd but without on-disk plist files.""" + result = run_command(["defaults", "domains"]) + if result is None or result.returncode != 0: + return + + # Output is comma-separated domain names + all_domains = [d.strip() for d in result.stdout.split(",") if d.strip()] + unseen = [d for d in all_domains if d not in seen] + + for domain_name in unseen: + keys = self._export_domain(domain_name) + if keys is None: + continue + + seen.add(domain_name) + domains.append( + PreferencesDomain( + domain_name=domain_name, + source="cfprefsd", + keys=keys, + ) + ) + + @staticmethod + def _export_domain(domain_name: str) -> dict[str, PreferenceValue] | None: + """Export a cfprefsd-only domain via `defaults export`.""" + result = run_command(["defaults", "export", domain_name, "-"]) + if result is None or result.returncode != 0: + return None + try: + data = plistlib.loads(result.stdout.encode()) + except (plistlib.InvalidFileException, ValueError, KeyError, OverflowError): + return None + if not isinstance(data, dict): + return None + return _convert_datetimes(data) diff --git a/src/mac2nix/scanners/security.py b/src/mac2nix/scanners/security.py index f168c63..badae7f 100644 --- a/src/mac2nix/scanners/security.py +++ b/src/mac2nix/scanners/security.py @@ -3,10 +3,11 @@ from __future__ import annotations import logging +import re import sqlite3 from pathlib import Path -from mac2nix.models.system import SecurityState +from mac2nix.models.system import FirewallAppRule, SecurityState from mac2nix.scanners._utils import run_command from mac2nix.scanners.base import BaseScannerPlugin, register @@ -28,6 +29,11 @@ def scan(self) -> SecurityState: gatekeeper_enabled=self._check_gatekeeper(), firewall_enabled=self._check_firewall(), tcc_summary=self._get_tcc_summary(), + firewall_stealth_mode=self._check_firewall_stealth(), + firewall_app_rules=self._get_firewall_app_rules(), + firewall_block_all_incoming=self._check_firewall_block_all(), + touch_id_sudo=self._check_touch_id_sudo(), + custom_certificates=self._get_custom_certificates(), ) def _check_filevault(self) -> bool | None: @@ -58,6 +64,67 @@ def _check_firewall(self) -> bool | None: return None return "enabled" in result.stdout.lower() + def _check_firewall_stealth(self) -> bool | None: + """Check if firewall stealth mode is enabled.""" + if not Path(_FIREWALL_PATH).exists(): + return None + result = run_command([_FIREWALL_PATH, "--getstealthmode"]) + if result is None or result.returncode != 0: + return None + return "enabled" in result.stdout.lower() + + def _check_firewall_block_all(self) -> bool | None: + """Check if firewall blocks all incoming connections.""" + if not Path(_FIREWALL_PATH).exists(): + return None + result = run_command([_FIREWALL_PATH, "--getblockall"]) + if result is None or result.returncode != 0: + return None + return "enabled" in result.stdout.lower() + + def _get_firewall_app_rules(self) -> list[FirewallAppRule]: + """Get firewall per-app rules.""" + if not Path(_FIREWALL_PATH).exists(): + return [] + result = run_command([_FIREWALL_PATH, "--listapps"]) + if result is None or result.returncode != 0: + return [] + + rules: list[FirewallAppRule] = [] + # Parse lines looking for app path and allow/block indicators + app_path_pattern = re.compile(r"^\d+\s*:\s*(.+)$") + current_path: str | None = None + + for line in result.stdout.splitlines(): + stripped = line.strip() + # Look for numbered app path lines + match = app_path_pattern.match(stripped) + if match: + current_path = match.group(1).strip() + continue + # Look for allow/block status after the path + if current_path and ("Allow" in stripped or "Block" in stripped): + allowed = "Allow" in stripped + rules.append(FirewallAppRule(app_path=current_path, allowed=allowed)) + current_path = None + + return rules + + def _check_touch_id_sudo(self) -> bool | None: + """Check if Touch ID is configured for sudo.""" + for sudo_file in [Path("/etc/pam.d/sudo_local"), Path("/etc/pam.d/sudo")]: + try: + content = sudo_file.read_text() + for line in content.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + continue + if "pam_tid.so" in stripped: + return True + except (PermissionError, OSError): + continue + return None + def _get_tcc_summary(self) -> dict[str, list[str]]: tcc_path = Path.home() / "Library" / "Application Support" / "com.apple.TCC" / "TCC.db" if not tcc_path.exists(): @@ -77,3 +144,36 @@ def _get_tcc_summary(self) -> dict[str, list[str]]: # TCC.db is SIP-protected on most macOS versions — expected failure logger.debug("Failed to read TCC database: %s", exc) return {} + + def _get_custom_certificates(self) -> list[str]: + """Discover custom/corporate certificates in System keychain.""" + result = run_command( + ["security", "find-certificate", "-a", "/Library/Keychains/System.keychain"] + ) + if result is None or result.returncode != 0: + return [] + + # Well-known CA issuers to filter out + known_cas = frozenset({ + "apple", "digicert", "verisign", "entrust", "globalsign", "comodo", + "geotrust", "thawte", "symantec", "godaddy", "letsencrypt", + "usertrust", "sectigo", "baltimore", "cybertrust", "certum", + "starfield", "amazontrust", "microsoftroot", "microsoft", + }) + + certificates: list[str] = [] + cert_name_pattern = re.compile(r'"labl"="(.+)"') + + for line in result.stdout.splitlines(): + match = cert_name_pattern.search(line) + if not match: + continue + name = match.group(1) + # Filter out well-known CAs + name_lower = name.lower().replace(" ", "") + if any(ca in name_lower for ca in known_cas): + continue + if name not in certificates: + certificates.append(name) + + return certificates diff --git a/src/mac2nix/scanners/shell.py b/src/mac2nix/scanners/shell.py index 8899084..e55375b 100644 --- a/src/mac2nix/scanners/shell.py +++ b/src/mac2nix/scanners/shell.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, field from pathlib import Path -from mac2nix.models.services import ShellConfig +from mac2nix.models.services import ShellConfig, ShellFramework from mac2nix.scanners._utils import run_command from mac2nix.scanners.base import BaseScannerPlugin, register @@ -36,6 +36,11 @@ _FUNCTION_PATTERN = re.compile(r"^(?:function\s+)?(\w+)\s*\(\)\s*\{?") _FISH_FUNCTION_PATTERN = re.compile(r"^function\s+(\S+)") +_SOURCE_PATTERN = re.compile(r"^(?:source|\.)\s+(.+)$") +_FISH_SOURCE_PATTERN = re.compile(r"^source\s+(.+)$") +_EVAL_PATTERN = re.compile(r'^eval\s+["\(]|^eval\s+"?\$\(') +_FISH_EVAL_PATTERN = re.compile(r"^eval\s+\(|^\s*\w+\s*\|") + @dataclass class _ParsedShellData: @@ -45,6 +50,8 @@ class _ParsedShellData: env_vars: dict[str, str] = field(default_factory=dict) path_components: list[str] = field(default_factory=list) functions: list[str] = field(default_factory=list) + sourced_files: list[Path] = field(default_factory=list) + dynamic_commands: list[str] = field(default_factory=list) @register("shell") @@ -63,21 +70,35 @@ def scan(self) -> ShellConfig: home = Path.home() rc_files: list[Path] = [] parsed = _ParsedShellData() + seen_files: set[Path] = set() rc_names = _RC_FILES.get(shell_type, []) for rc_name in rc_names: - rc_path = home / rc_name + # Respect XDG_CONFIG_HOME for fish + if shell_type == "fish" and rc_name.startswith(".config/"): + xdg = os.environ.get("XDG_CONFIG_HOME") + rc_path = Path(xdg) / rc_name.removeprefix(".config/") if xdg else home / rc_name + else: + rc_path = home / rc_name if rc_path.is_file(): rc_files.append(rc_path) - self._parse_rc_file(rc_path, shell_type, parsed) + seen_files.add(rc_path.resolve()) + self._parse_rc_file(rc_path, shell_type, parsed, home, seen_files) # Fish functions directory if shell_type == "fish": - func_dir = home / _FISH_FUNCTION_DIR + func_dir = self._get_fish_config_dir(home) / "functions" if func_dir.is_dir(): for func_file in sorted(func_dir.glob("*.fish")): parsed.functions.append(func_file.stem) + # Scan conf.d and completions directories + conf_d_files = self._scan_conf_d(home, shell_type) + completion_files = self._scan_completions(home, shell_type) + + # Detect shell frameworks + frameworks = self._detect_frameworks(home, shell_type) + return ShellConfig( shell_type=shell_type, rc_files=rc_files, @@ -85,6 +106,11 @@ def scan(self) -> ShellConfig: aliases=parsed.aliases, functions=parsed.functions, env_vars=parsed.env_vars, + conf_d_files=conf_d_files, + completion_files=completion_files, + sourced_files=parsed.sourced_files, + frameworks=frameworks, + dynamic_commands=parsed.dynamic_commands, ) @staticmethod @@ -103,7 +129,66 @@ def _get_login_shell() -> str: return os.environ.get("SHELL", "/bin/zsh") - def _parse_rc_file(self, rc_path: Path, shell_type: str, parsed: _ParsedShellData) -> None: + @staticmethod + def _get_fish_config_dir(home: Path) -> Path: + """Get fish config directory, respecting XDG_CONFIG_HOME.""" + xdg = os.environ.get("XDG_CONFIG_HOME") + if xdg: + return Path(xdg) / "fish" + return home / ".config" / "fish" + + def _scan_conf_d(self, home: Path, shell_type: str) -> list[Path]: + """Scan conf.d directories for shell configuration snippets.""" + files: list[Path] = [] + if shell_type == "fish": + conf_d = self._get_fish_config_dir(home) / "conf.d" + if conf_d.is_dir(): + try: + for f in sorted(conf_d.glob("*.fish")): + files.append(f) + except PermissionError: + logger.warning("Permission denied reading: %s", conf_d) + elif shell_type == "zsh": + for zsh_dir in [home / ".zsh", home / ".config" / "zsh"]: + if zsh_dir.is_dir(): + try: + for f in sorted(zsh_dir.iterdir()): + if f.is_file(): + files.append(f) + except PermissionError: + logger.warning("Permission denied reading: %s", zsh_dir) + return files + + def _scan_completions(self, home: Path, shell_type: str) -> list[Path]: + """Scan completions directories.""" + files: list[Path] = [] + if shell_type == "fish": + comp_dir = self._get_fish_config_dir(home) / "completions" + if comp_dir.is_dir(): + try: + for f in sorted(comp_dir.glob("*.fish")): + files.append(f) + except PermissionError: + logger.warning("Permission denied reading: %s", comp_dir) + elif shell_type == "zsh": + for comp_dir in [home / ".zsh" / "completions", home / ".config" / "zsh" / "completions"]: + if comp_dir.is_dir(): + try: + for f in sorted(comp_dir.iterdir()): + if f.is_file(): + files.append(f) + except PermissionError: + logger.warning("Permission denied reading: %s", comp_dir) + return files + + def _parse_rc_file( + self, + rc_path: Path, + shell_type: str, + parsed: _ParsedShellData, + home: Path, + seen_files: set[Path], + ) -> None: try: content = rc_path.read_text() except (PermissionError, OSError) as exc: @@ -117,8 +202,45 @@ def _parse_rc_file(self, rc_path: Path, shell_type: str, parsed: _ParsedShellDat if shell_type == "fish": self._parse_fish_line(stripped, parsed) + self._check_source_fish(stripped, parsed, home, seen_files) else: self._parse_posix_line(stripped, parsed) + self._check_source_posix(stripped, parsed, home, seen_files) + + def _check_source_posix( + self, line: str, parsed: _ParsedShellData, home: Path, seen_files: set[Path] + ) -> None: + match = _SOURCE_PATTERN.match(line) + if not match: + return + self._resolve_and_track_source(match.group(1).strip("'\""), parsed, home, seen_files) + + def _check_source_fish( + self, line: str, parsed: _ParsedShellData, home: Path, seen_files: set[Path] + ) -> None: + match = _FISH_SOURCE_PATTERN.match(line) + if not match: + return + self._resolve_and_track_source(match.group(1).strip("'\""), parsed, home, seen_files) + + def _resolve_and_track_source( + self, raw_path: str, parsed: _ParsedShellData, home: Path, seen_files: set[Path] + ) -> None: + """Resolve a sourced file path and add to sourced_files (one level only).""" + # Expand ~ and $HOME + resolved_str = raw_path.replace("$HOME", str(home)).replace("~", str(home)) + try: + resolved = Path(resolved_str).expanduser().resolve() + except (ValueError, OSError): + return + + if not resolved.is_file(): + return + if resolved in seen_files: + return + + seen_files.add(resolved) + parsed.sourced_files.append(resolved) def _parse_fish_line(self, line: str, parsed: _ParsedShellData) -> None: match = _FISH_ALIAS_PATTERN.match(line) @@ -143,6 +265,11 @@ def _parse_fish_line(self, line: str, parsed: _ParsedShellData) -> None: match = _FISH_FUNCTION_PATTERN.match(line) if match: parsed.functions.append(match.group(1)) + return + + # Detect eval/command substitution + if _FISH_EVAL_PATTERN.match(line): + parsed.dynamic_commands.append(line) def _parse_posix_line(self, line: str, parsed: _ParsedShellData) -> None: match = _ALIAS_PATTERN.match(line) @@ -171,3 +298,79 @@ def _parse_posix_line(self, line: str, parsed: _ParsedShellData) -> None: match = _FUNCTION_PATTERN.match(line) if match: parsed.functions.append(match.group(1)) + return + + # Detect eval/command substitution + if _EVAL_PATTERN.match(line): + parsed.dynamic_commands.append(line) + + def _detect_frameworks(self, home: Path, shell_type: str) -> list[ShellFramework]: + """Detect installed shell frameworks.""" + frameworks: list[ShellFramework] = [] + fish_config = self._get_fish_config_dir(home) + + if shell_type == "fish": + # Oh My Fish + omf_dir = fish_config / "omf" + if not omf_dir.is_dir(): + omf_dir = home / ".local" / "share" / "omf" + if omf_dir.is_dir(): + plugins = self._list_dir_names(omf_dir / "pkg") + theme = self._read_first_line(omf_dir / "theme") + frameworks.append(ShellFramework(name="oh-my-fish", path=omf_dir, plugins=plugins, theme=theme)) + + # Fisher + fish_plugins = fish_config / "fish_plugins" + if fish_plugins.is_file(): + try: + plugins = [line.strip() for line in fish_plugins.read_text().splitlines() if line.strip()] + except OSError: + plugins = [] + frameworks.append(ShellFramework(name="fisher", path=fish_plugins, plugins=plugins)) + + elif shell_type == "zsh": + # Oh My Zsh + omz_dir = home / ".oh-my-zsh" + if omz_dir.is_dir(): + plugins = self._list_dir_names(omz_dir / "custom" / "plugins") + frameworks.append(ShellFramework(name="oh-my-zsh", path=omz_dir, plugins=plugins)) + + # Prezto + prezto_dir = home / ".zprezto" + if prezto_dir.is_dir(): + frameworks.append(ShellFramework(name="prezto", path=prezto_dir)) + + # Starship (works with any shell) + starship_config = home / ".config" / "starship.toml" + if not starship_config.is_file(): + xdg = os.environ.get("XDG_CONFIG_HOME") + if xdg: + starship_config = Path(xdg) / "starship.toml" + if starship_config.is_file(): + frameworks.append(ShellFramework(name="starship", path=starship_config)) + + return frameworks + + @staticmethod + def _list_dir_names(path: Path) -> list[str]: + """List directory names in a path.""" + if not path.is_dir(): + return [] + try: + return sorted(d.name for d in path.iterdir() if d.is_dir()) + except PermissionError: + return [] + + @staticmethod + def _read_first_line(path: Path) -> str | None: + """Read the first non-empty line from a file.""" + if not path.is_file(): + return None + try: + for line in path.read_text().splitlines(): + stripped = line.strip() + if stripped: + return stripped + except OSError: + pass + return None diff --git a/src/mac2nix/scanners/system_scanner.py b/src/mac2nix/scanners/system_scanner.py index 2c3fc98..b5de3d7 100644 --- a/src/mac2nix/scanners/system_scanner.py +++ b/src/mac2nix/scanners/system_scanner.py @@ -1,13 +1,16 @@ -"""System scanner — reads hostname, timezone, locale, power settings, and Spotlight.""" +"""System scanner — reads hostname, timezone, locale, power settings, Spotlight, and system info.""" from __future__ import annotations +import json import logging import shutil +from datetime import UTC, datetime from pathlib import Path +from typing import Any -from mac2nix.models.system import SystemConfig -from mac2nix.scanners._utils import run_command +from mac2nix.models.system import PrinterInfo, SystemConfig, TimeMachineConfig +from mac2nix.scanners._utils import read_plist_safe, run_command from mac2nix.scanners.base import BaseScannerPlugin, register logger = logging.getLogger(__name__) @@ -26,17 +29,48 @@ def is_available(self) -> bool: def scan(self) -> SystemConfig: hostname = self._get_hostname() + local_hostname, dns_hostname = self._get_additional_hostnames() timezone = self._get_timezone() locale = self._get_locale() power_settings = self._get_power_settings() spotlight_indexing = self._get_spotlight_status() + macos_version, macos_build, macos_product_name = self._get_macos_version() + hw_model, hw_chip, hw_memory, hw_serial = self._get_hardware_info() + time_machine = self._get_time_machine() + software_update = self._get_software_update() + sleep_settings = self._get_sleep_settings() + login_window = self._get_login_window() + startup_chime = self._get_startup_chime() + ntp_enabled, ntp_server = self._get_network_time() + printers = self._get_printers() + remote_login, screen_sharing, file_sharing = self._get_remote_access() return SystemConfig( hostname=hostname, + local_hostname=local_hostname, + dns_hostname=dns_hostname, timezone=timezone, locale=locale, power_settings=power_settings, spotlight_indexing=spotlight_indexing, + macos_version=macos_version, + macos_build=macos_build, + macos_product_name=macos_product_name, + hardware_model=hw_model, + hardware_chip=hw_chip, + hardware_memory=hw_memory, + hardware_serial=hw_serial, + time_machine=time_machine, + software_update=software_update, + sleep_settings=sleep_settings, + login_window=login_window, + startup_chime=startup_chime, + network_time_enabled=ntp_enabled, + network_time_server=ntp_server, + printers=printers, + remote_login=remote_login, + screen_sharing=screen_sharing, + file_sharing=file_sharing, ) def _get_hostname(self) -> str: @@ -104,3 +138,262 @@ def _get_spotlight_status(self) -> bool | None: if result is None or result.returncode != 0: return None return "enabled" in result.stdout.lower() + + def _get_macos_version(self) -> tuple[str | None, str | None, str | None]: + """Parse sw_vers output for macOS version info.""" + result = run_command(["sw_vers"]) + if result is None or result.returncode != 0: + return None, None, None + + version: str | None = None + build: str | None = None + product_name: str | None = None + + for line in result.stdout.splitlines(): + if "ProductName:" in line: + product_name = line.split(":", 1)[1].strip() + elif "ProductVersion:" in line: + version = line.split(":", 1)[1].strip() + elif "BuildVersion:" in line: + build = line.split(":", 1)[1].strip() + + return version, build, product_name + + def _get_hardware_info( + self, + ) -> tuple[str | None, str | None, str | None, str | None]: + """Parse system_profiler SPHardwareDataType for hardware info.""" + result = run_command( + ["system_profiler", "SPHardwareDataType", "-json"], timeout=15 + ) + if result is None or result.returncode != 0: + return None, None, None, None + + try: + data = json.loads(result.stdout) + except (json.JSONDecodeError, ValueError): + return None, None, None, None + + hw_list = data.get("SPHardwareDataType", []) + if not hw_list: + return None, None, None, None + + hw = hw_list[0] + model = hw.get("machine_model") or hw.get("machine_name") + chip = hw.get("chip_type") or hw.get("cpu_type") + memory = hw.get("physical_memory") + serial = hw.get("serial_number") + + return model, chip, memory, serial + + def _get_additional_hostnames(self) -> tuple[str | None, str | None]: + """Get LocalHostName and HostName separately.""" + local_hostname: str | None = None + dns_hostname: str | None = None + + result = run_command(["scutil", "--get", "LocalHostName"]) + if result is not None and result.returncode == 0: + local_hostname = result.stdout.strip() or None + + result = run_command(["scutil", "--get", "HostName"]) + if result is not None and result.returncode == 0: + dns_hostname = result.stdout.strip() or None + + return local_hostname, dns_hostname + + def _get_time_machine(self) -> TimeMachineConfig | None: + """Get Time Machine backup configuration.""" + result = run_command(["tmutil", "destinationinfo"]) + if result is None or result.returncode != 0: + return None + + dest_name: str | None = None + dest_id: str | None = None + for line in result.stdout.splitlines(): + stripped = line.strip() + if stripped.startswith("Name"): + dest_name = stripped.split(":", 1)[1].strip() if ":" in stripped else None + elif stripped.startswith("ID"): + dest_id = stripped.split(":", 1)[1].strip() if ":" in stripped else None + + if dest_name is None and dest_id is None: + return TimeMachineConfig(configured=False) + + latest_backup: datetime | None = None + result = run_command(["tmutil", "latestbackup"]) + if result is not None and result.returncode == 0: + backup_path = result.stdout.strip() + if backup_path: + # Extract timestamp from path like /Volumes/.../2026-03-09-123456 + parts = backup_path.rstrip("/").rsplit("/", 1) + date_str = parts[-1] if parts else "" + try: + latest_backup = datetime.strptime(date_str, "%Y-%m-%d-%H%M%S").replace(tzinfo=UTC) + except ValueError: + logger.debug("Could not parse TM backup date: %s", date_str) + + return TimeMachineConfig( + configured=True, + destination_name=dest_name, + destination_id=dest_id, + latest_backup=latest_backup, + ) + + def _get_software_update(self) -> dict[str, Any]: + """Read software update preferences.""" + plist_path = Path("/Library/Preferences/com.apple.SoftwareUpdate.plist") + data = read_plist_safe(plist_path) + if data is None: + return {} + # Extract known keys of interest + keys = [ + "AutomaticCheckEnabled", + "AutomaticDownload", + "AutomaticallyInstallMacOSUpdates", + "CriticalUpdateInstall", + ] + return {k: data[k] for k in keys if k in data} + + def _get_sleep_settings(self) -> dict[str, str | int | None]: + """Read sleep-related systemsetup values.""" + settings: dict[str, str | int | None] = {} + commands = { + "computer_sleep": "-getcomputersleep", + "display_sleep": "-getdisplaysleep", + "hard_disk_sleep": "-getharddisksleep", + "wake_on_network": "-getwakeonnetworkaccess", + "restart_freeze": "-getrestartfreeze", + "restart_power_failure": "-getrestartpowerfailure", + } + for key, flag in commands.items(): + result = run_command(["systemsetup", flag]) + if result is None or result.returncode != 0: + continue + output = result.stdout.strip() + # Parse "Computer Sleep: 10" or "Wake On Network Access: On" + if ":" in output: + value = output.split(":", 1)[1].strip() + # Try to parse as int (sleep minutes) + try: + settings[key] = int(value) + except ValueError: + settings[key] = value + + return settings + + def _get_login_window(self) -> dict[str, Any]: + """Read login window preferences.""" + plist_path = Path("/Library/Preferences/com.apple.loginwindow.plist") + data = read_plist_safe(plist_path) + if data is None: + return {} + keys = [ + "autoLoginUser", + "GuestEnabled", + "SHOWFULLNAME", + "RestartDisabled", + "ShutDownDisabled", + "SleepDisabled", + "DisableConsoleAccess", + "AdminHostInfo", + "LoginwindowText", + ] + return {k: data[k] for k in keys if k in data} + + def _get_startup_chime(self) -> bool | None: + """Check startup chime setting via nvram.""" + result = run_command(["nvram", "SystemAudioVolume"]) + if result is None or result.returncode != 0: + # Missing/error typically means chime is on (default) + return None + # Output: "SystemAudioVolume\t%00" or "SystemAudioVolume\t%80" + output = result.stdout.strip() + return "%00" not in output and "%01" not in output + + def _get_network_time(self) -> tuple[bool | None, str | None]: + """Get NTP enabled status and server.""" + ntp_enabled: bool | None = None + ntp_server: str | None = None + + result = run_command(["systemsetup", "-getusingnetworktime"]) + if result is not None and result.returncode == 0: + output = result.stdout.strip() + if ":" in output: + value = output.split(":", 1)[1].strip() + ntp_enabled = value.lower() == "on" + + result = run_command(["systemsetup", "-getnetworktimeserver"]) + if result is not None and result.returncode == 0: + output = result.stdout.strip() + if ":" in output: + ntp_server = output.split(":", 1)[1].strip() or None + + return ntp_enabled, ntp_server + + def _get_printers(self) -> list[PrinterInfo]: + """Discover installed printers.""" + result = run_command(["lpstat", "-a"]) + if result is None or result.returncode != 0: + return [] + + printer_names: list[str] = [] + for line in result.stdout.splitlines(): + # "PrinterName accepting requests since ..." + parts = line.split() + if parts: + printer_names.append(parts[0]) + + if not printer_names: + return [] + + # Get default printer + default_name: str | None = None + result = run_command(["lpstat", "-d"]) + if result is not None and result.returncode == 0: + output = result.stdout.strip() + if ":" in output: + default_name = output.split(":", 1)[1].strip() + + printers: list[PrinterInfo] = [] + for name in printer_names: + options: dict[str, str] = {} + result = run_command(["lpoptions", "-d", name, "-l"]) + if result is not None and result.returncode == 0: + for line in result.stdout.splitlines(): + if "/" in line and ":" in line: + opt_key = line.split("/")[0].strip() + opt_val = line.split(":", 1)[1].strip() if ":" in line else "" + # Find the selected value (marked with *) + for part in opt_val.split(): + if part.startswith("*"): + options[opt_key] = part.lstrip("*") + break + printers.append( + PrinterInfo( + name=name, + is_default=(name == default_name), + options=options, + ) + ) + + return printers + + def _get_remote_access(self) -> tuple[bool | None, bool | None, bool | None]: + """Check SSH, Screen Sharing, and File Sharing status.""" + remote_login: bool | None = None + screen_sharing: bool | None = None + file_sharing: bool | None = None + + result = run_command(["systemsetup", "-getremotelogin"]) + if result is not None and result.returncode == 0: + remote_login = "on" in result.stdout.lower() + + result = run_command(["launchctl", "list", "com.apple.screensharing"]) + if result is not None: + screen_sharing = result.returncode == 0 + + result = run_command(["launchctl", "list", "com.apple.smbd"]) + if result is not None: + file_sharing = result.returncode == 0 + + return remote_login, screen_sharing, file_sharing diff --git a/tests/models/test_preferences.py b/tests/models/test_preferences.py index c0df4e7..8298cdf 100644 --- a/tests/models/test_preferences.py +++ b/tests/models/test_preferences.py @@ -27,6 +27,36 @@ def test_domain_with_various_value_types(self): assert domain.keys["persistent-apps"] == ["Safari", "Terminal"] assert domain.keys["window-settings"] == {"alpha": 0.9} + def test_source_path_optional(self): + domain = PreferencesDomain( + domain_name="com.apple.dock", + keys={"autohide": True}, + ) + assert domain.source_path is None + + def test_source_path_with_value(self): + domain = PreferencesDomain( + domain_name="com.apple.dock", + source_path=Path("~/Library/Preferences/com.apple.dock.plist"), + keys={"autohide": True}, + ) + assert domain.source_path == Path("~/Library/Preferences/com.apple.dock.plist") + + def test_source_default_is_disk(self): + domain = PreferencesDomain( + domain_name="com.apple.dock", + keys={"autohide": True}, + ) + assert domain.source == "disk" + + def test_source_custom_value(self): + domain = PreferencesDomain( + domain_name="com.apple.dock", + source="cfprefsd", + keys={"autohide": True}, + ) + assert domain.source == "cfprefsd" + class TestPreferencesResult: def test_multiple_domains(self): diff --git a/tests/models/test_remaining.py b/tests/models/test_remaining.py index 094f309..cb1ecda 100644 --- a/tests/models/test_remaining.py +++ b/tests/models/test_remaining.py @@ -2,18 +2,40 @@ from __future__ import annotations +from datetime import UTC, datetime from pathlib import Path -from mac2nix.models.hardware import AudioConfig, AudioDevice, DisplayConfig, Monitor +from mac2nix.models.application import BinarySource, BrewService, PathBinary +from mac2nix.models.files import ( + BundleEntry, + DotfileEntry, + DotfileManager, + FontCollection, + LibraryAuditResult, + LibraryFileEntry, + WorkflowEntry, +) +from mac2nix.models.hardware import AudioConfig, AudioDevice, DisplayConfig, Monitor, NightShiftConfig from mac2nix.models.services import ( CronEntry, LaunchAgentEntry, LaunchAgentSource, LaunchAgentsResult, + LaunchdScheduledJob, ScheduledTasks, ShellConfig, + ShellFramework, +) +from mac2nix.models.system import ( + FirewallAppRule, + NetworkConfig, + NetworkInterface, + PrinterInfo, + SecurityState, + SystemConfig, + TimeMachineConfig, + VpnProfile, ) -from mac2nix.models.system import NetworkConfig, NetworkInterface, SecurityState, SystemConfig class TestLaunchAgent: @@ -185,12 +207,15 @@ def test_with_cron_entries(self) -> None: CronEntry(schedule="0 * * * *", command="/usr/bin/backup", user="root"), CronEntry(schedule="*/5 * * * *", command="echo hello"), ], - launchd_scheduled=["com.apple.periodic-daily"], + launchd_scheduled=[ + LaunchdScheduledJob(label="com.apple.periodic-daily"), + ], ) assert len(tasks.cron_entries) == 2 assert tasks.cron_entries[0].user == "root" assert tasks.cron_entries[1].user is None assert len(tasks.launchd_scheduled) == 1 + assert tasks.launchd_scheduled[0].label == "com.apple.periodic-daily" class TestJsonRoundtrip: @@ -240,3 +265,558 @@ def test_audio_config_roundtrip(self) -> None: restored = AudioConfig.model_validate_json(json_str) assert restored.default_input == "Mic" assert restored.alert_volume == 0.5 + + +class TestBinarySource: + def test_enum_values(self) -> None: + assert BinarySource.BREW == "brew" + assert BinarySource.CARGO == "cargo" + assert BinarySource.GO == "go" + assert BinarySource.PIPX == "pipx" + assert BinarySource.NPM == "npm" + assert BinarySource.GEM == "gem" + assert BinarySource.SYSTEM == "system" + assert BinarySource.MANUAL == "manual" + + def test_is_str(self) -> None: + assert isinstance(BinarySource.BREW, str) + + +class TestPathBinary: + def test_construction(self) -> None: + binary = PathBinary( + name="rg", + path=Path("/opt/homebrew/bin/rg"), + source=BinarySource.BREW, + version="14.1.0", + ) + assert binary.name == "rg" + assert binary.path == Path("/opt/homebrew/bin/rg") + assert binary.source == BinarySource.BREW + assert binary.version == "14.1.0" + + def test_version_optional(self) -> None: + binary = PathBinary( + name="ls", + path=Path("/bin/ls"), + source=BinarySource.SYSTEM, + ) + assert binary.version is None + + +class TestBrewService: + def test_construction(self) -> None: + svc = BrewService( + name="postgresql@16", + status="started", + user="wgordon", + plist_path=Path("~/Library/LaunchAgents/homebrew.mxcl.postgresql@16.plist"), + ) + assert svc.name == "postgresql@16" + assert svc.status == "started" + assert svc.user == "wgordon" + assert svc.plist_path is not None + + def test_optional_defaults(self) -> None: + svc = BrewService(name="redis", status="none") + assert svc.user is None + assert svc.plist_path is None + + +class TestLaunchdScheduledJob: + def test_calendar_trigger(self) -> None: + job = LaunchdScheduledJob( + label="com.apple.periodic-daily", + schedule=[{"Hour": 3, "Minute": 15}], + program="/usr/libexec/periodic-wrapper", + trigger_type="calendar", + ) + assert job.label == "com.apple.periodic-daily" + assert job.schedule == [{"Hour": 3, "Minute": 15}] + assert job.trigger_type == "calendar" + + def test_interval_trigger(self) -> None: + job = LaunchdScheduledJob( + label="com.test.interval", + start_interval=300, + trigger_type="interval", + ) + assert job.start_interval == 300 + assert job.trigger_type == "interval" + + def test_watch_paths_trigger(self) -> None: + job = LaunchdScheduledJob( + label="com.test.watcher", + watch_paths=["/var/log/system.log"], + trigger_type="watch_paths", + ) + assert job.watch_paths == ["/var/log/system.log"] + + def test_defaults(self) -> None: + job = LaunchdScheduledJob(label="com.test.minimal") + assert job.schedule == [] + assert job.program is None + assert job.program_arguments == [] + assert job.watch_paths == [] + assert job.queue_directories == [] + assert job.start_interval is None + assert job.trigger_type == "calendar" + + +class TestLibraryAuditResult: + def test_all_defaults_empty(self) -> None: + result = LibraryAuditResult() + assert result.bundles == [] + assert result.directories == [] + assert result.uncovered_files == [] + assert result.workflows == [] + assert result.key_bindings == [] + assert result.spelling_words == [] + assert result.spelling_dictionaries == [] + assert result.input_methods == [] + assert result.keyboard_layouts == [] + assert result.color_profiles == [] + assert result.compositions == [] + assert result.scripts == [] + assert result.text_replacements == [] + assert result.system_bundles == [] + + def test_with_populated_fields(self) -> None: + result = LibraryAuditResult( + bundles=[BundleEntry(name="Test.bundle", path=Path("/Library/Bundles/Test.bundle"))], + spelling_words=["nix", "darwin"], + keyboard_layouts=["US", "Dvorak"], + text_replacements=[{"shortcut": "omw", "phrase": "On my way!"}], + ) + assert len(result.bundles) == 1 + assert result.spelling_words == ["nix", "darwin"] + assert len(result.text_replacements) == 1 + + +class TestBundleEntry: + def test_construction(self) -> None: + entry = BundleEntry( + name="Test.bundle", + path=Path("/Library/Bundles/Test.bundle"), + bundle_id="com.test.bundle", + version="1.0", + bundle_type="BNDL", + ) + assert entry.name == "Test.bundle" + assert entry.bundle_id == "com.test.bundle" + assert entry.bundle_type == "BNDL" + + def test_optional_defaults(self) -> None: + entry = BundleEntry(name="Minimal.bundle", path=Path("/Library/Bundles/Minimal.bundle")) + assert entry.bundle_id is None + assert entry.version is None + assert entry.bundle_type is None + + +class TestLibraryFileEntry: + def test_with_plist_content(self) -> None: + entry = LibraryFileEntry( + path=Path("~/Library/SomeFile.plist"), + file_type="plist", + plist_content={"key": "value"}, + ) + assert entry.plist_content == {"key": "value"} + assert entry.text_content is None + + def test_with_text_content(self) -> None: + entry = LibraryFileEntry( + path=Path("~/Library/SomeFile.conf"), + file_type="conf", + text_content="setting=value", + ) + assert entry.text_content == "setting=value" + assert entry.plist_content is None + + def test_optional_defaults(self) -> None: + entry = LibraryFileEntry(path=Path("~/Library/unknown")) + assert entry.file_type is None + assert entry.content_hash is None + assert entry.plist_content is None + assert entry.text_content is None + assert entry.migration_strategy is None + assert entry.size_bytes is None + + +class TestWorkflowEntry: + def test_construction(self) -> None: + entry = WorkflowEntry( + name="My Workflow", + path=Path("~/Library/Services/My Workflow.workflow"), + identifier="com.apple.Automator.MyWorkflow", + workflow_definition={"actions": [{"type": "shell"}]}, + ) + assert entry.name == "My Workflow" + assert entry.identifier == "com.apple.Automator.MyWorkflow" + assert entry.workflow_definition is not None + + def test_optional_defaults(self) -> None: + entry = WorkflowEntry(name="Basic", path=Path("/Users/test/Library/Services/basic.workflow")) + assert entry.identifier is None + assert entry.workflow_definition is None + + +class TestVpnProfile: + def test_construction(self) -> None: + vpn = VpnProfile( + name="Work VPN", + protocol="IKEv2", + status="connected", + remote_address="vpn.example.com", + ) + assert vpn.name == "Work VPN" + assert vpn.protocol == "IKEv2" + assert vpn.status == "connected" + assert vpn.remote_address == "vpn.example.com" + + def test_optional_defaults(self) -> None: + vpn = VpnProfile(name="Test VPN") + assert vpn.protocol is None + assert vpn.status is None + assert vpn.remote_address is None + + +class TestFirewallAppRule: + def test_allowed(self) -> None: + rule = FirewallAppRule(app_path="/Applications/Safari.app", allowed=True) + assert rule.app_path == "/Applications/Safari.app" + assert rule.allowed is True + + def test_blocked(self) -> None: + rule = FirewallAppRule(app_path="/Applications/Suspicious.app", allowed=False) + assert rule.allowed is False + + +class TestTimeMachineConfig: + def test_configured(self) -> None: + tm = TimeMachineConfig( + configured=True, + destination_name="Backup Drive", + destination_id="ABC-123", + latest_backup=datetime(2026, 3, 9, 10, 0, 0, tzinfo=UTC), + ) + assert tm.configured is True + assert tm.destination_name == "Backup Drive" + assert tm.latest_backup is not None + + def test_defaults(self) -> None: + tm = TimeMachineConfig() + assert tm.configured is False + assert tm.destination_name is None + assert tm.destination_id is None + assert tm.latest_backup is None + + +class TestPrinterInfo: + def test_default_printer(self) -> None: + printer = PrinterInfo( + name="HP LaserJet", + is_default=True, + options={"duplex": "DuplexNoTumble"}, + ) + assert printer.name == "HP LaserJet" + assert printer.is_default is True + assert printer.options["duplex"] == "DuplexNoTumble" + + def test_defaults(self) -> None: + printer = PrinterInfo(name="Generic") + assert printer.is_default is False + assert printer.options == {} + + +class TestNightShiftConfig: + def test_enabled_with_schedule(self) -> None: + ns = NightShiftConfig(enabled=True, schedule="sunset_to_sunrise") + assert ns.enabled is True + assert ns.schedule == "sunset_to_sunrise" + + def test_defaults(self) -> None: + ns = NightShiftConfig() + assert ns.enabled is None + assert ns.schedule is None + + +class TestShellFramework: + def test_construction(self) -> None: + fw = ShellFramework( + name="oh-my-zsh", + path=Path("~/.oh-my-zsh"), + plugins=["git", "docker", "kubectl"], + theme="powerlevel10k", + ) + assert fw.name == "oh-my-zsh" + assert fw.path == Path("~/.oh-my-zsh") + assert len(fw.plugins) == 3 + assert fw.theme == "powerlevel10k" + + def test_defaults(self) -> None: + fw = ShellFramework(name="fisher", path=Path("~/.config/fish/functions")) + assert fw.plugins == [] + assert fw.theme is None + + +class TestFontCollection: + def test_construction(self) -> None: + fc = FontCollection( + name="Programming Fonts", + path=Path("~/Library/FontCollections/Programming.collection"), + ) + assert fc.name == "Programming Fonts" + assert fc.path == Path("~/Library/FontCollections/Programming.collection") + + +class TestDotfileEntryNewFields: + def test_new_fields_defaults(self) -> None: + entry = DotfileEntry(path=Path("~/.gitconfig")) + assert entry.content_hash is None + assert entry.managed_by == DotfileManager.UNKNOWN + assert entry.symlink_target is None + assert entry.is_directory is False + assert entry.file_count is None + assert entry.sensitive is False + + def test_with_symlink(self) -> None: + entry = DotfileEntry( + path=Path("~/.gitconfig"), + symlink_target=Path("~/.dotfiles/.gitconfig"), + managed_by=DotfileManager.STOW, + ) + assert entry.symlink_target == Path("~/.dotfiles/.gitconfig") + assert entry.managed_by == DotfileManager.STOW + + def test_sensitive_directory(self) -> None: + entry = DotfileEntry( + path=Path("~/.ssh"), + is_directory=True, + file_count=5, + sensitive=True, + ) + assert entry.is_directory is True + assert entry.file_count == 5 + assert entry.sensitive is True + + +class TestDotfileManagerEnum: + def test_new_values(self) -> None: + assert DotfileManager.CHEZMOI == "chezmoi" + assert DotfileManager.YADM == "yadm" + assert DotfileManager.HOME_MANAGER == "home_manager" + assert DotfileManager.RCM == "rcm" + + def test_all_values(self) -> None: + expected = {"git", "stow", "chezmoi", "yadm", "home_manager", "rcm", "manual", "unknown"} + actual = {m.value for m in DotfileManager} + assert actual == expected + + +class TestLaunchAgentEntryNewFields: + def test_new_fields_all_have_defaults(self) -> None: + entry = LaunchAgentEntry(label="com.test.agent", source=LaunchAgentSource.USER) + assert entry.raw_plist == {} + assert entry.working_directory is None + assert entry.environment_variables is None + assert entry.keep_alive is None + assert entry.start_interval is None + assert entry.start_calendar_interval is None + assert entry.watch_paths == [] + assert entry.queue_directories == [] + assert entry.stdout_path is None + assert entry.stderr_path is None + assert entry.throttle_interval is None + assert entry.process_type is None + assert entry.nice is None + assert entry.user_name is None + assert entry.group_name is None + + def test_with_calendar_interval(self) -> None: + entry = LaunchAgentEntry( + label="com.test.scheduled", + source=LaunchAgentSource.USER, + start_calendar_interval={"Hour": 3, "Minute": 15}, + ) + assert entry.start_calendar_interval == {"Hour": 3, "Minute": 15} + + def test_with_keep_alive_dict(self) -> None: + entry = LaunchAgentEntry( + label="com.test.keepalive", + source=LaunchAgentSource.DAEMON, + keep_alive={"SuccessfulExit": False}, + ) + assert entry.keep_alive == {"SuccessfulExit": False} + + +class TestShellConfigNewFields: + def test_new_list_fields_default_empty(self) -> None: + config = ShellConfig(shell_type="zsh") + assert config.conf_d_files == [] + assert config.completion_files == [] + assert config.sourced_files == [] + assert config.frameworks == [] + assert config.dynamic_commands == [] + + def test_with_frameworks(self) -> None: + config = ShellConfig( + shell_type="zsh", + frameworks=[ + ShellFramework(name="oh-my-zsh", path=Path("~/.oh-my-zsh"), plugins=["git"]), + ], + ) + assert len(config.frameworks) == 1 + assert config.frameworks[0].name == "oh-my-zsh" + + +class TestSystemConfigNewFields: + def test_new_optional_fields(self) -> None: + config = SystemConfig(hostname="macbook") + assert config.macos_version is None + assert config.macos_build is None + assert config.macos_product_name is None + assert config.hardware_model is None + assert config.hardware_chip is None + assert config.hardware_memory is None + assert config.hardware_serial is None + assert config.time_machine is None + assert config.software_update == {} + assert config.sleep_settings == {} + assert config.login_window == {} + assert config.startup_chime is None + assert config.local_hostname is None + assert config.dns_hostname is None + assert config.network_time_enabled is None + assert config.network_time_server is None + assert config.printers == [] + assert config.remote_login is None + assert config.screen_sharing is None + assert config.file_sharing is None + + def test_with_time_machine(self) -> None: + config = SystemConfig( + hostname="macbook", + time_machine=TimeMachineConfig(configured=True, destination_name="Backup"), + ) + assert config.time_machine is not None + assert config.time_machine.configured is True + + def test_with_printers(self) -> None: + config = SystemConfig( + hostname="macbook", + printers=[PrinterInfo(name="HP LaserJet", is_default=True)], + ) + assert len(config.printers) == 1 + assert config.printers[0].is_default is True + + +class TestNetworkConfigNewFields: + def test_with_vpn_profiles(self) -> None: + config = NetworkConfig( + vpn_profiles=[ + VpnProfile(name="Work VPN", protocol="IKEv2"), + VpnProfile(name="Personal VPN", protocol="WireGuard"), + ], + ) + assert len(config.vpn_profiles) == 2 + assert config.vpn_profiles[0].name == "Work VPN" + + def test_new_fields_defaults(self) -> None: + config = NetworkConfig() + assert config.vpn_profiles == [] + assert config.proxy_bypass_domains == [] + assert config.locations == [] + assert config.current_location is None + + +class TestSecurityStateNewFields: + def test_new_fields_defaults(self) -> None: + state = SecurityState() + assert state.firewall_stealth_mode is None + assert state.firewall_app_rules == [] + assert state.firewall_block_all_incoming is None + assert state.touch_id_sudo is None + assert state.custom_certificates == [] + + def test_with_firewall_rules(self) -> None: + state = SecurityState( + firewall_enabled=True, + firewall_stealth_mode=True, + firewall_block_all_incoming=False, + firewall_app_rules=[ + FirewallAppRule(app_path="/Applications/Safari.app", allowed=True), + ], + ) + assert state.firewall_stealth_mode is True + assert len(state.firewall_app_rules) == 1 + + def test_with_touch_id_and_certs(self) -> None: + state = SecurityState( + touch_id_sudo=True, + custom_certificates=["Enterprise Root CA"], + ) + assert state.touch_id_sudo is True + assert state.custom_certificates == ["Enterprise Root CA"] + + +class TestAudioConfigNewFields: + def test_volume_and_mute_fields(self) -> None: + config = AudioConfig( + output_volume=75, + input_volume=80, + output_muted=False, + ) + assert config.output_volume == 75 + assert config.input_volume == 80 + assert config.output_muted is False + + def test_volume_defaults(self) -> None: + config = AudioConfig() + assert config.output_volume is None + assert config.input_volume is None + assert config.output_muted is None + + +class TestMonitorNewFields: + def test_with_refresh_rate_and_color_profile(self) -> None: + monitor = Monitor( + name="Built-in Retina Display", + resolution="3456x2234", + refresh_rate="120Hz", + color_profile="sRGB IEC61966-2.1", + ) + assert monitor.refresh_rate == "120Hz" + assert monitor.color_profile == "sRGB IEC61966-2.1" + + def test_new_fields_defaults(self) -> None: + monitor = Monitor(name="Generic") + assert monitor.refresh_rate is None + assert monitor.color_profile is None + + +class TestDisplayConfigNewFields: + def test_with_night_shift(self) -> None: + config = DisplayConfig( + night_shift=NightShiftConfig(enabled=True, schedule="sunset_to_sunrise"), + true_tone_enabled=True, + ) + assert config.night_shift is not None + assert config.night_shift.enabled is True + assert config.true_tone_enabled is True + + def test_defaults(self) -> None: + config = DisplayConfig() + assert config.night_shift is None + assert config.true_tone_enabled is None + + +class TestScheduledTasksCronEnv: + def test_cron_env(self) -> None: + tasks = ScheduledTasks( + cron_env={"SHELL": "/bin/bash", "PATH": "/usr/bin:/bin"}, + ) + assert tasks.cron_env["SHELL"] == "/bin/bash" + + def test_cron_env_default(self) -> None: + tasks = ScheduledTasks() + assert tasks.cron_env == {} diff --git a/tests/models/test_system_state.py b/tests/models/test_system_state.py index 1fb5a2a..7e9443d 100644 --- a/tests/models/test_system_state.py +++ b/tests/models/test_system_state.py @@ -4,7 +4,14 @@ from datetime import UTC, datetime from pathlib import Path -from mac2nix.models import BrewFormula, HomebrewState, PreferencesDomain, PreferencesResult, SystemState +from mac2nix.models import ( + BrewFormula, + HomebrewState, + LibraryAuditResult, + PreferencesDomain, + PreferencesResult, + SystemState, +) class TestSystemState: @@ -96,3 +103,24 @@ def test_with_domain_data(self): assert restored.preferences.domains[0].domain_name == "com.apple.dock" assert restored.homebrew is not None assert len(restored.homebrew.formulae) == 2 + + def test_library_audit_field(self): + state = SystemState( + hostname="test-mac", + macos_version="15.3", + architecture="arm64", + library_audit=LibraryAuditResult( + spelling_words=["nix", "darwin"], + keyboard_layouts=["US"], + ), + ) + assert state.library_audit is not None + assert state.library_audit.spelling_words == ["nix", "darwin"] + + def test_library_audit_default_none(self): + state = SystemState( + hostname="test-mac", + macos_version="15.3", + architecture="arm64", + ) + assert state.library_audit is None diff --git a/tests/scanners/test_app_config.py b/tests/scanners/test_app_config.py index 65ba6bf..21206c9 100644 --- a/tests/scanners/test_app_config.py +++ b/tests/scanners/test_app_config.py @@ -166,3 +166,159 @@ def test_returns_app_config_result(self, tmp_path: Path) -> None: result = AppConfigScanner().scan() assert isinstance(result, AppConfigResult) + + def test_containers_app_support(self, tmp_path: Path) -> None: + _setup_app_support(tmp_path) + container = tmp_path / "Library" / "Containers" / "com.test.app" / "Data" / "Library" / "Application Support" + container.mkdir(parents=True) + app_dir = container / "TestApp" + app_dir.mkdir() + (app_dir / "config.json").write_text('{"key": "value"}') + + with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): + result = AppConfigScanner().scan() + + assert isinstance(result, AppConfigResult) + assert len(result.entries) == 1 + assert result.entries[0].app_name == "TestApp" + + def test_skip_dirs_pruned(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "MyApp" + app_dir.mkdir() + (app_dir / "settings.json").write_text("{}") + cache_dir = app_dir / "Caches" + cache_dir.mkdir() + (cache_dir / "cached.json").write_text("{}") + git_dir = app_dir / ".git" + git_dir.mkdir() + (git_dir / "config").write_text("[core]") + + with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): + result = AppConfigScanner().scan() + + assert isinstance(result, AppConfigResult) + paths = {str(e.path) for e in result.entries} + assert any("settings.json" in p for p in paths) + assert not any("Caches" in p for p in paths) + assert not any(".git" in p for p in paths) + + def test_large_file_skipped(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "BigApp" + app_dir.mkdir() + (app_dir / "small.json").write_text("{}") + big_file = app_dir / "huge.json" + # Write just over 10MB + big_file.write_bytes(b"x" * (10 * 1024 * 1024 + 1)) + + with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): + result = AppConfigScanner().scan() + + assert isinstance(result, AppConfigResult) + assert len(result.entries) == 1 + assert result.entries[0].path.name == "small.json" + + def test_max_files_per_app_cap(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "ManyFilesApp" + app_dir.mkdir() + # Create 501 files to hit the cap (500) + for i in range(501): + (app_dir / f"file{i:04d}.json").write_text("{}") + + with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): + result = AppConfigScanner().scan() + + assert isinstance(result, AppConfigResult) + app_entries = [e for e in result.entries if e.app_name == "ManyFilesApp"] + assert len(app_entries) == 500 + + def test_toml_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "TomlApp" + app_dir.mkdir() + (app_dir / "config.toml").write_text("[section]\nkey = 'value'") + + with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): + result = AppConfigScanner().scan() + + assert isinstance(result, AppConfigResult) + assert result.entries[0].file_type == ConfigFileType.TOML + + def test_ini_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "IniApp" + app_dir.mkdir() + (app_dir / "config.ini").write_text("[section]\nkey=value") + + with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): + result = AppConfigScanner().scan() + + assert isinstance(result, AppConfigResult) + assert result.entries[0].file_type == ConfigFileType.CONF + + def test_cfg_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "CfgApp" + app_dir.mkdir() + (app_dir / "app.cfg").write_text("key=value") + + with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): + result = AppConfigScanner().scan() + + assert isinstance(result, AppConfigResult) + assert result.entries[0].file_type == ConfigFileType.CONF + + def test_sqlite3_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "Sqlite3App" + app_dir.mkdir() + (app_dir / "data.sqlite3").write_bytes(b"SQLite format 3\x00") + + with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): + result = AppConfigScanner().scan() + + assert isinstance(result, AppConfigResult) + assert result.entries[0].file_type == ConfigFileType.DATABASE + assert result.entries[0].scannable is False + + def test_nested_config_files(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "Chrome" + profile = app_dir / "Default" + profile.mkdir(parents=True) + (profile / "Preferences").write_text('{"key": "value"}') + (app_dir / "Local State").write_text('{"other": true}') + + with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): + result = AppConfigScanner().scan() + + assert isinstance(result, AppConfigResult) + assert len(result.entries) == 2 + + def test_modified_time_set(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "TimeApp" + app_dir.mkdir() + (app_dir / "config.json").write_text("{}") + + with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): + result = AppConfigScanner().scan() + + assert isinstance(result, AppConfigResult) + assert result.entries[0].modified_time is not None + + def test_permission_denied_containers(self, tmp_path: Path) -> None: + _setup_app_support(tmp_path) + containers = tmp_path / "Library" / "Containers" + containers.mkdir(parents=True) + + with ( + patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path), + patch("pathlib.Path.iterdir", side_effect=PermissionError("denied")), + ): + # Should not crash — gracefully handles permission error + result = AppConfigScanner().scan() + + assert isinstance(result, AppConfigResult) diff --git a/tests/scanners/test_applications.py b/tests/scanners/test_applications.py index 8bd3182..ae59bab 100644 --- a/tests/scanners/test_applications.py +++ b/tests/scanners/test_applications.py @@ -1,11 +1,12 @@ """Tests for applications scanner.""" +import os import plistlib import subprocess from pathlib import Path from unittest.mock import patch -from mac2nix.models.application import ApplicationsResult, AppSource +from mac2nix.models.application import ApplicationsResult, AppSource, BinarySource from mac2nix.scanners.applications import ApplicationsScanner @@ -130,3 +131,231 @@ def test_returns_applications_result(self) -> None: assert isinstance(result, ApplicationsResult) assert result.apps == [] + + def test_path_binaries_collected(self, tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + rg = bin_dir / "rg" + rg.write_text("#!/bin/sh\n") + rg.chmod(0o755) + + with ( + patch("mac2nix.scanners.applications._APP_DIRS", []), + patch("mac2nix.scanners.applications.shutil.which", return_value=None), + patch.dict(os.environ, {"PATH": str(bin_dir)}), + ): + result = ApplicationsScanner().scan() + + assert isinstance(result, ApplicationsResult) + assert len(result.path_binaries) >= 1 + names = {b.name for b in result.path_binaries} + assert "rg" in names + + def test_path_binaries_deduplication(self, tmp_path: Path) -> None: + dir1 = tmp_path / "bin1" + dir1.mkdir() + dir2 = tmp_path / "bin2" + dir2.mkdir() + for d in [dir1, dir2]: + f = d / "git" + f.write_text("#!/bin/sh\n") + f.chmod(0o755) + + with ( + patch("mac2nix.scanners.applications._APP_DIRS", []), + patch("mac2nix.scanners.applications.shutil.which", return_value=None), + patch.dict(os.environ, {"PATH": f"{dir1}:{dir2}"}), + ): + result = ApplicationsScanner().scan() + + git_binaries = [b for b in result.path_binaries if b.name == "git"] + assert len(git_binaries) == 1 + + def test_non_executable_skipped(self, tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + f = bin_dir / "not_exec" + f.write_text("data") + f.chmod(0o644) # not executable + + with ( + patch("mac2nix.scanners.applications._APP_DIRS", []), + patch("mac2nix.scanners.applications.shutil.which", return_value=None), + patch.dict(os.environ, {"PATH": str(bin_dir)}), + ): + result = ApplicationsScanner().scan() + + names = {b.name for b in result.path_binaries} + assert "not_exec" not in names + + +class TestBinaryClassification: + def test_system_dir(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/usr/bin/ls"), set()) + assert source == BinarySource.SYSTEM + + def test_sbin_dir(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/sbin/ping"), set()) + assert source == BinarySource.SYSTEM + + def test_brew_by_name(self) -> None: + source = ApplicationsScanner._classify_binary_source( + Path("/opt/homebrew/bin/rg"), {"rg"} + ) + assert source == BinarySource.BREW + + def test_brew_by_path(self) -> None: + source = ApplicationsScanner._classify_binary_source( + Path("/opt/homebrew/Cellar/ripgrep/14.0/bin/rg"), set() + ) + assert source == BinarySource.BREW + + def test_cargo_source(self) -> None: + source = ApplicationsScanner._classify_binary_source( + Path("/Users/user/.cargo/bin/fd"), set() + ) + assert source == BinarySource.CARGO + + def test_go_source(self) -> None: + source = ApplicationsScanner._classify_binary_source( + Path("/Users/user/go/bin/golangci-lint"), set() + ) + assert source == BinarySource.GO + + def test_pipx_source(self) -> None: + source = ApplicationsScanner._classify_binary_source( + Path("/Users/user/.local/bin/black"), set() + ) + assert source == BinarySource.PIPX + + def test_npm_source(self) -> None: + source = ApplicationsScanner._classify_binary_source( + Path("/Users/user/.npm/bin/eslint"), set() + ) + assert source == BinarySource.NPM + + def test_gem_source(self) -> None: + source = ApplicationsScanner._classify_binary_source( + Path("/Users/user/.gem/ruby/3.2.0/bin/rubocop"), set() + ) + assert source == BinarySource.GEM + + def test_unknown_defaults_manual(self) -> None: + source = ApplicationsScanner._classify_binary_source( + Path("/some/random/path/tool"), set() + ) + assert source == BinarySource.MANUAL + + +class TestXcodeInfo: + def test_xcode_full(self, cmd_result) -> None: + xcodebuild_output = "Xcode 15.3\nBuild version 15E204a\n" + pkgutil_output = "package-id: com.apple.pkg.CLTools_Executables\nversion: 15.3.0.0.1.1\n" + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["xcode-select", "-p"]: + return cmd_result("/Applications/Xcode.app/Contents/Developer\n") + if cmd[0] == "xcodebuild": + return cmd_result(xcodebuild_output) + if cmd[0] == "pkgutil": + return cmd_result(pkgutil_output) + return None + + with ( + patch("mac2nix.scanners.applications._APP_DIRS", []), + patch("mac2nix.scanners.applications.shutil.which", return_value=None), + patch("mac2nix.scanners.applications.run_command", side_effect=side_effect), + ): + result = ApplicationsScanner().scan() + + assert isinstance(result, ApplicationsResult) + assert result.xcode_path == "/Applications/Xcode.app/Contents/Developer" + assert result.xcode_version == "15.3" + assert result.clt_version == "15.3.0.0.1.1" + + def test_clt_only(self, cmd_result) -> None: + pkgutil_output = "package-id: com.apple.pkg.CLTools_Executables\nversion: 15.1.0.0.1.1\n" + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["xcode-select", "-p"]: + return cmd_result("/Library/Developer/CommandLineTools\n") + if cmd[0] == "xcodebuild": + return cmd_result("", returncode=1) + if cmd[0] == "pkgutil": + return cmd_result(pkgutil_output) + return None + + with ( + patch("mac2nix.scanners.applications._APP_DIRS", []), + patch("mac2nix.scanners.applications.shutil.which", return_value=None), + patch("mac2nix.scanners.applications.run_command", side_effect=side_effect), + ): + result = ApplicationsScanner().scan() + + assert isinstance(result, ApplicationsResult) + assert result.xcode_path == "/Library/Developer/CommandLineTools" + assert result.xcode_version is None + assert result.clt_version == "15.1.0.0.1.1" + + def test_no_xcode(self) -> None: + with ( + patch("mac2nix.scanners.applications._APP_DIRS", []), + patch("mac2nix.scanners.applications.shutil.which", return_value=None), + patch("mac2nix.scanners.applications.run_command", return_value=None), + ): + result = ApplicationsScanner().scan() + + assert isinstance(result, ApplicationsResult) + assert result.xcode_path is None + assert result.xcode_version is None + assert result.clt_version is None + + +class TestDevToolVersions: + def test_version_enrichment(self, tmp_path: Path, cmd_result) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + node = bin_dir / "node" + node.write_text("#!/bin/sh\n") + node.chmod(0o755) + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["node", "--version"]: + return cmd_result("v20.11.1\n") + return None + + with ( + patch("mac2nix.scanners.applications._APP_DIRS", []), + patch("mac2nix.scanners.applications.shutil.which", return_value=None), + patch.dict(os.environ, {"PATH": str(bin_dir)}), + patch("mac2nix.scanners.applications.run_command", side_effect=side_effect), + ): + result = ApplicationsScanner().scan() + + assert isinstance(result, ApplicationsResult) + node_bin = next((b for b in result.path_binaries if b.name == "node"), None) + assert node_bin is not None + assert node_bin.version == "20.11.1" + + def test_system_binary_skips_version(self, tmp_path: Path) -> None: + # System binaries should not get version enrichment + bin_dir = tmp_path / "usr" / "bin" + bin_dir.mkdir(parents=True) + git = bin_dir / "git" + git.write_text("#!/bin/sh\n") + git.chmod(0o755) + + with ( + patch("mac2nix.scanners.applications._APP_DIRS", []), + patch("mac2nix.scanners.applications.shutil.which", return_value=None), + patch.dict(os.environ, {"PATH": str(bin_dir)}), + patch("mac2nix.scanners.applications.run_command", return_value=None), + ): + result = ApplicationsScanner().scan() + + assert isinstance(result, ApplicationsResult) + # Binaries from arbitrary dirs don't match _SYSTEM_DIRS, so they get MANUAL source + # This test verifies no crash on enrichment when commands fail + git_bin = next((b for b in result.path_binaries if b.name == "git"), None) + assert git_bin is not None + assert git_bin.version is None diff --git a/tests/scanners/test_audio.py b/tests/scanners/test_audio.py index a8a0694..7367fc2 100644 --- a/tests/scanners/test_audio.py +++ b/tests/scanners/test_audio.py @@ -29,6 +29,8 @@ ] } +_VOLUME_SETTINGS = "output volume:50, input volume:75, alert volume:100, output muted:false" + class TestAudioScanner: def test_name_property(self) -> None: @@ -47,7 +49,7 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces if "SPAudioDataType" in cmd: return cmd_result(json.dumps(_AUDIO_JSON)) if "osascript" in cmd: - return cmd_result("50") + return cmd_result(_VOLUME_SETTINGS) return None with patch("mac2nix.scanners.audio.run_command", side_effect=side_effect): @@ -63,7 +65,7 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces if "SPAudioDataType" in cmd: return cmd_result(json.dumps(_AUDIO_JSON)) if "osascript" in cmd: - return cmd_result("50") + return cmd_result(_VOLUME_SETTINGS) return None with patch("mac2nix.scanners.audio.run_command", side_effect=side_effect): @@ -79,7 +81,7 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces if "SPAudioDataType" in cmd: return cmd_result(json.dumps(_AUDIO_JSON)) if "osascript" in cmd: - return cmd_result("50") + return cmd_result(_VOLUME_SETTINGS) return None with patch("mac2nix.scanners.audio.run_command", side_effect=side_effect): @@ -89,21 +91,39 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces assert result.default_output == "MacBook Pro Speakers" assert result.default_input == "MacBook Pro Microphone" - def test_alert_volume(self, cmd_result) -> None: + def test_volume_settings(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPAudioDataType" in cmd: + return cmd_result(json.dumps(_AUDIO_JSON)) + if "osascript" in cmd: + return cmd_result("output volume:50, input volume:75, alert volume:100, output muted:false") + return None + + with patch("mac2nix.scanners.audio.run_command", side_effect=side_effect): + result = AudioScanner().scan() + + assert isinstance(result, AudioConfig) + assert result.alert_volume == 100.0 + assert result.output_volume == 50 + assert result.input_volume == 75 + assert result.output_muted is False + + def test_volume_settings_muted(self, cmd_result) -> None: def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: if "SPAudioDataType" in cmd: return cmd_result(json.dumps(_AUDIO_JSON)) if "osascript" in cmd: - return cmd_result("75") + return cmd_result("output volume:0, input volume:50, alert volume:75, output muted:true") return None with patch("mac2nix.scanners.audio.run_command", side_effect=side_effect): result = AudioScanner().scan() assert isinstance(result, AudioConfig) - assert result.alert_volume == 75.0 + assert result.output_volume == 0 + assert result.output_muted is True - def test_alert_volume_parse_failure(self, cmd_result) -> None: + def test_volume_settings_parse_failure(self, cmd_result) -> None: def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: if "SPAudioDataType" in cmd: return cmd_result(json.dumps(_AUDIO_JSON)) @@ -116,6 +136,9 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces assert isinstance(result, AudioConfig) assert result.alert_volume is None + assert result.output_volume is None + assert result.input_volume is None + assert result.output_muted is None def test_system_profiler_fails(self) -> None: with patch("mac2nix.scanners.audio.run_command", return_value=None): @@ -189,6 +212,103 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces # Default should be the explicitly marked device, not the first one assert result.default_output == "Built-in Speakers" + def test_volume_partial_output(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPAudioDataType" in cmd: + return cmd_result(json.dumps(_AUDIO_JSON)) + if "osascript" in cmd: + return cmd_result("output volume:42, output muted:true") + return None + + with patch("mac2nix.scanners.audio.run_command", side_effect=side_effect): + result = AudioScanner().scan() + + assert isinstance(result, AudioConfig) + assert result.output_volume == 42 + assert result.output_muted is True + assert result.input_volume is None + assert result.alert_volume is None + + def test_volume_invalid_values(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPAudioDataType" in cmd: + return cmd_result(json.dumps(_AUDIO_JSON)) + if "osascript" in cmd: + return cmd_result("output volume:missing value, alert volume:not_a_number") + return None + + with patch("mac2nix.scanners.audio.run_command", side_effect=side_effect): + result = AudioScanner().scan() + + assert isinstance(result, AudioConfig) + assert result.output_volume is None + assert result.alert_volume is None + + def test_osascript_fails(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPAudioDataType" in cmd: + return cmd_result(json.dumps(_AUDIO_JSON)) + if "osascript" in cmd: + return None + return None + + with patch("mac2nix.scanners.audio.run_command", side_effect=side_effect): + result = AudioScanner().scan() + + assert isinstance(result, AudioConfig) + assert result.alert_volume is None + assert result.output_volume is None + assert result.input_volume is None + assert result.output_muted is None + # Devices should still be populated + assert len(result.output_devices) >= 1 + + def test_default_device_fallback_first(self, cmd_result) -> None: + """When no explicit default marker, first device is used as default.""" + audio_json = { + "SPAudioDataType": [ + { + "_name": "Audio", + "_items": [ + { + "_name": "Speaker A", + "coreaudio_device_uid": "a", + "coreaudio_device_output": "yes", + }, + { + "_name": "Speaker B", + "coreaudio_device_uid": "b", + "coreaudio_device_output": "yes", + }, + ], + } + ] + } + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPAudioDataType" in cmd: + return cmd_result(json.dumps(audio_json)) + return None + + with patch("mac2nix.scanners.audio.run_command", side_effect=side_effect): + result = AudioScanner().scan() + + assert isinstance(result, AudioConfig) + assert result.default_output == "Speaker A" + + def test_invalid_audio_json(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPAudioDataType" in cmd: + return cmd_result("{invalid json!!!") + return None + + with patch("mac2nix.scanners.audio.run_command", side_effect=side_effect): + result = AudioScanner().scan() + + assert isinstance(result, AudioConfig) + assert result.input_devices == [] + assert result.output_devices == [] + def test_returns_audio_config(self) -> None: with patch("mac2nix.scanners.audio.run_command", return_value=None): result = AudioScanner().scan() diff --git a/tests/scanners/test_cron.py b/tests/scanners/test_cron.py index 0ab930a..79ce691 100644 --- a/tests/scanners/test_cron.py +++ b/tests/scanners/test_cron.py @@ -96,7 +96,10 @@ def test_launchd_scheduled(self) -> None: result = CronScanner().scan() assert isinstance(result, ScheduledTasks) - assert result.launchd_scheduled == ["com.test.scheduled"] + assert len(result.launchd_scheduled) == 1 + assert result.launchd_scheduled[0].label == "com.test.scheduled" + assert result.launchd_scheduled[0].trigger_type == "calendar" + assert result.launchd_scheduled[0].schedule == [{"Hour": 5, "Minute": 0}] def test_crontab_command_fails(self) -> None: with ( @@ -107,7 +110,7 @@ def test_crontab_command_fails(self) -> None: assert isinstance(result, ScheduledTasks) assert result.cron_entries == [] - assert result.launchd_scheduled == [] + assert len(result.launchd_scheduled) == 0 def test_returns_scheduled_tasks(self) -> None: with ( @@ -117,3 +120,128 @@ def test_returns_scheduled_tasks(self) -> None: result = CronScanner().scan() assert isinstance(result, ScheduledTasks) + + def test_launchd_watch_trigger(self) -> None: + plist_path = Path("/Users/test/Library/LaunchAgents/com.test.watcher.plist") + data = { + "Label": "com.test.watcher", + "WatchPaths": ["/Users/test/Documents/inbox"], + "Program": "/usr/local/bin/process-inbox", + } + + with ( + patch("mac2nix.scanners.cron.run_command", return_value=None), + patch( + "mac2nix.scanners.cron.read_launchd_plists", + return_value=[(plist_path, "user", data)], + ), + ): + result = CronScanner().scan() + + assert isinstance(result, ScheduledTasks) + assert len(result.launchd_scheduled) == 1 + assert result.launchd_scheduled[0].trigger_type == "watch" + assert result.launchd_scheduled[0].watch_paths == ["/Users/test/Documents/inbox"] + assert result.launchd_scheduled[0].program == "/usr/local/bin/process-inbox" + + def test_launchd_queue_trigger(self) -> None: + plist_path = Path("/Users/test/Library/LaunchAgents/com.test.queue.plist") + data = { + "Label": "com.test.queue", + "QueueDirectories": ["/Users/test/Documents/queue"], + "ProgramArguments": ["/usr/local/bin/process-queue", "--batch"], + } + + with ( + patch("mac2nix.scanners.cron.run_command", return_value=None), + patch( + "mac2nix.scanners.cron.read_launchd_plists", + return_value=[(plist_path, "user", data)], + ), + ): + result = CronScanner().scan() + + assert isinstance(result, ScheduledTasks) + assert len(result.launchd_scheduled) == 1 + assert result.launchd_scheduled[0].trigger_type == "queue" + assert result.launchd_scheduled[0].queue_directories == ["/Users/test/Documents/queue"] + + def test_launchd_interval_trigger(self) -> None: + plist_path = Path("/Users/test/Library/LaunchAgents/com.test.interval.plist") + data = { + "Label": "com.test.interval", + "StartInterval": 3600, + "Program": "/usr/local/bin/periodic-task", + } + + with ( + patch("mac2nix.scanners.cron.run_command", return_value=None), + patch( + "mac2nix.scanners.cron.read_launchd_plists", + return_value=[(plist_path, "user", data)], + ), + ): + result = CronScanner().scan() + + assert isinstance(result, ScheduledTasks) + assert len(result.launchd_scheduled) == 1 + assert result.launchd_scheduled[0].trigger_type == "interval" + assert result.launchd_scheduled[0].start_interval == 3600 + assert result.launchd_scheduled[0].schedule == [] + + def test_launchd_calendar_list_schedule(self) -> None: + plist_path = Path("/Users/test/Library/LaunchAgents/com.test.multi.plist") + data = { + "Label": "com.test.multi", + "StartCalendarInterval": [ + {"Hour": 8, "Minute": 0}, + {"Hour": 17, "Minute": 30}, + ], + } + + with ( + patch("mac2nix.scanners.cron.run_command", return_value=None), + patch( + "mac2nix.scanners.cron.read_launchd_plists", + return_value=[(plist_path, "user", data)], + ), + ): + result = CronScanner().scan() + + assert isinstance(result, ScheduledTasks) + assert len(result.launchd_scheduled) == 1 + assert result.launchd_scheduled[0].trigger_type == "calendar" + assert len(result.launchd_scheduled[0].schedule) == 2 + + def test_cron_env_variables(self, cmd_result) -> None: + crontab = "SHELL=/bin/bash\nPATH=/usr/bin:/usr/local/bin\n0 5 * * * /usr/bin/task\n" + + with ( + patch( + "mac2nix.scanners.cron.run_command", + return_value=cmd_result(crontab), + ), + patch("mac2nix.scanners.cron.read_launchd_plists", return_value=[]), + ): + result = CronScanner().scan() + + assert isinstance(result, ScheduledTasks) + assert result.cron_env["SHELL"] == "/bin/bash" + assert result.cron_env["PATH"] == "/usr/bin:/usr/local/bin" + assert len(result.cron_entries) == 1 + + def test_launchd_no_label_skipped(self) -> None: + plist_path = Path("/Users/test/Library/LaunchAgents/com.test.nolabel.plist") + data = {"StartCalendarInterval": {"Hour": 5, "Minute": 0}} + + with ( + patch("mac2nix.scanners.cron.run_command", return_value=None), + patch( + "mac2nix.scanners.cron.read_launchd_plists", + return_value=[(plist_path, "user", data)], + ), + ): + result = CronScanner().scan() + + assert isinstance(result, ScheduledTasks) + assert len(result.launchd_scheduled) == 0 diff --git a/tests/scanners/test_display.py b/tests/scanners/test_display.py index 52c5607..55a7876 100644 --- a/tests/scanners/test_display.py +++ b/tests/scanners/test_display.py @@ -1,6 +1,7 @@ """Tests for display scanner.""" import json +import subprocess from unittest.mock import patch from mac2nix.models.hardware import DisplayConfig @@ -135,6 +136,288 @@ def test_resolution_fallback_key(self, cmd_result) -> None: assert len(result.monitors) == 1 assert result.monitors[0].resolution == "1920 x 1080" + def test_refresh_rate(self, cmd_result) -> None: + display_json = { + "SPDisplaysDataType": [ + { + "_name": "GPU", + "spdisplays_ndrvs": [ + { + "_name": "ProMotion Display", + "_spdisplays_resolution": "3456 x 2234 Retina", + "_spdisplays_refresh": "120 Hz", + } + ], + } + ] + } + + with patch( + "mac2nix.scanners.display.run_command", + return_value=cmd_result(json.dumps(display_json)), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.monitors[0].refresh_rate == "120 Hz" + + def test_refresh_rate_fallback_key(self, cmd_result) -> None: + display_json = { + "SPDisplaysDataType": [ + { + "_name": "GPU", + "spdisplays_ndrvs": [ + { + "_name": "External", + "spdisplays_refresh": "60 Hz", + } + ], + } + ] + } + + with patch( + "mac2nix.scanners.display.run_command", + return_value=cmd_result(json.dumps(display_json)), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.monitors[0].refresh_rate == "60 Hz" + + def test_color_profile(self, cmd_result) -> None: + display_json = { + "SPDisplaysDataType": [ + { + "_name": "GPU", + "spdisplays_ndrvs": [ + { + "_name": "Built-in", + "spdisplays_color_profile": "Color LCD", + } + ], + } + ] + } + + with patch( + "mac2nix.scanners.display.run_command", + return_value=cmd_result(json.dumps(display_json)), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.monitors[0].color_profile == "Color LCD" + + def test_color_profile_fallback_key(self, cmd_result) -> None: + display_json = { + "SPDisplaysDataType": [ + { + "_name": "GPU", + "spdisplays_ndrvs": [ + { + "_name": "External", + "_spdisplays_color_profile": "sRGB IEC61966-2.1", + } + ], + } + ] + } + + with patch( + "mac2nix.scanners.display.run_command", + return_value=cmd_result(json.dumps(display_json)), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.monitors[0].color_profile == "sRGB IEC61966-2.1" + + def test_no_refresh_rate_or_color(self, cmd_result) -> None: + display_json = { + "SPDisplaysDataType": [ + { + "_name": "GPU", + "spdisplays_ndrvs": [ + { + "_name": "Basic Monitor", + } + ], + } + ] + } + + with patch( + "mac2nix.scanners.display.run_command", + return_value=cmd_result(json.dumps(display_json)), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.monitors[0].refresh_rate is None + assert result.monitors[0].color_profile is None + + def test_night_shift_sunset_to_sunrise(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPDisplaysDataType" in cmd: + return cmd_result(json.dumps({"SPDisplaysDataType": []})) + return None + + with ( + patch("mac2nix.scanners.display.run_command", side_effect=side_effect), + patch( + "mac2nix.scanners.display.read_plist_safe", + return_value={ + "CBBlueReductionStatus": { + "BlueReductionEnabled": 1, + "BlueReductionMode": 1, + } + }, + ), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.night_shift is not None + assert result.night_shift.enabled is True + assert result.night_shift.schedule == "sunset-to-sunrise" + + def test_night_shift_custom_schedule(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPDisplaysDataType" in cmd: + return cmd_result(json.dumps({"SPDisplaysDataType": []})) + return None + + with ( + patch("mac2nix.scanners.display.run_command", side_effect=side_effect), + patch( + "mac2nix.scanners.display.read_plist_safe", + return_value={ + "CBBlueReductionStatus": { + "BlueReductionEnabled": 1, + "BlueReductionMode": 2, + } + }, + ), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.night_shift is not None + assert result.night_shift.schedule == "custom" + + def test_night_shift_disabled(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPDisplaysDataType" in cmd: + return cmd_result(json.dumps({"SPDisplaysDataType": []})) + return None + + with ( + patch("mac2nix.scanners.display.run_command", side_effect=side_effect), + patch( + "mac2nix.scanners.display.read_plist_safe", + return_value={ + "CBBlueReductionStatus": { + "BlueReductionEnabled": False, + } + }, + ), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.night_shift is not None + assert result.night_shift.enabled is False + assert result.night_shift.schedule == "off" + + def test_night_shift_not_available(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPDisplaysDataType" in cmd: + return cmd_result(json.dumps({"SPDisplaysDataType": []})) + return None + + with ( + patch("mac2nix.scanners.display.run_command", side_effect=side_effect), + patch("mac2nix.scanners.display.read_plist_safe", return_value=None), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.night_shift is None + + def test_night_shift_nested_key(self, cmd_result) -> None: + """Test fallback for Night Shift data nested under a user key.""" + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPDisplaysDataType" in cmd: + return cmd_result(json.dumps({"SPDisplaysDataType": []})) + return None + + with ( + patch("mac2nix.scanners.display.run_command", side_effect=side_effect), + patch( + "mac2nix.scanners.display.read_plist_safe", + return_value={ + "CBBlueReductionStatus": "not_a_dict", + "user-uuid-1234": { + "CBBlueReductionStatus": { + "BlueReductionEnabled": 1, + "BlueReductionMode": 1, + } + }, + }, + ), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.night_shift is not None + assert result.night_shift.enabled is True + assert result.night_shift.schedule == "sunset-to-sunrise" + + def test_true_tone_enabled(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPDisplaysDataType" in cmd: + return cmd_result(json.dumps({"SPDisplaysDataType": []})) + if cmd[0] == "defaults": + return cmd_result("1\n") + return None + + with ( + patch("mac2nix.scanners.display.run_command", side_effect=side_effect), + patch("mac2nix.scanners.display.read_plist_safe", return_value=None), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.true_tone_enabled is True + + def test_true_tone_disabled(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "SPDisplaysDataType" in cmd: + return cmd_result(json.dumps({"SPDisplaysDataType": []})) + if cmd[0] == "defaults": + return cmd_result("0\n") + return None + + with ( + patch("mac2nix.scanners.display.run_command", side_effect=side_effect), + patch("mac2nix.scanners.display.read_plist_safe", return_value=None), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.true_tone_enabled is False + + def test_true_tone_unavailable(self) -> None: + with ( + patch("mac2nix.scanners.display.run_command", return_value=None), + patch("mac2nix.scanners.display.read_plist_safe", return_value=None), + ): + result = DisplayScanner().scan() + + assert isinstance(result, DisplayConfig) + assert result.true_tone_enabled is None + def test_returns_display_config(self) -> None: with patch("mac2nix.scanners.display.run_command", return_value=None): result = DisplayScanner().scan() diff --git a/tests/scanners/test_dotfiles.py b/tests/scanners/test_dotfiles.py index cfc54f4..60b500a 100644 --- a/tests/scanners/test_dotfiles.py +++ b/tests/scanners/test_dotfiles.py @@ -16,7 +16,7 @@ def test_plain_file(self, tmp_path: Path) -> None: with ( patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), - patch("mac2nix.scanners.dotfiles._SCAN_DIRS", []), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), ): result = DotfilesScanner().scan() @@ -36,7 +36,7 @@ def test_symlink_file(self, tmp_path: Path) -> None: with ( patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), - patch("mac2nix.scanners.dotfiles._SCAN_DIRS", []), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), ): result = DotfilesScanner().scan() @@ -56,7 +56,7 @@ def test_stow_managed(self, tmp_path: Path) -> None: with ( patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), - patch("mac2nix.scanners.dotfiles._SCAN_DIRS", []), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), ): result = DotfilesScanner().scan() @@ -75,7 +75,7 @@ def test_git_managed(self, tmp_path: Path) -> None: with ( patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), - patch("mac2nix.scanners.dotfiles._SCAN_DIRS", []), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), ): result = DotfilesScanner().scan() @@ -86,21 +86,21 @@ def test_git_managed(self, tmp_path: Path) -> None: def test_missing_optional(self, tmp_path: Path) -> None: with ( patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), - patch("mac2nix.scanners.dotfiles._SCAN_DIRS", []), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), ): result = DotfilesScanner().scan() assert isinstance(result, DotfilesResult) assert result.entries == [] - def test_scan_dirs(self, tmp_path: Path) -> None: + def test_xdg_scan_dirs(self, tmp_path: Path) -> None: config_dir = tmp_path / ".config" config_dir.mkdir() (config_dir / "starship.toml").write_text("format = '$all'") with ( patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), - patch("mac2nix.scanners.dotfiles._SCAN_DIRS", [".config"]), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[config_dir]), ): result = DotfilesScanner().scan() @@ -112,7 +112,7 @@ def test_hash_file_permission_denied(self, tmp_path: Path) -> None: with ( patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), - patch("mac2nix.scanners.dotfiles._SCAN_DIRS", []), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), patch("mac2nix.scanners.dotfiles.hash_file", return_value=None), ): result = DotfilesScanner().scan() @@ -131,7 +131,7 @@ def test_stow_parent_name_detection(self, tmp_path: Path) -> None: with ( patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), - patch("mac2nix.scanners.dotfiles._SCAN_DIRS", []), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), ): result = DotfilesScanner().scan() @@ -142,8 +142,242 @@ def test_stow_parent_name_detection(self, tmp_path: Path) -> None: def test_returns_dotfiles_result(self, tmp_path: Path) -> None: with ( patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), - patch("mac2nix.scanners.dotfiles._SCAN_DIRS", []), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), ): result = DotfilesScanner().scan() assert isinstance(result, DotfilesResult) + + def test_discovers_directories(self, tmp_path: Path) -> None: + (tmp_path / ".config").mkdir() + (tmp_path / ".config" / "somefile").write_text("x") + (tmp_path / ".zshrc").write_text("# zsh") + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), + ): + result = DotfilesScanner().scan() + + assert isinstance(result, DotfilesResult) + dir_entry = next(e for e in result.entries if e.path.name == ".config") + assert dir_entry.is_directory is True + assert dir_entry.file_count == 1 + + def test_excluded_dotfiles_skipped(self, tmp_path: Path) -> None: + (tmp_path / ".DS_Store").write_bytes(b"\x00") + (tmp_path / ".Trash").mkdir() + (tmp_path / ".zshrc").write_text("# zsh") + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), + ): + result = DotfilesScanner().scan() + + assert isinstance(result, DotfilesResult) + names = {e.path.name for e in result.entries} + assert ".DS_Store" not in names + assert ".Trash" not in names + assert ".zshrc" in names + + def test_sensitive_dir_flagged(self, tmp_path: Path) -> None: + (tmp_path / ".ssh").mkdir() + (tmp_path / ".ssh" / "id_rsa").write_text("key") + (tmp_path / ".gnupg").mkdir() + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), + ): + result = DotfilesScanner().scan() + + ssh = next(e for e in result.entries if e.path.name == ".ssh") + assert ssh.sensitive is True + assert ssh.is_directory is True + gnupg = next(e for e in result.entries if e.path.name == ".gnupg") + assert gnupg.sensitive is True + + def test_sensitive_file_flagged(self, tmp_path: Path) -> None: + (tmp_path / ".netrc").write_text("machine example.com login user password pass") + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), + ): + result = DotfilesScanner().scan() + + netrc = next(e for e in result.entries if e.path.name == ".netrc") + assert netrc.sensitive is True + assert netrc.content_hash is None # hash skipped for sensitive files + + def test_sensitive_nested_path(self, tmp_path: Path) -> None: + gcloud = tmp_path / ".config" / "gcloud" + gcloud.mkdir(parents=True) + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[tmp_path / ".config"]), + ): + result = DotfilesScanner().scan() + + gcloud_entry = next( + (e for e in result.entries if e.path.name == "gcloud"), + None, + ) + assert gcloud_entry is not None + assert gcloud_entry.sensitive is True + + def test_xdg_env_override(self, tmp_path: Path) -> None: + custom_config = tmp_path / "custom_config" + custom_config.mkdir() + (custom_config / "starship.toml").write_text("format = '$all'") + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.dict("os.environ", {"XDG_CONFIG_HOME": str(custom_config)}), + ): + dirs = DotfilesScanner._get_xdg_scan_dirs(tmp_path) + + assert custom_config in dirs + + def test_xdg_default_dirs(self, tmp_path: Path) -> None: + (tmp_path / ".config").mkdir() + (tmp_path / ".local" / "share").mkdir(parents=True) + + with patch.dict("os.environ", {}, clear=True): + dirs = DotfilesScanner._get_xdg_scan_dirs(tmp_path) + + expected_names = {str(tmp_path / ".config"), str(tmp_path / ".local" / "share")} + actual_names = {str(d) for d in dirs} + assert expected_names.issubset(actual_names) + + def test_permission_denied_home(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), + patch("pathlib.Path.iterdir", side_effect=PermissionError("denied")), + ): + result = DotfilesScanner().scan() + + assert isinstance(result, DotfilesResult) + assert result.entries == [] + + def test_permission_denied_xdg_dir(self, tmp_path: Path) -> None: + config_dir = tmp_path / ".config" + config_dir.mkdir() + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[config_dir]), + ): + # Make config dir unreadable after scanner sees it exists + config_dir.chmod(0o000) + try: + result = DotfilesScanner().scan() + finally: + config_dir.chmod(0o755) + + assert isinstance(result, DotfilesResult) + + def test_non_dotfile_skipped(self, tmp_path: Path) -> None: + (tmp_path / "regular_file").write_text("not a dotfile") + (tmp_path / ".actual_dotfile").write_text("dotfile") + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), + ): + result = DotfilesScanner().scan() + + names = {e.path.name for e in result.entries} + assert "regular_file" not in names + assert ".actual_dotfile" in names + + def test_chezmoi_managed(self, tmp_path: Path) -> None: + chezmoi_dir = tmp_path / ".local" / "share" / "chezmoi" + chezmoi_dir.mkdir(parents=True) + target = chezmoi_dir / ".bashrc" + target.write_text("alias ll='ls -la'") + link = tmp_path / ".bashrc" + link.symlink_to(target) + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), + ): + result = DotfilesScanner().scan() + + assert isinstance(result, DotfilesResult) + bashrc = next(e for e in result.entries if e.path.name == ".bashrc") + assert bashrc.managed_by == DotfileManager.CHEZMOI + + def test_yadm_managed(self, tmp_path: Path) -> None: + yadm_dir = tmp_path / ".local" / "share" / "yadm" + yadm_dir.mkdir(parents=True) + target = yadm_dir / ".vimrc" + target.write_text("set number") + link = tmp_path / ".vimrc" + link.symlink_to(target) + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), + ): + result = DotfilesScanner().scan() + + assert isinstance(result, DotfilesResult) + vimrc = next(e for e in result.entries if e.path.name == ".vimrc") + assert vimrc.managed_by == DotfileManager.YADM + + def test_home_manager_managed(self, tmp_path: Path) -> None: + hm_dir = tmp_path / ".config" / "home-manager" + hm_dir.mkdir(parents=True) + target = hm_dir / ".gitconfig" + target.write_text("[user]\nname = Test") + link = tmp_path / ".gitconfig" + link.symlink_to(target) + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), + ): + result = DotfilesScanner().scan() + + assert isinstance(result, DotfilesResult) + gitconfig = next(e for e in result.entries if e.path.name == ".gitconfig") + assert gitconfig.managed_by == DotfileManager.HOME_MANAGER + + def test_rcm_global_manager(self, tmp_path: Path) -> None: + (tmp_path / ".rcrc").write_text("DOTFILES_DIRS=~/.dotfiles") + target = tmp_path / "some_repo" / ".zshrc" + target.parent.mkdir() + target.write_text("# zsh") + link = tmp_path / ".zshrc" + link.symlink_to(target) + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), + ): + result = DotfilesScanner().scan() + + assert isinstance(result, DotfilesResult) + zshrc = next(e for e in result.entries if e.path.name == ".zshrc") + # RCM is detected globally, applied as fallback to UNKNOWN entries + assert zshrc.managed_by == DotfileManager.RCM + + def test_chezmoi_global_manager(self, tmp_path: Path) -> None: + (tmp_path / ".chezmoiroot").write_text("home") + (tmp_path / ".bashrc").write_text("# bash") + + with ( + patch("mac2nix.scanners.dotfiles.Path.home", return_value=tmp_path), + patch.object(DotfilesScanner, "_get_xdg_scan_dirs", return_value=[]), + ): + result = DotfilesScanner().scan() + + assert isinstance(result, DotfilesResult) + # Plain files get MANUAL, not affected by global manager + bashrc = next(e for e in result.entries if e.path.name == ".bashrc") + assert bashrc.managed_by == DotfileManager.MANUAL diff --git a/tests/scanners/test_fonts.py b/tests/scanners/test_fonts.py index 9fb6fd0..445212a 100644 --- a/tests/scanners/test_fonts.py +++ b/tests/scanners/test_fonts.py @@ -100,6 +100,48 @@ def test_nonexistent_dir(self) -> None: assert isinstance(result, FontsResult) assert result.entries == [] + def test_font_collections(self, tmp_path: Path) -> None: + collections_dir = tmp_path / "Library" / "FontCollections" + collections_dir.mkdir(parents=True) + (collections_dir / "MyFavorites.collection").write_bytes(b"data") + (collections_dir / "CodeFonts.collection").write_bytes(b"data") + (collections_dir / "readme.txt").write_text("not a collection") + + with ( + patch("mac2nix.scanners.fonts._FONT_DIRS", []), + patch("mac2nix.scanners.fonts.Path.home", return_value=tmp_path), + ): + result = FontsScanner().scan() + + assert isinstance(result, FontsResult) + assert len(result.collections) == 2 + names = {c.name for c in result.collections} + assert "MyFavorites" in names + assert "CodeFonts" in names + + def test_font_collections_empty(self, tmp_path: Path) -> None: + collections_dir = tmp_path / "Library" / "FontCollections" + collections_dir.mkdir(parents=True) + + with ( + patch("mac2nix.scanners.fonts._FONT_DIRS", []), + patch("mac2nix.scanners.fonts.Path.home", return_value=tmp_path), + ): + result = FontsScanner().scan() + + assert isinstance(result, FontsResult) + assert result.collections == [] + + def test_font_collections_no_dir(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.fonts._FONT_DIRS", []), + patch("mac2nix.scanners.fonts.Path.home", return_value=tmp_path), + ): + result = FontsScanner().scan() + + assert isinstance(result, FontsResult) + assert result.collections == [] + def test_returns_fonts_result(self) -> None: with patch("mac2nix.scanners.fonts._FONT_DIRS", []): result = FontsScanner().scan() diff --git a/tests/scanners/test_homebrew.py b/tests/scanners/test_homebrew.py index 32a29ec..56e5962 100644 --- a/tests/scanners/test_homebrew.py +++ b/tests/scanners/test_homebrew.py @@ -1,5 +1,6 @@ """Tests for Homebrew scanner.""" +from pathlib import Path from unittest.mock import patch from mac2nix.models.application import HomebrewState @@ -35,10 +36,20 @@ def test_is_available_brew_absent(self) -> None: with patch("mac2nix.scanners.homebrew.shutil.which", return_value=None): assert HomebrewScanner().is_available() is False + def _scan_side_effects(self, cmd_result, brewfile=_BREWFILE, versions=_VERSIONS): + """Build side_effect list for all 5 run_command calls in scan().""" + return [ + cmd_result(brewfile), # brew bundle dump + cmd_result(versions), # brew list --versions + cmd_result(""), # brew list --pinned + cmd_result(""), # brew services list + cmd_result("/opt/homebrew"), # brew --prefix + ] + def test_parses_taps(self, cmd_result) -> None: with patch( "mac2nix.scanners.homebrew.run_command", - side_effect=[cmd_result(_BREWFILE), cmd_result(_VERSIONS)], + side_effect=self._scan_side_effects(cmd_result), ): result = HomebrewScanner().scan() @@ -49,7 +60,7 @@ def test_parses_taps(self, cmd_result) -> None: def test_parses_formulae(self, cmd_result) -> None: with patch( "mac2nix.scanners.homebrew.run_command", - side_effect=[cmd_result(_BREWFILE), cmd_result(_VERSIONS)], + side_effect=self._scan_side_effects(cmd_result), ): result = HomebrewScanner().scan() @@ -61,7 +72,7 @@ def test_parses_formulae(self, cmd_result) -> None: def test_parses_casks(self, cmd_result) -> None: with patch( "mac2nix.scanners.homebrew.run_command", - side_effect=[cmd_result(_BREWFILE), cmd_result(_VERSIONS)], + side_effect=self._scan_side_effects(cmd_result), ): result = HomebrewScanner().scan() @@ -73,7 +84,7 @@ def test_parses_casks(self, cmd_result) -> None: def test_parses_mas_apps(self, cmd_result) -> None: with patch( "mac2nix.scanners.homebrew.run_command", - side_effect=[cmd_result(_BREWFILE), cmd_result(_VERSIONS)], + side_effect=self._scan_side_effects(cmd_result), ): result = HomebrewScanner().scan() @@ -85,7 +96,7 @@ def test_parses_mas_apps(self, cmd_result) -> None: def test_version_enrichment(self, cmd_result) -> None: with patch( "mac2nix.scanners.homebrew.run_command", - side_effect=[cmd_result(_BREWFILE), cmd_result(_VERSIONS)], + side_effect=self._scan_side_effects(cmd_result), ): result = HomebrewScanner().scan() @@ -111,9 +122,143 @@ def test_skips_comments_and_blanks(self, cmd_result) -> None: brewfile = '# Comment line\n\ntap "homebrew/core"\n' with patch( "mac2nix.scanners.homebrew.run_command", - side_effect=[cmd_result(brewfile), cmd_result("")], + side_effect=self._scan_side_effects(cmd_result, brewfile=brewfile, versions=""), ): result = HomebrewScanner().scan() assert isinstance(result, HomebrewState) assert result.taps == ["homebrew/core"] + + def test_pinned_formulae(self, cmd_result) -> None: + pinned_output = "node\nnginx\n" + side_effects = [ + cmd_result(_BREWFILE), + cmd_result(_VERSIONS), + cmd_result(pinned_output), + cmd_result(""), + cmd_result("/opt/homebrew"), + ] + with patch( + "mac2nix.scanners.homebrew.run_command", + side_effect=side_effects, + ): + result = HomebrewScanner().scan() + + assert isinstance(result, HomebrewState) + git_formula = next(f for f in result.formulae if f.name == "git") + assert git_formula.pinned is False + + def test_services_parsing(self, cmd_result) -> None: + services_output = ( + "Name Status User File\n" + "mysql started wgordon /opt/homebrew/opt/mysql/homebrew.mysql.plist\n" + "redis stopped\n" + ) + side_effects = [ + cmd_result(_BREWFILE), + cmd_result(_VERSIONS), + cmd_result(""), + cmd_result(services_output), + cmd_result("/opt/homebrew"), + ] + with patch( + "mac2nix.scanners.homebrew.run_command", + side_effect=side_effects, + ): + result = HomebrewScanner().scan() + + assert isinstance(result, HomebrewState) + assert len(result.services) == 2 + mysql = next(s for s in result.services if s.name == "mysql") + assert mysql.status == "started" + assert mysql.user == "wgordon" + assert mysql.plist_path == Path("/opt/homebrew/opt/mysql/homebrew.mysql.plist") + redis = next(s for s in result.services if s.name == "redis") + assert redis.status == "stopped" + assert redis.user is None + assert redis.plist_path is None + + def test_services_header_skipped(self, cmd_result) -> None: + services_output = "Name Status User File\n" + side_effects = [ + cmd_result(_BREWFILE), + cmd_result(_VERSIONS), + cmd_result(""), + cmd_result(services_output), + cmd_result("/opt/homebrew"), + ] + with patch( + "mac2nix.scanners.homebrew.run_command", + side_effect=side_effects, + ): + result = HomebrewScanner().scan() + + assert isinstance(result, HomebrewState) + assert result.services == [] + + def test_prefix_detected(self, cmd_result) -> None: + side_effects = [ + cmd_result(_BREWFILE), + cmd_result(_VERSIONS), + cmd_result(""), + cmd_result(""), + cmd_result("/opt/homebrew\n"), + ] + with patch( + "mac2nix.scanners.homebrew.run_command", + side_effect=side_effects, + ): + result = HomebrewScanner().scan() + + assert isinstance(result, HomebrewState) + assert result.prefix == "/opt/homebrew" + + def test_prefix_intel(self, cmd_result) -> None: + side_effects = [ + cmd_result(_BREWFILE), + cmd_result(_VERSIONS), + cmd_result(""), + cmd_result(""), + cmd_result("/usr/local\n"), + ] + with patch( + "mac2nix.scanners.homebrew.run_command", + side_effect=side_effects, + ): + result = HomebrewScanner().scan() + + assert isinstance(result, HomebrewState) + assert result.prefix == "/usr/local" + + def test_prefix_command_fails(self) -> None: + with patch( + "mac2nix.scanners.homebrew.run_command", + return_value=None, + ): + result = HomebrewScanner().scan() + + assert isinstance(result, HomebrewState) + assert result.prefix is None + + def test_services_none_user_plist(self, cmd_result) -> None: + services_output = ( + "Name Status User File\n" + "dnsmasq started none none\n" + ) + side_effects = [ + cmd_result(_BREWFILE), + cmd_result(_VERSIONS), + cmd_result(""), + cmd_result(services_output), + cmd_result("/opt/homebrew"), + ] + with patch( + "mac2nix.scanners.homebrew.run_command", + side_effect=side_effects, + ): + result = HomebrewScanner().scan() + + assert isinstance(result, HomebrewState) + assert len(result.services) == 1 + assert result.services[0].user is None + assert result.services[0].plist_path is None diff --git a/tests/scanners/test_launch_agents.py b/tests/scanners/test_launch_agents.py index fe9b470..72db2e1 100644 --- a/tests/scanners/test_launch_agents.py +++ b/tests/scanners/test_launch_agents.py @@ -197,3 +197,171 @@ def test_returns_launch_agents_result(self) -> None: result = LaunchAgentsScanner().scan() assert isinstance(result, LaunchAgentsResult) + + def test_full_plist_fields_extracted(self) -> None: + plist_path = Path("/Users/test/Library/LaunchAgents/com.test.full.plist") + plist_data = { + "Label": "com.test.full", + "Program": "/usr/bin/test", + "ProgramArguments": ["/usr/bin/test", "--verbose"], + "RunAtLoad": True, + "WorkingDirectory": "/var/run/test", + "EnvironmentVariables": {"HOME": "/Users/test", "LANG": "en_US.UTF-8"}, + "KeepAlive": {"SuccessfulExit": False}, + "StartInterval": 3600, + "StartCalendarInterval": {"Hour": 5, "Minute": 0}, + "WatchPaths": ["/var/log/system.log"], + "QueueDirectories": ["/var/spool/test"], + "StandardOutPath": "/var/log/test.out.log", + "StandardErrorPath": "/var/log/test.err.log", + "ThrottleInterval": 10, + "ProcessType": "Background", + "Nice": 5, + "UserName": "root", + "GroupName": "wheel", + } + + with ( + patch( + "mac2nix.scanners.launch_agents.read_launchd_plists", + return_value=[(plist_path, "user", plist_data)], + ), + patch("mac2nix.scanners.launch_agents.run_command", return_value=None), + ): + result = LaunchAgentsScanner().scan() + + assert isinstance(result, LaunchAgentsResult) + assert len(result.entries) == 1 + entry = result.entries[0] + assert entry.working_directory == "/var/run/test" + assert entry.environment_variables == {"HOME": "/Users/test", "LANG": "en_US.UTF-8"} + assert entry.keep_alive == {"SuccessfulExit": False} + assert entry.start_interval == 3600 + assert entry.start_calendar_interval == {"Hour": 5, "Minute": 0} + assert entry.watch_paths == ["/var/log/system.log"] + assert entry.queue_directories == ["/var/spool/test"] + assert entry.stdout_path == "/var/log/test.out.log" + assert entry.stderr_path == "/var/log/test.err.log" + assert entry.throttle_interval == 10 + assert entry.process_type == "Background" + assert entry.nice == 5 + assert entry.user_name == "root" + assert entry.group_name == "wheel" + + def test_sensitive_env_vars_redacted(self) -> None: + plist_path = Path("/Users/test/Library/LaunchAgents/com.test.secrets.plist") + redacted = "***REDACTED***" + plist_data = { + "Label": "com.test.secrets", + "EnvironmentVariables": { + "HOME": "/Users/test", + "API_KEY": "super_secret_123", + "GH_TOKEN": "ghp_abc", + "DB_PASSWORD": "hunter2", + "NORMAL_VAR": "safe_value", + "MY_AUTH_HEADER": "Bearer abc", + }, + } + + with ( + patch( + "mac2nix.scanners.launch_agents.read_launchd_plists", + return_value=[(plist_path, "user", plist_data)], + ), + patch("mac2nix.scanners.launch_agents.run_command", return_value=None), + ): + result = LaunchAgentsScanner().scan() + + entry = result.entries[0] + assert entry.environment_variables["HOME"] == "/Users/test" + assert entry.environment_variables["NORMAL_VAR"] == "safe_value" + assert entry.environment_variables["API_KEY"] == redacted + assert entry.environment_variables["GH_TOKEN"] == redacted + assert entry.environment_variables["DB_PASSWORD"] == redacted + assert entry.environment_variables["MY_AUTH_HEADER"] == redacted + + def test_raw_plist_env_also_redacted(self) -> None: + plist_path = Path("/Users/test/Library/LaunchAgents/com.test.raw.plist") + redacted = "***REDACTED***" + plist_data = { + "Label": "com.test.raw", + "EnvironmentVariables": { + "SAFE": "ok", + "API_TOKEN": "secret", + }, + } + + with ( + patch( + "mac2nix.scanners.launch_agents.read_launchd_plists", + return_value=[(plist_path, "user", plist_data)], + ), + patch("mac2nix.scanners.launch_agents.run_command", return_value=None), + ): + result = LaunchAgentsScanner().scan() + + entry = result.entries[0] + assert entry.raw_plist["EnvironmentVariables"]["API_TOKEN"] == redacted + assert entry.raw_plist["EnvironmentVariables"]["SAFE"] == "ok" + + def test_raw_plist_is_deep_copy(self) -> None: + plist_data = { + "Label": "com.test.copy", + "EnvironmentVariables": {"API_KEY": "secret"}, + } + original_data = {"Label": "com.test.copy", "EnvironmentVariables": {"API_KEY": "secret"}} + + with ( + patch( + "mac2nix.scanners.launch_agents.read_launchd_plists", + return_value=[(Path("/Users/test/Library/LaunchAgents/test.plist"), "user", plist_data)], + ), + patch("mac2nix.scanners.launch_agents.run_command", return_value=None), + ): + LaunchAgentsScanner().scan() + + # Original data should not be mutated + assert plist_data["EnvironmentVariables"]["API_KEY"] == original_data["EnvironmentVariables"]["API_KEY"] + + def test_empty_plist_skipped(self) -> None: + plist_path = Path("/Users/test/Library/LaunchAgents/empty.plist") + + with ( + patch( + "mac2nix.scanners.launch_agents.read_launchd_plists", + return_value=[(plist_path, "user", {})], + ), + patch("mac2nix.scanners.launch_agents.run_command", return_value=None), + ): + result = LaunchAgentsScanner().scan() + + assert isinstance(result, LaunchAgentsResult) + assert result.entries == [] + + def test_btm_enabled_disposition(self, cmd_result) -> None: + with ( + patch("mac2nix.scanners.launch_agents.read_launchd_plists", return_value=[]), + patch("mac2nix.scanners.launch_agents.run_command", return_value=cmd_result(_BTM_OUTPUT)), + patch("mac2nix.scanners.launch_agents.os.getuid", return_value=501), + ): + result = LaunchAgentsScanner().scan() + + login_entries = [e for e in result.entries if e.source == LaunchAgentSource.LOGIN_ITEM] + for entry in login_entries: + assert entry.enabled is True # disposition says "enabled" + + def test_system_agent(self) -> None: + plist_path = Path("/Library/LaunchAgents/com.system.agent.plist") + plist_data = {"Label": "com.system.agent", "Program": "/usr/bin/agent"} + + with ( + patch( + "mac2nix.scanners.launch_agents.read_launchd_plists", + return_value=[(plist_path, "system", plist_data)], + ), + patch("mac2nix.scanners.launch_agents.run_command", return_value=None), + ): + result = LaunchAgentsScanner().scan() + + assert result.entries[0].source == LaunchAgentSource.SYSTEM + assert result.entries[0].plist_path == plist_path diff --git a/tests/scanners/test_library_audit.py b/tests/scanners/test_library_audit.py new file mode 100644 index 0000000..168b467 --- /dev/null +++ b/tests/scanners/test_library_audit.py @@ -0,0 +1,512 @@ +"""Tests for library audit scanner.""" + +import sqlite3 +from pathlib import Path +from unittest.mock import MagicMock, patch + +from mac2nix.models.files import LibraryAuditResult +from mac2nix.scanners.library_audit import ( + _COVERED_DIRS, + _TRANSIENT_DIRS, + LibraryAuditScanner, + _redact_sensitive_keys, +) + + +class TestLibraryAuditScanner: + def test_name_property(self) -> None: + assert LibraryAuditScanner().name == "library_audit" + + def test_returns_library_audit_result(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + with ( + patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), + patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryAuditScanner().scan() + + assert isinstance(result, LibraryAuditResult) + + def test_audit_directories_covered(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + prefs = lib / "Preferences" + prefs.mkdir() + (prefs / "com.apple.finder.plist").write_bytes(b"data") + + with ( + patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), + patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryAuditScanner().scan() + + pref_dir = next(d for d in result.directories if d.name == "Preferences") + assert pref_dir.covered_by_scanner == "preferences" + assert pref_dir.has_user_content is False + + def test_audit_directories_uncovered(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + custom = lib / "CustomDir" + custom.mkdir() + (custom / "file.txt").write_text("hello") + + with ( + patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), + patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryAuditScanner().scan() + + custom_dir = next(d for d in result.directories if d.name == "CustomDir") + assert custom_dir.covered_by_scanner is None + assert custom_dir.has_user_content is True + + def test_audit_directories_transient_not_user_content(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + caches = lib / "Caches" + caches.mkdir() + (caches / "something.cache").write_bytes(b"data") + + with ( + patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), + patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryAuditScanner().scan() + + cache_dir = next(d for d in result.directories if d.name == "Caches") + assert cache_dir.covered_by_scanner is None + assert cache_dir.has_user_content is False + + def test_dir_stats(self, tmp_path: Path) -> None: + (tmp_path / "file1.txt").write_text("hello") + (tmp_path / "file2.txt").write_text("world!") + + file_count, total_size, newest_mod = LibraryAuditScanner._dir_stats(tmp_path) + + assert file_count == 2 + assert total_size is not None + assert total_size > 0 + assert newest_mod is not None + + def test_dir_stats_permission_denied(self, tmp_path: Path) -> None: + protected = tmp_path / "protected" + protected.mkdir() + + with patch.object(Path, "iterdir", side_effect=PermissionError("denied")): + file_count, total_size, newest_mod = LibraryAuditScanner._dir_stats(protected) + + assert file_count is None + assert total_size is None + assert newest_mod is None + + def test_classify_file_plist(self, tmp_path: Path) -> None: + plist_file = tmp_path / "test.plist" + plist_file.write_bytes(b"data") + + with ( + patch("mac2nix.scanners.library_audit.read_plist_safe", return_value={"key": "value"}), + patch("mac2nix.scanners.library_audit.hash_file", return_value="abc123"), + ): + entry = LibraryAuditScanner()._classify_file(plist_file) + + assert entry is not None + assert entry.file_type == "plist" + assert entry.migration_strategy == "plist_capture" + assert entry.plist_content == {"key": "value"} + + def test_classify_file_text(self, tmp_path: Path) -> None: + txt_file = tmp_path / "readme.txt" + txt_file.write_text("some text content") + + with patch("mac2nix.scanners.library_audit.hash_file", return_value="def456"): + entry = LibraryAuditScanner()._classify_file(txt_file) + + assert entry is not None + assert entry.file_type == "txt" + assert entry.migration_strategy == "text_capture" + assert entry.text_content == "some text content" + + def test_classify_file_text_too_large(self, tmp_path: Path) -> None: + large_file = tmp_path / "big.txt" + large_file.write_text("x" * 70000) + + with patch("mac2nix.scanners.library_audit.hash_file", return_value="abc"): + entry = LibraryAuditScanner()._classify_file(large_file) + + assert entry is not None + assert entry.migration_strategy == "hash_only" + assert entry.text_content is None + + def test_classify_file_bundle_extension(self, tmp_path: Path) -> None: + bundle = tmp_path / "plugin.component" + bundle.write_bytes(b"data") + + with patch("mac2nix.scanners.library_audit.hash_file", return_value="hash"): + entry = LibraryAuditScanner()._classify_file(bundle) + + assert entry is not None + assert entry.migration_strategy == "bundle" + + def test_classify_file_unknown(self, tmp_path: Path) -> None: + binary_file = tmp_path / "data.bin" + binary_file.write_bytes(b"\x00\x01\x02") + + with patch("mac2nix.scanners.library_audit.hash_file", return_value="hash"): + entry = LibraryAuditScanner()._classify_file(binary_file) + + assert entry is not None + assert entry.migration_strategy == "hash_only" + + def test_key_bindings(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + kb_dir = lib / "KeyBindings" + kb_dir.mkdir(parents=True) + kb_file = kb_dir / "DefaultKeyBinding.dict" + kb_file.write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_audit.read_plist_safe", + return_value={"^w": "deleteWordBackward:", "~f": "moveWordForward:"}, + ): + result = LibraryAuditScanner()._scan_key_bindings(lib) + + assert len(result) == 2 + keys = {e.key for e in result} + assert "^w" in keys + assert "~f" in keys + + def test_key_bindings_no_file(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + result = LibraryAuditScanner()._scan_key_bindings(lib) + assert result == [] + + def test_spelling_words(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + spelling = lib / "Spelling" + spelling.mkdir(parents=True) + local_dict = spelling / "LocalDictionary" + local_dict.write_text("nix\ndarwin\nhomebrew\n") + (spelling / "en_US").write_text("") + + words, dicts = LibraryAuditScanner()._scan_spelling(lib) + + assert words == ["nix", "darwin", "homebrew"] + assert "en_US" in dicts + + def test_spelling_no_dir(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + words, dicts = LibraryAuditScanner()._scan_spelling(lib) + assert words == [] + assert dicts == [] + + def test_scan_workflows(self, tmp_path: Path) -> None: + wf_dir = tmp_path / "Services" + wf = wf_dir / "MyService.workflow" + contents = wf / "Contents" + contents.mkdir(parents=True) + info = contents / "Info.plist" + info.write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_audit.read_plist_safe", + return_value={"CFBundleIdentifier": "com.example.myservice"}, + ): + result = LibraryAuditScanner()._scan_workflows(wf_dir) + + assert len(result) == 1 + assert result[0].name == "MyService" + assert result[0].identifier == "com.example.myservice" + + def test_scan_workflows_no_dir(self, tmp_path: Path) -> None: + result = LibraryAuditScanner()._scan_workflows(tmp_path / "nonexistent") + assert result == [] + + def test_scan_bundles_in_dir(self, tmp_path: Path) -> None: + im_dir = tmp_path / "Input Methods" + bundle = im_dir / "MyInput.app" + contents = bundle / "Contents" + contents.mkdir(parents=True) + info = contents / "Info.plist" + info.write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_audit.read_plist_safe", + return_value={ + "CFBundleIdentifier": "com.example.input", + "CFBundleShortVersionString": "1.0", + }, + ): + result = LibraryAuditScanner()._scan_bundles_in_dir(im_dir) + + assert len(result) == 1 + assert result[0].bundle_id == "com.example.input" + assert result[0].version == "1.0" + + def test_scan_bundles_no_dir(self) -> None: + result = LibraryAuditScanner()._scan_bundles_in_dir(Path("/nonexistent")) + assert result == [] + + def test_scan_file_hashes(self, tmp_path: Path) -> None: + (tmp_path / "layout1.keylayout").write_text("xml") + (tmp_path / "layout2.keylayout").write_text("xml") + (tmp_path / "other.txt").write_text("ignored") + + result = LibraryAuditScanner._scan_file_hashes(tmp_path, ".keylayout") + + assert len(result) == 2 + assert "layout1.keylayout" in result + assert "layout2.keylayout" in result + + def test_scan_file_hashes_no_dir(self) -> None: + result = LibraryAuditScanner._scan_file_hashes(Path("/nonexistent"), ".icc") + assert result == [] + + def test_scan_scripts_with_applescript(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + scripts = lib / "Scripts" + scripts.mkdir(parents=True) + scpt = scripts / "hello.scpt" + scpt.write_bytes(b"compiled") + sh = scripts / "cleanup.sh" + sh.write_text("#!/bin/bash\necho cleanup") + + with patch( + "mac2nix.scanners.library_audit.run_command", + return_value=MagicMock(returncode=0, stdout='display dialog "Hello"'), + ): + result = LibraryAuditScanner()._scan_scripts(lib) + + assert len(result) == 2 + script_names = [s.split(":")[0] if ":" in s else s for s in result] + assert "cleanup.sh" in script_names + assert "hello.scpt" in script_names + + def test_scan_scripts_applescript_decompile_fails(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + scripts = lib / "Scripts" + scripts.mkdir(parents=True) + scpt = scripts / "broken.scpt" + scpt.write_bytes(b"compiled") + + with patch("mac2nix.scanners.library_audit.run_command", return_value=None): + result = LibraryAuditScanner()._scan_scripts(lib) + + assert result == ["broken.scpt"] + + def test_scan_scripts_no_dir(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + result = LibraryAuditScanner()._scan_scripts(lib) + assert result == [] + + def test_text_replacements(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + ks_dir = lib / "KeyboardServices" + ks_dir.mkdir(parents=True) + db_path = ks_dir / "TextReplacements.db" + db_path.write_bytes(b"dummy") + + mock_rows = [("omw", "On my way!"), ("addr", "123 Main St")] + mock_cursor = type("MockCursor", (), {"fetchall": lambda _self: mock_rows})() + mock_conn = type( + "MockConn", + (), + { + "execute": lambda _self, _query: mock_cursor, + "close": lambda _self: None, + }, + )() + + with patch("mac2nix.scanners.library_audit.sqlite3.connect", return_value=mock_conn): + result = LibraryAuditScanner()._scan_text_replacements(lib) + + assert len(result) == 2 + assert result[0] == {"shortcut": "omw", "phrase": "On my way!"} + + def test_text_replacements_no_db(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + result = LibraryAuditScanner()._scan_text_replacements(lib) + assert result == [] + + def test_capture_uncovered_dir_capped(self, tmp_path: Path) -> None: + for i in range(210): + (tmp_path / f"file{i:03d}.txt").write_text(f"content {i}") + + with ( + patch("mac2nix.scanners.library_audit.hash_file", return_value="hash"), + patch("mac2nix.scanners.library_audit.read_plist_safe", return_value=None), + ): + files, _workflows = LibraryAuditScanner()._capture_uncovered_dir(tmp_path) + + assert len(files) <= 200 + + def test_uncovered_files_collected(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + custom = lib / "CustomStuff" + custom.mkdir() + (custom / "config.json").write_text('{"key": "value"}') + + with ( + patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), + patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), + patch("mac2nix.scanners.library_audit.hash_file", return_value="hash"), + patch("mac2nix.scanners.library_audit.read_plist_safe", return_value=None), + ): + result = LibraryAuditScanner().scan() + + assert len(result.uncovered_files) >= 1 + json_file = next(f for f in result.uncovered_files if "config.json" in str(f.path)) + assert json_file.file_type == "json" + + def test_workflows_from_services_dir(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + services = lib / "Services" + wf = services / "Convert.workflow" + contents = wf / "Contents" + contents.mkdir(parents=True) + (contents / "Info.plist").write_bytes(b"dummy") + + with ( + patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), + patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), + patch( + "mac2nix.scanners.library_audit.read_plist_safe", + return_value={"CFBundleIdentifier": "com.example.convert"}, + ), + ): + result = LibraryAuditScanner().scan() + + assert any(w.name == "Convert" for w in result.workflows) + + def test_empty_library(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + + with ( + patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), + patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryAuditScanner().scan() + + assert isinstance(result, LibraryAuditResult) + assert result.directories == [] + assert result.uncovered_files == [] + + def test_no_library_dir(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), + patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryAuditScanner().scan() + + assert isinstance(result, LibraryAuditResult) + assert result.directories == [] + + +class TestRedactSensitiveKeys: + def test_redacts_api_key(self) -> None: + data = {"API_KEY": "secret123", "name": "test"} + _redact_sensitive_keys(data) + + redacted = "***REDACTED***" + assert data["API_KEY"] == redacted + assert data["name"] == "test" + + def test_redacts_nested_dict(self) -> None: + data = {"config": {"DB_PASSWORD": "secret", "host": "localhost"}} + _redact_sensitive_keys(data) + + redacted = "***REDACTED***" + assert data["config"]["DB_PASSWORD"] == redacted + assert data["config"]["host"] == "localhost" + + def test_redacts_in_list(self) -> None: + data = {"items": [{"ACCESS_TOKEN": "token123"}, {"normal": "value"}]} + _redact_sensitive_keys(data) + + redacted = "***REDACTED***" + assert data["items"][0]["ACCESS_TOKEN"] == redacted + assert data["items"][1]["normal"] == "value" + + def test_case_insensitive_match(self) -> None: + data = {"my_auth_header": "Bearer xyz"} + _redact_sensitive_keys(data) + + redacted = "***REDACTED***" + assert data["my_auth_header"] == redacted + + def test_no_sensitive_keys(self) -> None: + data = {"name": "test", "count": 42} + _redact_sensitive_keys(data) + assert data == {"name": "test", "count": 42} + + +class TestCoveredDirsMapping: + def test_known_covered_dirs(self) -> None: + assert _COVERED_DIRS["Preferences"] == "preferences" + assert _COVERED_DIRS["Application Support"] == "app_config" + assert _COVERED_DIRS["LaunchAgents"] == "launch_agents" + assert _COVERED_DIRS["Fonts"] == "fonts" + + def test_transient_dirs(self) -> None: + assert "Caches" in _TRANSIENT_DIRS + assert "Logs" in _TRANSIENT_DIRS + assert "Saved Application State" in _TRANSIENT_DIRS + + +class TestScanAudioPlugins: + def test_finds_component_bundles(self, tmp_path: Path) -> None: + components = tmp_path / "Components" + components.mkdir() + plugin = components / "MyPlugin.component" + plugin.mkdir() + info = plugin / "Contents" / "Info.plist" + info.parent.mkdir() + info.write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_audit.read_plist_safe", + return_value={"CFBundleIdentifier": "com.test.plugin", "CFBundleShortVersionString": "1.0"}, + ): + result = LibraryAuditScanner()._scan_audio_plugins(tmp_path) + + assert len(result) == 1 + assert result[0].name == "MyPlugin.component" + assert result[0].bundle_id == "com.test.plugin" + + def test_skips_non_bundle_dirs(self, tmp_path: Path) -> None: + components = tmp_path / "Components" + components.mkdir() + regular_dir = components / "NotABundle" + regular_dir.mkdir() + + result = LibraryAuditScanner()._scan_audio_plugins(tmp_path) + assert result == [] + + def test_empty_audio_dir(self, tmp_path: Path) -> None: + result = LibraryAuditScanner()._scan_audio_plugins(tmp_path / "nonexistent") + assert result == [] + + +class TestTextReplacementsCorrupted: + def test_corrupted_db_returns_empty(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + ks_dir = lib / "KeyboardServices" + ks_dir.mkdir(parents=True) + db_path = ks_dir / "TextReplacements.db" + db_path.write_bytes(b"not a sqlite database") + + with patch( + "mac2nix.scanners.library_audit.sqlite3.connect", + side_effect=sqlite3.OperationalError("not a database"), + ): + result = LibraryAuditScanner()._scan_text_replacements(lib) + + assert result == [] diff --git a/tests/scanners/test_network.py b/tests/scanners/test_network.py index a606d63..305cc0d 100644 --- a/tests/scanners/test_network.py +++ b/tests/scanners/test_network.py @@ -47,18 +47,20 @@ def _network_side_effect(responses): - """Create a side_effect function that dispatches by command binary name.""" + """Create a side_effect function that dispatches by command binary and flag.""" def side_effect(cmd, **_kwargs): binary = cmd[0] if binary == "networksetup": - # Match on the flag (second arg) flag = cmd[1] if len(cmd) > 1 else "" return responses.get(("networksetup", flag)) if binary == "ifconfig": return responses.get(("ifconfig",)) if binary == "scutil": - return responses.get(("scutil",)) + # Distinguish scutil --dns from scutil --nc list + flag = cmd[1] if len(cmd) > 1 else "" + sub = cmd[2] if len(cmd) > 2 else "" + return responses.get(("scutil", flag, sub), responses.get(("scutil",))) return None return side_effect @@ -179,3 +181,232 @@ def test_proxy_enabled(self, cmd_result) -> None: assert "http_proxy" in result.proxy_settings assert result.proxy_settings["http_proxy"] == "proxy.corp.com:8080" assert "https_proxy" not in result.proxy_settings + + def test_ipv6_address(self, cmd_result) -> None: + ifconfig_ipv6 = ( + "en0: flags=8863 mtu 1500\n" + "\tinet 192.168.1.42 netmask 0xffffff00 broadcast 192.168.1.255\n" + "\tinet6 2001:db8::1 prefixlen 64\n" + ) + responses = { + ("networksetup", "-listallhardwareports"): cmd_result( + "Hardware Port: Wi-Fi\nDevice: en0\nEthernet Address: aa:bb:cc:dd:ee:ff\n" + ), + ("ifconfig",): cmd_result(ifconfig_ipv6), + ("scutil",): cmd_result(""), + } + + with patch( + "mac2nix.scanners.network.run_command", + side_effect=_network_side_effect(responses), + ): + result = NetworkScanner().scan() + + assert isinstance(result, NetworkConfig) + wifi = next(i for i in result.interfaces if i.name == "Wi-Fi") + assert wifi.ipv6_address == "2001:db8::1" + + def test_ipv6_link_local_skipped(self, cmd_result) -> None: + ifconfig_link_local = ( + "en0: flags=8863 mtu 1500\n" + "\tinet6 fe80::1%en0 prefixlen 64 scopeid 0x4\n" + ) + responses = { + ("networksetup", "-listallhardwareports"): cmd_result( + "Hardware Port: Wi-Fi\nDevice: en0\nEthernet Address: aa:bb:cc:dd:ee:ff\n" + ), + ("ifconfig",): cmd_result(ifconfig_link_local), + ("scutil",): cmd_result(""), + } + + with patch( + "mac2nix.scanners.network.run_command", + side_effect=_network_side_effect(responses), + ): + result = NetworkScanner().scan() + + assert isinstance(result, NetworkConfig) + wifi = next(i for i in result.interfaces if i.name == "Wi-Fi") + assert wifi.ipv6_address is None + + def test_interface_active_status(self, cmd_result) -> None: + ifconfig_mixed = ( + "en0: flags=8863 mtu 1500\n" + "\tinet 192.168.1.42 netmask 0xffffff00\n" + "en1: flags=8822 mtu 1500\n" + ) + responses = { + ("networksetup", "-listallhardwareports"): cmd_result(_HARDWARE_PORTS), + ("ifconfig",): cmd_result(ifconfig_mixed), + ("scutil",): cmd_result(""), + } + + with patch( + "mac2nix.scanners.network.run_command", + side_effect=_network_side_effect(responses), + ): + result = NetworkScanner().scan() + + assert isinstance(result, NetworkConfig) + wifi = next(i for i in result.interfaces if i.name == "Wi-Fi") + assert wifi.is_active is True + thunder = next(i for i in result.interfaces if i.name == "Thunderbolt Ethernet") + assert thunder.is_active is False + + def test_vpn_profiles(self, cmd_result) -> None: + vpn_output = ( + '* (Connected) ABC12345-1234-1234-1234-123456789012 "Work VPN" [IPSec]\n' + '* (Disconnected) DEF12345-1234-1234-1234-123456789012 "Home VPN" [L2TP]\n' + ) + responses = { + ("networksetup", "-listallhardwareports"): cmd_result( + "Hardware Port: Wi-Fi\nDevice: en0\nEthernet Address: aa:bb:cc:dd:ee:ff\n" + ), + ("ifconfig",): cmd_result("en0: flags=8863\n\tinet 10.0.0.1 netmask 0xffffff00\n"), + ("scutil",): cmd_result(""), + ("scutil", "--nc", "list"): cmd_result(vpn_output), + } + + with patch( + "mac2nix.scanners.network.run_command", + side_effect=_network_side_effect(responses), + ): + result = NetworkScanner().scan() + + assert isinstance(result, NetworkConfig) + assert len(result.vpn_profiles) == 2 + work_vpn = next(v for v in result.vpn_profiles if v.name == "Work VPN") + assert work_vpn.status == "Connected" + assert work_vpn.protocol == "IPSec" + home_vpn = next(v for v in result.vpn_profiles if v.name == "Home VPN") + assert home_vpn.status == "Disconnected" + assert home_vpn.protocol == "L2TP" + + def test_proxy_bypass_domains(self, cmd_result) -> None: + bypass_output = "*.local\n169.254/16\nlocalhost\n" + responses = { + ("networksetup", "-listallhardwareports"): cmd_result( + "Hardware Port: Wi-Fi\nDevice: en0\nEthernet Address: aa:bb:cc:dd:ee:ff\n" + ), + ("ifconfig",): cmd_result("en0: flags=8863\n\tinet 10.0.0.1 netmask 0xffffff00\n"), + ("scutil",): cmd_result(""), + ("networksetup", "-getproxybypassdomains"): cmd_result(bypass_output), + } + + with patch( + "mac2nix.scanners.network.run_command", + side_effect=_network_side_effect(responses), + ): + result = NetworkScanner().scan() + + assert isinstance(result, NetworkConfig) + assert "*.local" in result.proxy_bypass_domains + assert "localhost" in result.proxy_bypass_domains + + def test_socks_proxy(self, cmd_result) -> None: + socks_enabled = "Enabled: Yes\nServer: socks.corp.com\nPort: 1080\n" + responses = { + ("networksetup", "-listallhardwareports"): cmd_result( + "Hardware Port: Wi-Fi\nDevice: en0\nEthernet Address: aa:bb:cc:dd:ee:ff\n" + ), + ("ifconfig",): cmd_result("en0: flags=8863\n\tinet 10.0.0.1 netmask 0xffffff00\n"), + ("scutil",): cmd_result(""), + ("networksetup", "-getsocksfirewallproxy"): cmd_result(socks_enabled), + } + + with patch( + "mac2nix.scanners.network.run_command", + side_effect=_network_side_effect(responses), + ): + result = NetworkScanner().scan() + + assert isinstance(result, NetworkConfig) + assert "socks_proxy" in result.proxy_settings + assert result.proxy_settings["socks_proxy"] == "socks.corp.com:1080" + + def test_network_locations(self, cmd_result) -> None: + locations_output = "Automatic\nWork\nHome\n" + current_loc = "Work\n" + responses = { + ("networksetup", "-listallhardwareports"): cmd_result( + "Hardware Port: Wi-Fi\nDevice: en0\nEthernet Address: aa:bb:cc:dd:ee:ff\n" + ), + ("ifconfig",): cmd_result("en0: flags=8863\n\tinet 10.0.0.1 netmask 0xffffff00\n"), + ("scutil",): cmd_result(""), + ("networksetup", "-listlocations"): cmd_result(locations_output), + ("networksetup", "-getcurrentlocation"): cmd_result(current_loc), + } + + with patch( + "mac2nix.scanners.network.run_command", + side_effect=_network_side_effect(responses), + ): + result = NetworkScanner().scan() + + assert isinstance(result, NetworkConfig) + assert result.locations == ["Automatic", "Work", "Home"] + assert result.current_location == "Work" + + def test_wifi_preferred_networks(self, cmd_result) -> None: + preferred = ( + "Preferred networks on en0:\n" + "\tHomeNetwork\n" + "\tOfficeWifi\n" + "\tCoffeeShop\n" + ) + responses = { + ("networksetup", "-listallhardwareports"): cmd_result( + "Hardware Port: Wi-Fi\nDevice: en0\nEthernet Address: aa:bb:cc:dd:ee:ff\n" + ), + ("ifconfig",): cmd_result("en0: flags=8863\n\tinet 10.0.0.1 netmask 0xffffff00\n"), + ("scutil",): cmd_result(""), + ("networksetup", "-listpreferredwirelessnetworks"): cmd_result(preferred), + } + + with patch( + "mac2nix.scanners.network.run_command", + side_effect=_network_side_effect(responses), + ): + result = NetworkScanner().scan() + + assert isinstance(result, NetworkConfig) + assert len(result.wifi_networks) == 3 + assert "HomeNetwork" in result.wifi_networks + assert "OfficeWifi" in result.wifi_networks + + def test_empty_ifconfig(self, cmd_result) -> None: + responses = { + ("networksetup", "-listallhardwareports"): cmd_result(_HARDWARE_PORTS), + ("ifconfig",): cmd_result(""), + ("scutil",): cmd_result(""), + } + + with patch( + "mac2nix.scanners.network.run_command", + side_effect=_network_side_effect(responses), + ): + result = NetworkScanner().scan() + + assert isinstance(result, NetworkConfig) + for iface in result.interfaces: + assert iface.ip_address is None + + def test_wifi_preferred_fails_falls_back_to_current(self, cmd_result) -> None: + responses = { + ("networksetup", "-listallhardwareports"): cmd_result( + "Hardware Port: Wi-Fi\nDevice: en0\nEthernet Address: aa:bb:cc:dd:ee:ff\n" + ), + ("ifconfig",): cmd_result("en0: flags=8863\n\tinet 10.0.0.1 netmask 0xffffff00\n"), + ("scutil",): cmd_result(""), + ("networksetup", "-listpreferredwirelessnetworks"): None, + ("networksetup", "-getairportnetwork"): cmd_result("Current Wi-Fi Network: FallbackNet"), + } + + with patch( + "mac2nix.scanners.network.run_command", + side_effect=_network_side_effect(responses), + ): + result = NetworkScanner().scan() + + assert isinstance(result, NetworkConfig) + assert result.wifi_networks == ["FallbackNet"] diff --git a/tests/scanners/test_preferences.py b/tests/scanners/test_preferences.py index 77574a8..4673267 100644 --- a/tests/scanners/test_preferences.py +++ b/tests/scanners/test_preferences.py @@ -1,6 +1,7 @@ """Tests for preferences scanner.""" import plistlib +import subprocess from pathlib import Path from unittest.mock import patch @@ -8,6 +9,11 @@ from mac2nix.scanners.preferences import PreferencesScanner +def _no_cfprefsd(_cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + """Mock run_command that returns None for all calls (disables cfprefsd discovery).""" + return None + + class TestPreferencesScanner: def test_name_property(self) -> None: scanner = PreferencesScanner() @@ -19,9 +25,9 @@ def test_reads_user_preferences(self, tmp_path: Path) -> None: plist_data = {"autohide": True, "tilesize": 48} (prefs_dir / "com.apple.dock.plist").write_bytes(plistlib.dumps(plist_data)) - with patch( - "mac2nix.scanners.preferences._PREF_GLOBS", - [(prefs_dir, "*.plist")], + with ( + patch("mac2nix.scanners.preferences._PREF_GLOBS", [(prefs_dir, "*.plist", "disk")]), + patch("mac2nix.scanners.preferences.run_command", side_effect=_no_cfprefsd), ): result = PreferencesScanner().scan() @@ -37,9 +43,9 @@ def test_reads_binary_plist(self, tmp_path: Path) -> None: plist_data = {"ShowPathbar": True} (prefs_dir / "com.apple.finder.plist").write_bytes(plistlib.dumps(plist_data, fmt=plistlib.FMT_BINARY)) - with patch( - "mac2nix.scanners.preferences._PREF_GLOBS", - [(prefs_dir, "*.plist")], + with ( + patch("mac2nix.scanners.preferences._PREF_GLOBS", [(prefs_dir, "*.plist", "disk")]), + patch("mac2nix.scanners.preferences.run_command", side_effect=_no_cfprefsd), ): result = PreferencesScanner().scan() @@ -53,9 +59,9 @@ def test_skips_unreadable(self, tmp_path: Path) -> None: (prefs_dir / "good.plist").write_bytes(plistlib.dumps({"key": "val"})) (prefs_dir / "bad.plist").write_text("not a plist") - with patch( - "mac2nix.scanners.preferences._PREF_GLOBS", - [(prefs_dir, "*.plist")], + with ( + patch("mac2nix.scanners.preferences._PREF_GLOBS", [(prefs_dir, "*.plist", "disk")]), + patch("mac2nix.scanners.preferences.run_command", side_effect=_no_cfprefsd), ): result = PreferencesScanner().scan() @@ -67,9 +73,9 @@ def test_empty_dirs(self, tmp_path: Path) -> None: empty_dir = tmp_path / "empty" empty_dir.mkdir() - with patch( - "mac2nix.scanners.preferences._PREF_GLOBS", - [(empty_dir, "*.plist")], + with ( + patch("mac2nix.scanners.preferences._PREF_GLOBS", [(empty_dir, "*.plist", "disk")]), + patch("mac2nix.scanners.preferences.run_command", side_effect=_no_cfprefsd), ): result = PreferencesScanner().scan() @@ -77,9 +83,9 @@ def test_empty_dirs(self, tmp_path: Path) -> None: assert result.domains == [] def test_nonexistent_dir(self) -> None: - with patch( - "mac2nix.scanners.preferences._PREF_GLOBS", - [(Path("/nonexistent/path"), "*.plist")], + with ( + patch("mac2nix.scanners.preferences._PREF_GLOBS", [(Path("/nonexistent/path"), "*.plist", "disk")]), + patch("mac2nix.scanners.preferences.run_command", side_effect=_no_cfprefsd), ): result = PreferencesScanner().scan() @@ -87,10 +93,130 @@ def test_nonexistent_dir(self) -> None: assert result.domains == [] def test_returns_preferences_result(self) -> None: - with patch( - "mac2nix.scanners.preferences._PREF_GLOBS", - [], + with ( + patch("mac2nix.scanners.preferences._PREF_GLOBS", []), + patch("mac2nix.scanners.preferences.run_command", side_effect=_no_cfprefsd), + ): + result = PreferencesScanner().scan() + + assert isinstance(result, PreferencesResult) + + def test_synced_preferences_source(self, tmp_path: Path) -> None: + synced_dir = tmp_path / "Library" / "SyncedPreferences" + synced_dir.mkdir(parents=True) + plist_data = {"SyncedKey": "value"} + (synced_dir / "com.apple.synced.plist").write_bytes(plistlib.dumps(plist_data)) + + with ( + patch("mac2nix.scanners.preferences._PREF_GLOBS", [(synced_dir, "*.plist", "synced")]), + patch("mac2nix.scanners.preferences.run_command", side_effect=_no_cfprefsd), + ): + result = PreferencesScanner().scan() + + assert isinstance(result, PreferencesResult) + assert len(result.domains) == 1 + assert result.domains[0].domain_name == "com.apple.synced" + assert result.domains[0].source == "synced" + assert result.domains[0].keys["SyncedKey"] == "value" + + def test_byhost_preferences(self, tmp_path: Path) -> None: + byhost_dir = tmp_path / "Library" / "Preferences" / "ByHost" + byhost_dir.mkdir(parents=True) + plist_data = {"ByHostKey": True} + (byhost_dir / "com.apple.dock.AABBCCDD.plist").write_bytes(plistlib.dumps(plist_data)) + + with ( + patch("mac2nix.scanners.preferences._PREF_GLOBS", [(byhost_dir, "*.plist", "disk")]), + patch("mac2nix.scanners.preferences.run_command", side_effect=_no_cfprefsd), + ): + result = PreferencesScanner().scan() + + assert isinstance(result, PreferencesResult) + assert len(result.domains) == 1 + assert result.domains[0].keys["ByHostKey"] is True + + def test_container_preferences(self, tmp_path: Path) -> None: + container_prefs = tmp_path / "Library" / "Containers" / "com.app.test" / "Data" / "Library" / "Preferences" + container_prefs.mkdir(parents=True) + plist_data = {"ContainerKey": 42} + (container_prefs / "com.app.test.plist").write_bytes(plistlib.dumps(plist_data)) + + with ( + patch( + "mac2nix.scanners.preferences._PREF_GLOBS", + [(tmp_path / "Library" / "Containers", "*/Data/Library/Preferences/*.plist", "disk")], + ), + patch("mac2nix.scanners.preferences.run_command", side_effect=_no_cfprefsd), + ): + result = PreferencesScanner().scan() + + assert isinstance(result, PreferencesResult) + assert len(result.domains) == 1 + assert result.domains[0].keys["ContainerKey"] == 42 + + def test_multiple_directories(self, tmp_path: Path) -> None: + dir1 = tmp_path / "prefs1" + dir1.mkdir() + (dir1 / "a.plist").write_bytes(plistlib.dumps({"key1": "val1"})) + dir2 = tmp_path / "prefs2" + dir2.mkdir() + (dir2 / "b.plist").write_bytes(plistlib.dumps({"key2": "val2"})) + + with ( + patch( + "mac2nix.scanners.preferences._PREF_GLOBS", + [(dir1, "*.plist", "disk"), (dir2, "*.plist", "disk")], + ), + patch("mac2nix.scanners.preferences.run_command", side_effect=_no_cfprefsd), + ): + result = PreferencesScanner().scan() + + assert isinstance(result, PreferencesResult) + assert len(result.domains) == 2 + names = {d.domain_name for d in result.domains} + assert names == {"a", "b"} + + def test_source_path_populated(self, tmp_path: Path) -> None: + prefs_dir = tmp_path / "Library" / "Preferences" + prefs_dir.mkdir(parents=True) + plist_path = prefs_dir / "com.test.plist" + plist_path.write_bytes(plistlib.dumps({"key": "val"})) + + with ( + patch("mac2nix.scanners.preferences._PREF_GLOBS", [(prefs_dir, "*.plist", "disk")]), + patch("mac2nix.scanners.preferences.run_command", side_effect=_no_cfprefsd), + ): + result = PreferencesScanner().scan() + + assert result.domains[0].source_path == plist_path + assert result.domains[0].source == "disk" + + def test_cfprefsd_discovery(self, cmd_result, tmp_path: Path) -> None: + """Test that cfprefsd-only domains are discovered via defaults export.""" + prefs_dir = tmp_path / "Library" / "Preferences" + prefs_dir.mkdir(parents=True) + (prefs_dir / "com.known.plist").write_bytes(plistlib.dumps({"key": "val"})) + + export_plist = plistlib.dumps({"cfkey": "cfval"}) + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["defaults", "domains"]: + return cmd_result("com.known, com.cfonly") + if cmd == ["defaults", "export", "com.cfonly", "-"]: + return cmd_result(export_plist.decode()) + return None + + with ( + patch("mac2nix.scanners.preferences._PREF_GLOBS", [(prefs_dir, "*.plist", "disk")]), + patch("mac2nix.scanners.preferences.run_command", side_effect=side_effect), ): result = PreferencesScanner().scan() assert isinstance(result, PreferencesResult) + domain_names = {d.domain_name for d in result.domains} + assert "com.known" in domain_names + assert "com.cfonly" in domain_names + cf_domain = next(d for d in result.domains if d.domain_name == "com.cfonly") + assert cf_domain.source == "cfprefsd" + assert cf_domain.source_path is None + assert cf_domain.keys["cfkey"] == "cfval" diff --git a/tests/scanners/test_security.py b/tests/scanners/test_security.py index 82f3e8f..4eeda35 100644 --- a/tests/scanners/test_security.py +++ b/tests/scanners/test_security.py @@ -1,5 +1,6 @@ """Tests for security scanner.""" +import sqlite3 import subprocess from pathlib import Path from unittest.mock import patch @@ -151,3 +152,216 @@ def test_returns_security_state(self) -> None: result = SecurityScanner().scan() assert isinstance(result, SecurityState) + + def test_firewall_stealth_enabled(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "socketfilterfw" in cmd[0] and "--getstealthmode" in cmd: + return cmd_result("Stealth mode enabled.") + return None + + with ( + patch("mac2nix.scanners.security.run_command", side_effect=side_effect), + patch("mac2nix.scanners.security.Path.exists", return_value=True), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), + ): + result = SecurityScanner().scan() + + assert isinstance(result, SecurityState) + assert result.firewall_stealth_mode is True + + def test_firewall_stealth_disabled(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "socketfilterfw" in cmd[0] and "--getstealthmode" in cmd: + return cmd_result("Stealth mode disabled.") + return None + + with ( + patch("mac2nix.scanners.security.run_command", side_effect=side_effect), + patch("mac2nix.scanners.security.Path.exists", return_value=True), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), + ): + result = SecurityScanner().scan() + + assert isinstance(result, SecurityState) + assert result.firewall_stealth_mode is False + + def test_firewall_block_all_enabled(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "socketfilterfw" in cmd[0] and "--getblockall" in cmd: + return cmd_result("Block all ENABLED!") + return None + + with ( + patch("mac2nix.scanners.security.run_command", side_effect=side_effect), + patch("mac2nix.scanners.security.Path.exists", return_value=True), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), + ): + result = SecurityScanner().scan() + + assert isinstance(result, SecurityState) + assert result.firewall_block_all_incoming is True + + def test_firewall_app_rules(self, cmd_result) -> None: + listapps_output = ( + "ALF : Total number of applications = 2\n\n" + "1 : /Applications/Safari.app\n" + " ( Allow incoming connections )\n\n" + "2 : /Applications/Firefox.app\n" + " ( Block incoming connections )\n" + ) + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "socketfilterfw" in cmd[0] and "--listapps" in cmd: + return cmd_result(listapps_output) + return None + + with ( + patch("mac2nix.scanners.security.run_command", side_effect=side_effect), + patch("mac2nix.scanners.security.Path.exists", return_value=True), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), + ): + result = SecurityScanner().scan() + + assert isinstance(result, SecurityState) + assert len(result.firewall_app_rules) == 2 + safari = next(r for r in result.firewall_app_rules if "Safari" in r.app_path) + assert safari.allowed is True + firefox = next(r for r in result.firewall_app_rules if "Firefox" in r.app_path) + assert firefox.allowed is False + + def test_firewall_app_rules_empty(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if "socketfilterfw" in cmd[0] and "--listapps" in cmd: + return cmd_result("ALF : Total number of applications = 0\n") + return None + + with ( + patch("mac2nix.scanners.security.run_command", side_effect=side_effect), + patch("mac2nix.scanners.security.Path.exists", return_value=True), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), + ): + result = SecurityScanner().scan() + + assert isinstance(result, SecurityState) + assert result.firewall_app_rules == [] + + def test_touch_id_sudo_enabled(self) -> None: + with ( + patch("mac2nix.scanners.security.run_command", return_value=None), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), + patch( + "mac2nix.scanners.security.SecurityScanner._check_touch_id_sudo", + return_value=True, + ), + ): + result = SecurityScanner().scan() + + assert isinstance(result, SecurityState) + assert result.touch_id_sudo is True + + def test_touch_id_sudo_not_configured(self) -> None: + with ( + patch("mac2nix.scanners.security.run_command", return_value=None), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), + patch( + "mac2nix.scanners.security.SecurityScanner._check_touch_id_sudo", + return_value=None, + ), + ): + result = SecurityScanner().scan() + + assert isinstance(result, SecurityState) + assert result.touch_id_sudo is None + + def test_firewall_path_not_found(self) -> None: + with ( + patch("mac2nix.scanners.security.run_command", return_value=None), + patch("mac2nix.scanners.security.Path.exists", return_value=False), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), + ): + result = SecurityScanner().scan() + + assert isinstance(result, SecurityState) + assert result.firewall_enabled is None + assert result.firewall_stealth_mode is None + assert result.firewall_block_all_incoming is None + assert result.firewall_app_rules == [] + + def test_custom_certificates(self, cmd_result) -> None: + cert_output = ( + '"labl"="Corporate Root CA"\n' + '"labl"="DigiCert Global Root G2"\n' + '"labl"="My Internal CA"\n' + '"labl"="Apple Root CA"\n' + ) + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd[0] == "security": + return cmd_result(cert_output) + return None + + with ( + patch("mac2nix.scanners.security.run_command", side_effect=side_effect), + patch("mac2nix.scanners.security.Path.exists", return_value=True), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), + ): + result = SecurityScanner().scan() + + assert isinstance(result, SecurityState) + assert "Corporate Root CA" in result.custom_certificates + assert "My Internal CA" in result.custom_certificates + # Well-known CAs should be filtered out + assert all("DigiCert" not in c for c in result.custom_certificates) + assert all("Apple" not in c for c in result.custom_certificates) + + def test_custom_certificates_command_fails(self) -> None: + with ( + patch("mac2nix.scanners.security.run_command", return_value=None), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), + ): + result = SecurityScanner().scan() + + assert isinstance(result, SecurityState) + assert result.custom_certificates == [] + + def test_custom_certificates_no_custom(self, cmd_result) -> None: + cert_output = ( + '"labl"="DigiCert Global Root G2"\n' + '"labl"="Apple Root CA"\n' + '"labl"="VeriSign Class 3"\n' + ) + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd[0] == "security": + return cmd_result(cert_output) + return None + + with ( + patch("mac2nix.scanners.security.run_command", side_effect=side_effect), + patch("mac2nix.scanners.security.Path.exists", return_value=True), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), + ): + result = SecurityScanner().scan() + + assert isinstance(result, SecurityState) + assert result.custom_certificates == [] + + def test_tcc_database_corrupted(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd[0] == "fdesetup": + return cmd_result("FileVault is Off.") + return None + + with ( + patch("mac2nix.scanners.security.run_command", side_effect=side_effect), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), + patch("mac2nix.scanners.security.Path.exists", return_value=True), + patch( + "mac2nix.scanners.security.sqlite3.connect", + side_effect=sqlite3.OperationalError("database is malformed"), + ), + ): + result = SecurityScanner().scan() + + assert isinstance(result, SecurityState) + assert result.tcc_summary == {} diff --git a/tests/scanners/test_shell.py b/tests/scanners/test_shell.py index bc37e02..8507c75 100644 --- a/tests/scanners/test_shell.py +++ b/tests/scanners/test_shell.py @@ -1,5 +1,6 @@ """Tests for shell scanner.""" +import os from pathlib import Path from unittest.mock import patch @@ -207,3 +208,370 @@ def test_returns_shell_config(self, tmp_path: Path) -> None: result = ShellScanner().scan() assert isinstance(result, ShellConfig) + + def test_fish_conf_d(self, tmp_path: Path) -> None: + conf_d = tmp_path / ".config" / "fish" / "conf.d" + conf_d.mkdir(parents=True) + (conf_d / "abbr.fish").write_text("abbr -a g git") + (conf_d / "path.fish").write_text("fish_add_path /opt/bin") + (tmp_path / ".config" / "fish" / "config.fish").write_text("# config") + + with ( + _patch_shell("/opt/homebrew/bin/fish"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert len(result.conf_d_files) == 2 + names = {f.name for f in result.conf_d_files} + assert "abbr.fish" in names + assert "path.fish" in names + + def test_fish_completions(self, tmp_path: Path) -> None: + comp_dir = tmp_path / ".config" / "fish" / "completions" + comp_dir.mkdir(parents=True) + (comp_dir / "git.fish").write_text("complete -c git") + (comp_dir / "docker.fish").write_text("complete -c docker") + (tmp_path / ".config" / "fish" / "config.fish").write_text("# config") + + with ( + _patch_shell("/opt/homebrew/bin/fish"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert len(result.completion_files) == 2 + + def test_zsh_conf_d(self, tmp_path: Path) -> None: + zsh_dir = tmp_path / ".zsh" + zsh_dir.mkdir() + (zsh_dir / "aliases.zsh").write_text("alias ll='ls -la'") + (zsh_dir / "exports.zsh").write_text("export EDITOR=vim") + (tmp_path / ".zshrc").write_text("# zsh") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert len(result.conf_d_files) == 2 + + def test_zsh_completions(self, tmp_path: Path) -> None: + comp_dir = tmp_path / ".zsh" / "completions" + comp_dir.mkdir(parents=True) + (comp_dir / "_git").write_text("#compdef git") + (tmp_path / ".zshrc").write_text("# zsh") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert len(result.completion_files) == 1 + + def test_posix_source_detection(self, tmp_path: Path) -> None: + sourced = tmp_path / ".shell_aliases" + sourced.write_text("alias g='git'") + (tmp_path / ".zshrc").write_text(f"source {sourced}\n") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert len(result.sourced_files) == 1 + assert result.sourced_files[0].name == ".shell_aliases" + + def test_fish_source_detection(self, tmp_path: Path) -> None: + fish_dir = tmp_path / ".config" / "fish" + fish_dir.mkdir(parents=True) + sourced = fish_dir / "extras.fish" + sourced.write_text("set -gx EXTRA true") + (fish_dir / "config.fish").write_text(f"source {sourced}\n") + + with ( + _patch_shell("/opt/homebrew/bin/fish"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert len(result.sourced_files) == 1 + + def test_source_tilde_expansion(self, tmp_path: Path) -> None: + sourced = tmp_path / ".my_aliases" + sourced.write_text("alias ll='ls -la'") + (tmp_path / ".zshrc").write_text("source ~/.my_aliases\n") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert len(result.sourced_files) == 1 + + def test_source_nonexistent_ignored(self, tmp_path: Path) -> None: + (tmp_path / ".zshrc").write_text("source /nonexistent/file\n") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert result.sourced_files == [] + + def test_source_no_infinite_loop(self, tmp_path: Path) -> None: + rc = tmp_path / ".zshrc" + # Source itself — should not loop + rc.write_text(f"source {rc}\n") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + # .zshrc is the rc file itself, already in seen_files — not in sourced_files + assert len(result.sourced_files) == 0 + + def test_posix_path_export(self, tmp_path: Path) -> None: + (tmp_path / ".zshrc").write_text("export PATH=/usr/local/bin:/opt/bin:$PATH\n") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert "/usr/local/bin" in result.path_components + assert "/opt/bin" in result.path_components + assert "$PATH" not in result.path_components + + def test_comments_and_blanks_skipped(self, tmp_path: Path) -> None: + (tmp_path / ".zshrc").write_text("# comment\n\n \nalias ll='ls -la'\n# another comment\n") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert len(result.aliases) == 1 + + def test_multiple_rc_files(self, tmp_path: Path) -> None: + (tmp_path / ".zshrc").write_text("alias a='b'\n") + (tmp_path / ".zprofile").write_text("export EDITOR=vim\n") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert len(result.rc_files) == 2 + assert "a" in result.aliases + assert result.env_vars.get("EDITOR") == "vim" + + def test_fish_alias_extraction(self, tmp_path: Path) -> None: + fish_dir = tmp_path / ".config" / "fish" + fish_dir.mkdir(parents=True) + (fish_dir / "config.fish").write_text("alias g git\nalias ll 'ls -la'\n") + + with ( + _patch_shell("/opt/homebrew/bin/fish"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert "g" in result.aliases + assert result.aliases["g"] == "git" + + def test_bash_detection(self, tmp_path: Path) -> None: + (tmp_path / ".bashrc").write_text("alias ll='ls -la'\n") + + with ( + _patch_shell("/bin/bash"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert result.shell_type == "bash" + assert "ll" in result.aliases + + def test_oh_my_fish_detection(self, tmp_path: Path) -> None: + fish_dir = tmp_path / ".config" / "fish" + fish_dir.mkdir(parents=True) + (fish_dir / "config.fish").write_text("# config") + omf_dir = fish_dir / "omf" + omf_dir.mkdir() + pkg_dir = omf_dir / "pkg" + pkg_dir.mkdir() + (pkg_dir / "z").mkdir() + theme_file = omf_dir / "theme" + theme_file.write_text("bobthefish\n") + + with ( + _patch_shell("/opt/homebrew/bin/fish"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + omf = next(f for f in result.frameworks if f.name == "oh-my-fish") + assert "z" in omf.plugins + assert omf.theme == "bobthefish" + + def test_fisher_detection(self, tmp_path: Path) -> None: + fish_dir = tmp_path / ".config" / "fish" + fish_dir.mkdir(parents=True) + (fish_dir / "config.fish").write_text("# config") + (fish_dir / "fish_plugins").write_text("jorgebucaran/fisher\npatrickf1/fzf.fish\n") + + with ( + _patch_shell("/opt/homebrew/bin/fish"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + fisher = next(f for f in result.frameworks if f.name == "fisher") + assert "jorgebucaran/fisher" in fisher.plugins + assert "patrickf1/fzf.fish" in fisher.plugins + + def test_oh_my_zsh_detection(self, tmp_path: Path) -> None: + omz_dir = tmp_path / ".oh-my-zsh" + omz_dir.mkdir() + custom_plugins = omz_dir / "custom" / "plugins" + custom_plugins.mkdir(parents=True) + (custom_plugins / "zsh-autosuggestions").mkdir() + (tmp_path / ".zshrc").write_text("# zsh") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + omz = next(f for f in result.frameworks if f.name == "oh-my-zsh") + assert "zsh-autosuggestions" in omz.plugins + + def test_prezto_detection(self, tmp_path: Path) -> None: + (tmp_path / ".zprezto").mkdir() + (tmp_path / ".zshrc").write_text("# zsh") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + prezto = next(f for f in result.frameworks if f.name == "prezto") + assert prezto.path == tmp_path / ".zprezto" + + def test_starship_detection(self, tmp_path: Path) -> None: + (tmp_path / ".config").mkdir() + (tmp_path / ".config" / "starship.toml").write_text("format = '$all'") + (tmp_path / ".zshrc").write_text("# zsh") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + starship = next(f for f in result.frameworks if f.name == "starship") + assert starship.path is not None + + def test_starship_xdg_detection(self, tmp_path: Path) -> None: + custom_config = tmp_path / "custom_xdg" + custom_config.mkdir() + (custom_config / "starship.toml").write_text("format = '$all'") + (tmp_path / ".zshrc").write_text("# zsh") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + patch.dict(os.environ, {"XDG_CONFIG_HOME": str(custom_config)}), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert any(f.name == "starship" for f in result.frameworks) + + def test_eval_detection_posix(self, tmp_path: Path) -> None: + (tmp_path / ".zshrc").write_text('eval "$(starship init zsh)"\n') + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert len(result.dynamic_commands) >= 1 + assert any("starship" in cmd for cmd in result.dynamic_commands) + + def test_eval_detection_fish(self, tmp_path: Path) -> None: + fish_dir = tmp_path / ".config" / "fish" + fish_dir.mkdir(parents=True) + (fish_dir / "config.fish").write_text("eval (starship init fish)\n") + + with ( + _patch_shell("/opt/homebrew/bin/fish"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert len(result.dynamic_commands) >= 1 + + def test_fish_xdg_config_home(self, tmp_path: Path) -> None: + custom_xdg = tmp_path / "custom_config" + fish_dir = custom_xdg / "fish" + fish_dir.mkdir(parents=True) + (fish_dir / "config.fish").write_text("set -gx EDITOR nvim\n") + + with ( + _patch_shell("/opt/homebrew/bin/fish"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + patch.dict(os.environ, {"XDG_CONFIG_HOME": str(custom_xdg)}), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert result.shell_type == "fish" + assert result.env_vars.get("EDITOR") == "nvim" + assert any(f.name == "config.fish" for f in result.rc_files) + + def test_no_frameworks(self, tmp_path: Path) -> None: + (tmp_path / ".zshrc").write_text("alias ll='ls -la'\n") + + with ( + _patch_shell("/bin/zsh"), + patch("mac2nix.scanners.shell.Path.home", return_value=tmp_path), + ): + result = ShellScanner().scan() + + assert isinstance(result, ShellConfig) + assert result.frameworks == [] diff --git a/tests/scanners/test_system_scanner.py b/tests/scanners/test_system_scanner.py index f30dda6..a0edc74 100644 --- a/tests/scanners/test_system_scanner.py +++ b/tests/scanners/test_system_scanner.py @@ -1,5 +1,6 @@ """Tests for system scanner.""" +import json import subprocess from pathlib import Path from unittest.mock import patch @@ -139,3 +140,458 @@ def test_returns_system_config(self) -> None: result = SystemScanner().scan() assert isinstance(result, SystemConfig) + + def test_macos_version(self, cmd_result) -> None: + sw_vers_output = ( + "ProductName:\tmacOS\n" + "ProductVersion:\t15.3.1\n" + "BuildVersion:\t24D70\n" + ) + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd[0] == "sw_vers": + return cmd_result(sw_vers_output) + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.macos_version == "15.3.1" + assert result.macos_build == "24D70" + assert result.macos_product_name == "macOS" + + def test_macos_version_command_fails(self) -> None: + with patch("mac2nix.scanners.system_scanner.run_command", return_value=None): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.macos_version is None + assert result.macos_build is None + assert result.macos_product_name is None + + def test_hardware_info(self, cmd_result) -> None: + hw_data = { + "SPHardwareDataType": [ + { + "machine_model": "Mac14,2", + "chip_type": "Apple M2", + "physical_memory": "16 GB", + "serial_number": "XYZ123456", + } + ] + } + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd[0] == "system_profiler": + return cmd_result(json.dumps(hw_data)) + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.hardware_model == "Mac14,2" + assert result.hardware_chip == "Apple M2" + assert result.hardware_memory == "16 GB" + assert result.hardware_serial == "XYZ123456" + + def test_hardware_info_fallback_keys(self, cmd_result) -> None: + hw_data = { + "SPHardwareDataType": [ + { + "machine_name": "MacBook Pro", + "cpu_type": "Intel Core i9", + } + ] + } + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd[0] == "system_profiler": + return cmd_result(json.dumps(hw_data)) + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.hardware_model == "MacBook Pro" + assert result.hardware_chip == "Intel Core i9" + + def test_hardware_info_empty_data(self, cmd_result) -> None: + hw_data = {"SPHardwareDataType": []} + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd[0] == "system_profiler": + return cmd_result(json.dumps(hw_data)) + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.hardware_model is None + assert result.hardware_chip is None + + def test_hardware_info_invalid_json(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd[0] == "system_profiler": + return cmd_result("not valid json{{{") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.hardware_model is None + + def test_additional_hostnames(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("MyMac\n") + if cmd == ["scutil", "--get", "LocalHostName"]: + return cmd_result("mymac-local\n") + if cmd == ["scutil", "--get", "HostName"]: + return cmd_result("mymac.example.com\n") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.hostname == "MyMac" + assert result.local_hostname == "mymac-local" + assert result.dns_hostname == "mymac.example.com" + + def test_additional_hostnames_not_set(self) -> None: + with patch("mac2nix.scanners.system_scanner.run_command", return_value=None): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.local_hostname is None + assert result.dns_hostname is None + + def test_time_machine_configured(self, cmd_result) -> None: + tm_output = "Name : TimeCapsule\nID : ABC-123-DEF\n" + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd == ["tmutil", "destinationinfo"]: + return cmd_result(tm_output) + if cmd == ["tmutil", "latestbackup"]: + return cmd_result("/Volumes/TimeCapsule/Backups/2026-03-09-143000\n") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.time_machine is not None + assert result.time_machine.configured is True + assert result.time_machine.destination_name == "TimeCapsule" + assert result.time_machine.destination_id == "ABC-123-DEF" + assert result.time_machine.latest_backup is not None + + def test_time_machine_not_configured(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd == ["tmutil", "destinationinfo"]: + return cmd_result("No destinations configured\n") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.time_machine is not None + assert result.time_machine.configured is False + + def test_time_machine_command_fails(self) -> None: + with patch("mac2nix.scanners.system_scanner.run_command", return_value=None): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.time_machine is None + + def test_software_update_prefs(self) -> None: + plist_data = { + "AutomaticCheckEnabled": True, + "AutomaticDownload": True, + "AutomaticallyInstallMacOSUpdates": False, + "CriticalUpdateInstall": True, + } + + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.read_plist_safe", return_value=plist_data), + ): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.software_update["AutomaticCheckEnabled"] is True + assert result.software_update["AutomaticallyInstallMacOSUpdates"] is False + + def test_software_update_missing(self) -> None: + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.read_plist_safe", return_value=None), + ): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.software_update == {} + + def test_sleep_settings(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd == ["systemsetup", "-getcomputersleep"]: + return cmd_result("Computer Sleep: 10\n") + if cmd == ["systemsetup", "-getdisplaysleep"]: + return cmd_result("Display Sleep: 5\n") + if cmd == ["systemsetup", "-getwakeonnetworkaccess"]: + return cmd_result("Wake On Network Access: On\n") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.sleep_settings["computer_sleep"] == 10 + assert result.sleep_settings["display_sleep"] == 5 + assert result.sleep_settings["wake_on_network"] == "On" + + def test_sleep_settings_command_fails(self) -> None: + with patch("mac2nix.scanners.system_scanner.run_command", return_value=None): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.sleep_settings == {} + + def test_login_window(self) -> None: + plist_data = { + "GuestEnabled": False, + "SHOWFULLNAME": True, + "LoginwindowText": "Welcome", + } + + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.read_plist_safe", return_value=plist_data), + ): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.login_window["GuestEnabled"] is False + assert result.login_window["SHOWFULLNAME"] is True + assert result.login_window["LoginwindowText"] == "Welcome" + + def test_login_window_missing(self) -> None: + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.read_plist_safe", return_value=None), + ): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.login_window == {} + + def test_startup_chime_on(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd[0] == "nvram": + return cmd_result("SystemAudioVolume\t%80\n") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.startup_chime is True + + def test_startup_chime_off(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd[0] == "nvram": + return cmd_result("SystemAudioVolume\t%00\n") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.startup_chime is False + + def test_startup_chime_not_set(self) -> None: + with patch("mac2nix.scanners.system_scanner.run_command", return_value=None): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.startup_chime is None + + def test_network_time(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd == ["systemsetup", "-getusingnetworktime"]: + return cmd_result("Network Time: On\n") + if cmd == ["systemsetup", "-getnetworktimeserver"]: + return cmd_result("Network Time Server: time.apple.com\n") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.network_time_enabled is True + assert result.network_time_server == "time.apple.com" + + def test_network_time_off(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd == ["systemsetup", "-getusingnetworktime"]: + return cmd_result("Network Time: Off\n") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.network_time_enabled is False + + def test_printers(self, cmd_result) -> None: + lpstat_a = ( + "HP_LaserJet accepting requests since Mon Mar 9\n" + "Brother_HL accepting requests since Mon Mar 9\n" + ) + lpstat_d = "system default destination: HP_LaserJet\n" + lpoptions_hp = "PageSize/Media Size: Letter *A4 Legal\nDuplex/Double-Sided: None *DuplexNoTumble\n" + lpoptions_brother = "PageSize/Media Size: *Letter A4\n" + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd == ["lpstat", "-a"]: + return cmd_result(lpstat_a) + if cmd == ["lpstat", "-d"]: + return cmd_result(lpstat_d) + if cmd[0] == "lpoptions" and "HP_LaserJet" in cmd: + return cmd_result(lpoptions_hp) + if cmd[0] == "lpoptions" and "Brother_HL" in cmd: + return cmd_result(lpoptions_brother) + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert len(result.printers) == 2 + hp = next(p for p in result.printers if p.name == "HP_LaserJet") + assert hp.is_default is True + assert hp.options.get("PageSize") == "A4" + brother = next(p for p in result.printers if p.name == "Brother_HL") + assert brother.is_default is False + assert brother.options.get("PageSize") == "Letter" + + def test_printers_none(self) -> None: + with patch("mac2nix.scanners.system_scanner.run_command", return_value=None): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.printers == [] + + def test_remote_access(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd == ["systemsetup", "-getremotelogin"]: + return cmd_result("Remote Login: On\n") + if cmd == ["launchctl", "list", "com.apple.screensharing"]: + return cmd_result("loaded\n") + if cmd == ["launchctl", "list", "com.apple.smbd"]: + return cmd_result("", returncode=113) + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.remote_login is True + assert result.screen_sharing is True + assert result.file_sharing is False + + def test_remote_access_all_off(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd == ["systemsetup", "-getremotelogin"]: + return cmd_result("Remote Login: Off\n") + if cmd[0] == "launchctl": + return cmd_result("", returncode=113) + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.remote_login is False + assert result.screen_sharing is False + assert result.file_sharing is False + + def test_sleep_settings_all_flags(self, cmd_result) -> None: + responses = { + "-getcomputersleep": "Computer Sleep: 10", + "-getdisplaysleep": "Display Sleep: 5", + "-getharddisksleep": "Hard Disk Sleep: 15", + "-getwakeonnetworkaccess": "Wake On Network Access: On", + "-getrestartfreeze": "Restart After Freeze: On", + "-getrestartpowerfailure": "Restart After Power Failure: Off", + } + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd[0] == "systemsetup" and len(cmd) > 1: + text = responses.get(cmd[1]) + if text: + return cmd_result(text + "\n") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert result.sleep_settings["computer_sleep"] == 10 + assert result.sleep_settings["display_sleep"] == 5 + assert result.sleep_settings["hard_disk_sleep"] == 15 + assert result.sleep_settings["wake_on_network"] == "On" + assert result.sleep_settings["restart_freeze"] == "On" + assert result.sleep_settings["restart_power_failure"] == "Off" + + def test_hardware_info_empty_json(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Mac\n") + if cmd[0] == "system_profiler": + return cmd_result("") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.hardware_model is None From dc1072443a43c9a235675820d50a48e21ba02ea1 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 10 Mar 2026 16:00:59 -0400 Subject: [PATCH 02/17] =?UTF-8?q?fix(scanners):=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20API=20naming,=20JSON,=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename _convert_datetimes → convert_datetimes (used cross-module) - Use brew services list --json instead of fragile text parsing - Tighten _classify_binary_source path matching to avoid false positives - Remove unused brew_names parameter from _get_path_binaries - Consolidate dirnames pruning in library_audit _capture_uncovered_dir - Remove unused _SYSTEM_COVERED_DIRS constant - Clarify Night Shift UUID-keyed plist fallback with comment - Fix extra blank line in preferences.py --- src/mac2nix/scanners/_utils.py | 8 +++---- src/mac2nix/scanners/applications.py | 24 ++++++++----------- src/mac2nix/scanners/display.py | 6 +++-- src/mac2nix/scanners/homebrew.py | 28 +++++++++++++---------- src/mac2nix/scanners/library_audit.py | 22 ++++++------------ src/mac2nix/scanners/preferences.py | 5 ++-- tests/scanners/test_applications.py | 24 +++++++++---------- tests/scanners/test_homebrew.py | 33 ++++++++++++++------------- tests/scanners/test_utils.py | 14 ++++++------ 9 files changed, 79 insertions(+), 85 deletions(-) diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index a5c1d35..41e6d8e 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -22,7 +22,7 @@ ] -def _convert_datetimes(obj: Any) -> Any: +def convert_datetimes(obj: Any) -> Any: """Recursively convert non-JSON-safe plist values. plistlib returns datetime objects (for NSDate) and bytes objects (for NSData) @@ -33,9 +33,9 @@ def _convert_datetimes(obj: Any) -> Any: if isinstance(obj, bytes): return f"" if isinstance(obj, dict): - return {k: _convert_datetimes(v) for k, v in obj.items()} + return {k: convert_datetimes(v) for k, v in obj.items()} if isinstance(obj, list): - return [_convert_datetimes(item) for item in obj] + return [convert_datetimes(item) for item in obj] return obj @@ -94,7 +94,7 @@ def read_plist_safe(path: Path) -> dict[str, Any] | None: logger.warning("Failed to read plist %s: %s", path, exc) return None - return _convert_datetimes(data) + return convert_datetimes(data) def _read_plist_via_plutil(path: Path) -> dict[str, Any] | None: diff --git a/src/mac2nix/scanners/applications.py b/src/mac2nix/scanners/applications.py index 7ff9b7a..18df7cb 100644 --- a/src/mac2nix/scanners/applications.py +++ b/src/mac2nix/scanners/applications.py @@ -107,11 +107,8 @@ def _get_mas_apps(self) -> dict[str, int]: apps[name_part.lower()] = app_id return apps - def _get_path_binaries(self, brew_names: set[str] | None = None) -> list[PathBinary]: + def _get_path_binaries(self) -> list[PathBinary]: """Walk PATH directories and collect executable binaries.""" - if brew_names is None: - brew_names = set() - binaries: list[PathBinary] = [] seen_names: set[str] = set() path_dirs = os.environ.get("PATH", "").split(":") @@ -133,7 +130,7 @@ def _get_path_binaries(self, brew_names: set[str] | None = None) -> list[PathBin continue seen_names.add(name) - source = self._classify_binary_source(entry, brew_names) + source = self._classify_binary_source(entry) binaries.append( PathBinary( name=name, @@ -147,21 +144,20 @@ def _get_path_binaries(self, brew_names: set[str] | None = None) -> list[PathBin return binaries @staticmethod - def _classify_binary_source(path: Path, brew_names: set[str]) -> BinarySource: + def _classify_binary_source(path: Path) -> BinarySource: """Classify a binary's source based on its path.""" path_str = str(path) - # Check if it's a brew-installed binary - if path.name in brew_names: - return BinarySource.BREW - - # Check for brew prefix paths - if "/homebrew/" in path_str.lower() or "/Cellar/" in path_str: + # Check for brew prefix paths (Homebrew installs under /opt/homebrew/ or + # /usr/local/Cellar/ — match path segments to avoid false positives on + # directories that happen to contain "homebrew" in their name) + if "/opt/homebrew/" in path_str or "/Cellar/" in path_str: return BinarySource.BREW - # Check known source patterns + # Check known source patterns (these are dotfile/home-relative paths + # that are unlikely to appear as substrings in unrelated paths) for pattern, source in _SOURCE_PATTERNS.items(): - if pattern in path_str: + if f"/{pattern}" in path_str: return source # Check system dirs diff --git a/src/mac2nix/scanners/display.py b/src/mac2nix/scanners/display.py index be787a2..68759c1 100644 --- a/src/mac2nix/scanners/display.py +++ b/src/mac2nix/scanners/display.py @@ -93,10 +93,12 @@ def _get_night_shift(self) -> NightShiftConfig | None: if data is None: continue - # Night Shift data is nested under CBBlueReductionStatus + # Night Shift data lives under CBBlueReductionStatus. On some + # macOS versions the plist is keyed by user UUID at the top level + # (e.g. {"": {"CBBlueReductionStatus": {...}}}), so we + # fall back to searching one level deep for the nested key. ns_data = data.get("CBBlueReductionStatus", {}) if not isinstance(ns_data, dict): - # Sometimes the top-level keys vary for val in data.values(): if isinstance(val, dict) and "CBBlueReductionStatus" in val: ns_data = val["CBBlueReductionStatus"] diff --git a/src/mac2nix/scanners/homebrew.py b/src/mac2nix/scanners/homebrew.py index 91eb301..10386b1 100644 --- a/src/mac2nix/scanners/homebrew.py +++ b/src/mac2nix/scanners/homebrew.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging import re import shutil @@ -121,24 +122,27 @@ def _get_pinned(self) -> set[str]: return {line.strip() for line in result.stdout.splitlines() if line.strip()} def _get_services(self) -> list[BrewService]: - """Parse brew services list output.""" - result = run_command(["brew", "services", "list"]) + """Parse brew services list via JSON output.""" + result = run_command(["brew", "services", "list", "--json"]) if result is None or result.returncode != 0: return [] + try: + data = json.loads(result.stdout) + except (json.JSONDecodeError, ValueError): + return [] + services: list[BrewService] = [] - for line in result.stdout.splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("Name"): + for entry in data: + if not isinstance(entry, dict): continue - parts = stripped.split() - if len(parts) < 2: + name = entry.get("name") + status = entry.get("status") + if not name or not status: continue - name = parts[0] - status = parts[1] - user = parts[2] if len(parts) >= 3 and parts[2] != "none" else None - plist_str = parts[3] if len(parts) >= 4 and parts[3] != "none" else None - plist_path = Path(plist_str) if plist_str else None + user = entry.get("user") or None + file_path = entry.get("file") or None + plist_path = Path(file_path) if file_path else None services.append( BrewService(name=name, status=status, user=user, plist_path=plist_path) ) diff --git a/src/mac2nix/scanners/library_audit.py b/src/mac2nix/scanners/library_audit.py index e043c54..1896c61 100644 --- a/src/mac2nix/scanners/library_audit.py +++ b/src/mac2nix/scanners/library_audit.py @@ -52,13 +52,6 @@ _MAX_FILES_PER_DIR = 200 -_SYSTEM_COVERED_DIRS = frozenset({ - "Preferences", - "LaunchAgents", - "LaunchDaemons", - "Fonts", -}) - _SYSTEM_SCAN_PATTERNS: dict[str, str] = { "Extensions": "*.kext", "PreferencePanes": "*.prefPane", @@ -213,19 +206,18 @@ def _capture_uncovered_dir( files.append(entry) count += 1 # Check dirnames for workflow bundles (they're directories, not files) - for dirname in list(dirnames): + # and prune them + transient/cache subdirectories in a single pass + _skip = {"Caches", "Cache", "Logs", "tmp", "__pycache__"} + kept: list[str] = [] + for dirname in dirnames: if dirname.endswith(".workflow"): wf_path = Path(dirpath) / dirname wf = self._parse_workflow(wf_path) if wf is not None: workflows.append(wf) - # Remove from dirnames to prevent walking into the bundle - dirnames.remove(dirname) - # Skip transient/cache subdirectories - dirnames[:] = [ - d for d in dirnames - if d not in {"Caches", "Cache", "Logs", "tmp", "__pycache__"} - ] + elif dirname not in _skip: + kept.append(dirname) + dirnames[:] = kept except PermissionError: logger.warning("Permission denied walking: %s", dir_path) diff --git a/src/mac2nix/scanners/preferences.py b/src/mac2nix/scanners/preferences.py index c1a1d34..4d59290 100644 --- a/src/mac2nix/scanners/preferences.py +++ b/src/mac2nix/scanners/preferences.py @@ -7,7 +7,7 @@ from pathlib import Path from mac2nix.models.preferences import PreferencesDomain, PreferencesResult, PreferenceValue -from mac2nix.scanners._utils import _convert_datetimes, read_plist_safe, run_command +from mac2nix.scanners._utils import convert_datetimes, read_plist_safe, run_command from mac2nix.scanners.base import BaseScannerPlugin, register logger = logging.getLogger(__name__) @@ -21,7 +21,6 @@ ] - @register("preferences") class PreferencesScanner(BaseScannerPlugin): @property @@ -98,4 +97,4 @@ def _export_domain(domain_name: str) -> dict[str, PreferenceValue] | None: return None if not isinstance(data, dict): return None - return _convert_datetimes(data) + return convert_datetimes(data) diff --git a/tests/scanners/test_applications.py b/tests/scanners/test_applications.py index ae59bab..a18d911 100644 --- a/tests/scanners/test_applications.py +++ b/tests/scanners/test_applications.py @@ -191,58 +191,58 @@ def test_non_executable_skipped(self, tmp_path: Path) -> None: class TestBinaryClassification: def test_system_dir(self) -> None: - source = ApplicationsScanner._classify_binary_source(Path("/usr/bin/ls"), set()) + source = ApplicationsScanner._classify_binary_source(Path("/usr/bin/ls")) assert source == BinarySource.SYSTEM def test_sbin_dir(self) -> None: - source = ApplicationsScanner._classify_binary_source(Path("/sbin/ping"), set()) + source = ApplicationsScanner._classify_binary_source(Path("/sbin/ping")) assert source == BinarySource.SYSTEM - def test_brew_by_name(self) -> None: + def test_brew_by_path(self) -> None: source = ApplicationsScanner._classify_binary_source( - Path("/opt/homebrew/bin/rg"), {"rg"} + Path("/opt/homebrew/bin/rg") ) assert source == BinarySource.BREW - def test_brew_by_path(self) -> None: + def test_brew_by_cellar_path(self) -> None: source = ApplicationsScanner._classify_binary_source( - Path("/opt/homebrew/Cellar/ripgrep/14.0/bin/rg"), set() + Path("/opt/homebrew/Cellar/ripgrep/14.0/bin/rg") ) assert source == BinarySource.BREW def test_cargo_source(self) -> None: source = ApplicationsScanner._classify_binary_source( - Path("/Users/user/.cargo/bin/fd"), set() + Path("/Users/user/.cargo/bin/fd") ) assert source == BinarySource.CARGO def test_go_source(self) -> None: source = ApplicationsScanner._classify_binary_source( - Path("/Users/user/go/bin/golangci-lint"), set() + Path("/Users/user/go/bin/golangci-lint") ) assert source == BinarySource.GO def test_pipx_source(self) -> None: source = ApplicationsScanner._classify_binary_source( - Path("/Users/user/.local/bin/black"), set() + Path("/Users/user/.local/bin/black") ) assert source == BinarySource.PIPX def test_npm_source(self) -> None: source = ApplicationsScanner._classify_binary_source( - Path("/Users/user/.npm/bin/eslint"), set() + Path("/Users/user/.npm/bin/eslint") ) assert source == BinarySource.NPM def test_gem_source(self) -> None: source = ApplicationsScanner._classify_binary_source( - Path("/Users/user/.gem/ruby/3.2.0/bin/rubocop"), set() + Path("/Users/user/.gem/ruby/3.2.0/bin/rubocop") ) assert source == BinarySource.GEM def test_unknown_defaults_manual(self) -> None: source = ApplicationsScanner._classify_binary_source( - Path("/some/random/path/tool"), set() + Path("/some/random/path/tool") ) assert source == BinarySource.MANUAL diff --git a/tests/scanners/test_homebrew.py b/tests/scanners/test_homebrew.py index 56e5962..f068793 100644 --- a/tests/scanners/test_homebrew.py +++ b/tests/scanners/test_homebrew.py @@ -1,5 +1,6 @@ """Tests for Homebrew scanner.""" +import json from pathlib import Path from unittest.mock import patch @@ -42,7 +43,7 @@ def _scan_side_effects(self, cmd_result, brewfile=_BREWFILE, versions=_VERSIONS) cmd_result(brewfile), # brew bundle dump cmd_result(versions), # brew list --versions cmd_result(""), # brew list --pinned - cmd_result(""), # brew services list + cmd_result("[]"), # brew services list --json cmd_result("/opt/homebrew"), # brew --prefix ] @@ -149,16 +150,17 @@ def test_pinned_formulae(self, cmd_result) -> None: assert git_formula.pinned is False def test_services_parsing(self, cmd_result) -> None: - services_output = ( - "Name Status User File\n" - "mysql started wgordon /opt/homebrew/opt/mysql/homebrew.mysql.plist\n" - "redis stopped\n" - ) + services_json = json.dumps([ + {"name": "mysql", "status": "started", "user": "wgordon", + "file": "/opt/homebrew/opt/mysql/homebrew.mysql.plist", "exit_code": None}, + {"name": "redis", "status": "stopped", "user": None, + "file": None, "exit_code": None}, + ]) side_effects = [ cmd_result(_BREWFILE), cmd_result(_VERSIONS), cmd_result(""), - cmd_result(services_output), + cmd_result(services_json), cmd_result("/opt/homebrew"), ] with patch( @@ -178,13 +180,12 @@ def test_services_parsing(self, cmd_result) -> None: assert redis.user is None assert redis.plist_path is None - def test_services_header_skipped(self, cmd_result) -> None: - services_output = "Name Status User File\n" + def test_services_empty_json(self, cmd_result) -> None: side_effects = [ cmd_result(_BREWFILE), cmd_result(_VERSIONS), cmd_result(""), - cmd_result(services_output), + cmd_result("[]"), cmd_result("/opt/homebrew"), ] with patch( @@ -240,16 +241,16 @@ def test_prefix_command_fails(self) -> None: assert isinstance(result, HomebrewState) assert result.prefix is None - def test_services_none_user_plist(self, cmd_result) -> None: - services_output = ( - "Name Status User File\n" - "dnsmasq started none none\n" - ) + def test_services_null_user_file(self, cmd_result) -> None: + services_json = json.dumps([ + {"name": "dnsmasq", "status": "started", "user": None, + "file": None, "exit_code": None}, + ]) side_effects = [ cmd_result(_BREWFILE), cmd_result(_VERSIONS), cmd_result(""), - cmd_result(services_output), + cmd_result(services_json), cmd_result("/opt/homebrew"), ] with patch( diff --git a/tests/scanners/test_utils.py b/tests/scanners/test_utils.py index 6ff7d1d..1e49fef 100644 --- a/tests/scanners/test_utils.py +++ b/tests/scanners/test_utils.py @@ -7,7 +7,7 @@ from unittest.mock import patch from mac2nix.scanners._utils import ( - _convert_datetimes, + convert_datetimes, hash_file, read_launchd_plists, read_plist_safe, @@ -138,29 +138,29 @@ def test_read_plist_safe_converts_datetimes(self, tmp_path: Path) -> None: class TestConvertDatetimes: def test_datetime_converted(self) -> None: dt = datetime(2026, 3, 7, 12, 0, 0, tzinfo=UTC) - result = _convert_datetimes(dt) + result = convert_datetimes(dt) assert isinstance(result, str) assert "2026-03-07" in result def test_nested_dict(self) -> None: dt = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC) data = {"outer": {"inner": dt, "keep": "string"}} - result = _convert_datetimes(data) + result = convert_datetimes(data) assert isinstance(result["outer"]["inner"], str) assert result["outer"]["keep"] == "string" def test_nested_list(self) -> None: dt = datetime(2026, 6, 15, 8, 0, 0, tzinfo=UTC) data = [dt, "plain", 42] - result = _convert_datetimes(data) + result = convert_datetimes(data) assert isinstance(result[0], str) assert result[1] == "plain" assert result[2] == 42 def test_passthrough_non_datetime(self) -> None: - assert _convert_datetimes("hello") == "hello" - assert _convert_datetimes(42) == 42 - assert _convert_datetimes(None) is None + assert convert_datetimes("hello") == "hello" + assert convert_datetimes(42) == 42 + assert convert_datetimes(None) is None class TestHashFile: From 87b2dd441af9c2603ded174cfd0d9fdeae2c5a61 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 10 Mar 2026 16:28:49 -0400 Subject: [PATCH 03/17] style: run ruff format on all scanner sources and tests --- src/mac2nix/scanners/app_config.py | 48 +++++++------- src/mac2nix/scanners/display.py | 4 +- src/mac2nix/scanners/dotfiles.py | 86 ++++++++++++++------------ src/mac2nix/scanners/homebrew.py | 4 +- src/mac2nix/scanners/library_audit.py | 68 ++++++++++---------- src/mac2nix/scanners/network.py | 8 +-- src/mac2nix/scanners/preferences.py | 4 +- src/mac2nix/scanners/security.py | 34 +++++++--- src/mac2nix/scanners/shell.py | 8 +-- src/mac2nix/scanners/system_scanner.py | 4 +- tests/scanners/test_applications.py | 32 +++------- tests/scanners/test_display.py | 1 + tests/scanners/test_homebrew.py | 35 ++++++----- tests/scanners/test_network.py | 7 +-- tests/scanners/test_security.py | 4 +- tests/scanners/test_system_scanner.py | 11 +--- 16 files changed, 171 insertions(+), 187 deletions(-) diff --git a/src/mac2nix/scanners/app_config.py b/src/mac2nix/scanners/app_config.py index e24ef3d..aead506 100644 --- a/src/mac2nix/scanners/app_config.py +++ b/src/mac2nix/scanners/app_config.py @@ -28,29 +28,31 @@ ".sqlite3": ConfigFileType.DATABASE, } -_SKIP_DIRS = frozenset({ - "Caches", - "Cache", - "Logs", - "logs", - "tmp", - "temp", - "__pycache__", - "node_modules", - ".git", - ".svn", - ".hg", - "DerivedData", - "Build", - ".build", - "IndexedDB", - "GPUCache", - "ShaderCache", - "Service Worker", - "Code Cache", - "CachedData", - "blob_storage", -}) +_SKIP_DIRS = frozenset( + { + "Caches", + "Cache", + "Logs", + "logs", + "tmp", + "temp", + "__pycache__", + "node_modules", + ".git", + ".svn", + ".hg", + "DerivedData", + "Build", + ".build", + "IndexedDB", + "GPUCache", + "ShaderCache", + "Service Worker", + "Code Cache", + "CachedData", + "blob_storage", + } +) _MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB _MAX_FILES_PER_APP = 500 diff --git a/src/mac2nix/scanners/display.py b/src/mac2nix/scanners/display.py index 68759c1..b5d4c2b 100644 --- a/src/mac2nix/scanners/display.py +++ b/src/mac2nix/scanners/display.py @@ -126,9 +126,7 @@ def _get_night_shift(self) -> NightShiftConfig | None: def _get_true_tone(self) -> bool | None: """Check True Tone (Color Adaptation) status.""" - result = run_command( - ["defaults", "read", "com.apple.CoreBrightness", "CBColorAdaptationEnabled"] - ) + result = run_command(["defaults", "read", "com.apple.CoreBrightness", "CBColorAdaptationEnabled"]) if result is None or result.returncode != 0: return None value = result.stdout.strip() diff --git a/src/mac2nix/scanners/dotfiles.py b/src/mac2nix/scanners/dotfiles.py index 551c7d0..2644a67 100644 --- a/src/mac2nix/scanners/dotfiles.py +++ b/src/mac2nix/scanners/dotfiles.py @@ -12,20 +12,22 @@ logger = logging.getLogger(__name__) -_EXCLUDED_DOTFILES = frozenset({ - ".Trash", - ".cache", - ".DS_Store", - ".CFUserTextEncoding", - ".bash_history", - ".zsh_history", - ".python_history", - ".node_repl_history", - ".psql_history", - ".sqlite_history", - ".lesshst", - ".wget-hsts", -}) +_EXCLUDED_DOTFILES = frozenset( + { + ".Trash", + ".cache", + ".DS_Store", + ".CFUserTextEncoding", + ".bash_history", + ".zsh_history", + ".python_history", + ".node_repl_history", + ".psql_history", + ".sqlite_history", + ".lesshst", + ".wget-hsts", + } +) _SCAN_DIRS = [ ".config", @@ -33,30 +35,38 @@ ".local/state", ] -_SENSITIVE_DIRS = frozenset({ - ".ssh", - ".gnupg", - ".aws", - ".docker", - ".kube", - ".azure", -}) - -_SENSITIVE_DIR_PATHS = frozenset({ - ".config/gcloud", -}) - -_SENSITIVE_FILES = frozenset({ - ".netrc", - ".npmrc", - ".pypirc", -}) - -_SENSITIVE_FILE_PATHS = frozenset({ - ".gem/credentials", - ".config/gh/hosts.yml", - ".config/hub", -}) +_SENSITIVE_DIRS = frozenset( + { + ".ssh", + ".gnupg", + ".aws", + ".docker", + ".kube", + ".azure", + } +) + +_SENSITIVE_DIR_PATHS = frozenset( + { + ".config/gcloud", + } +) + +_SENSITIVE_FILES = frozenset( + { + ".netrc", + ".npmrc", + ".pypirc", + } +) + +_SENSITIVE_FILE_PATHS = frozenset( + { + ".gem/credentials", + ".config/gh/hosts.yml", + ".config/hub", + } +) @register("dotfiles") diff --git a/src/mac2nix/scanners/homebrew.py b/src/mac2nix/scanners/homebrew.py index 10386b1..b2f0d7e 100644 --- a/src/mac2nix/scanners/homebrew.py +++ b/src/mac2nix/scanners/homebrew.py @@ -143,9 +143,7 @@ def _get_services(self) -> list[BrewService]: user = entry.get("user") or None file_path = entry.get("file") or None plist_path = Path(file_path) if file_path else None - services.append( - BrewService(name=name, status=status, user=user, plist_path=plist_path) - ) + services.append(BrewService(name=name, status=status, user=user, plist_path=plist_path)) return services def _get_prefix(self) -> str | None: diff --git a/src/mac2nix/scanners/library_audit.py b/src/mac2nix/scanners/library_audit.py index 1896c61..0b48465 100644 --- a/src/mac2nix/scanners/library_audit.py +++ b/src/mac2nix/scanners/library_audit.py @@ -33,20 +33,22 @@ "SyncedPreferences": "preferences", } -_TRANSIENT_DIRS = frozenset({ - "Caches", - "Logs", - "Saved Application State", - "Cookies", - "HTTPStorages", - "WebKit", - "Messages", - "Calendars", - "Reminders", - "Metadata", - "Updates", - "Autosave Information", -}) +_TRANSIENT_DIRS = frozenset( + { + "Caches", + "Logs", + "Saved Application State", + "Cookies", + "HTTPStorages", + "WebKit", + "Messages", + "Calendars", + "Reminders", + "Metadata", + "Updates", + "Autosave Information", + } +) _SENSITIVE_KEY_PATTERNS = {"_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH"} @@ -59,15 +61,17 @@ "QuickLook": "*.qlgenerator", } -_BUNDLE_EXTENSIONS = frozenset({ - ".component", - ".vst", - ".saver", - ".prefPane", - ".qlgenerator", - ".plugin", - ".kext", -}) +_BUNDLE_EXTENSIONS = frozenset( + { + ".component", + ".vst", + ".saver", + ".prefPane", + ".qlgenerator", + ".plugin", + ".kext", + } +) def _redact_sensitive_keys(data: dict[str, Any]) -> None: @@ -182,9 +186,7 @@ def _dir_stats(path: Path) -> tuple[int | None, int | None, datetime | None]: except PermissionError: return None, None, None - def _capture_uncovered_dir( - self, dir_path: Path - ) -> tuple[list[LibraryFileEntry], list[WorkflowEntry]]: + def _capture_uncovered_dir(self, dir_path: Path) -> tuple[list[LibraryFileEntry], list[WorkflowEntry]]: """Capture files from an uncovered directory (capped).""" files: list[LibraryFileEntry] = [] workflows: list[WorkflowEntry] = [] @@ -313,14 +315,8 @@ def _scan_text_replacements(self, lib_path: Path) -> list[dict[str, str]]: try: conn = sqlite3.connect(f"file:{db_path}?mode=ro&immutable=1", uri=True) try: - cursor = conn.execute( - "SELECT ZSHORTCUT, ZPHRASE FROM ZTEXTREPLACEMENTENTRY" - ) - return [ - {"shortcut": row[0], "phrase": row[1]} - for row in cursor.fetchall() - if row[0] and row[1] - ] + cursor = conn.execute("SELECT ZSHORTCUT, ZPHRASE FROM ZTEXTREPLACEMENTENTRY") + return [{"shortcut": row[0], "phrase": row[1]} for row in cursor.fetchall() if row[0] and row[1]] finally: conn.close() except (sqlite3.OperationalError, sqlite3.DatabaseError) as exc: @@ -423,9 +419,7 @@ def _scan_scripts(self, lib_path: Path) -> list[str]: if f.is_file(): if f.suffix == ".scpt": # Try to decompile AppleScript - result = run_command( - ["osadecompile", str(f)], timeout=10 - ) + result = run_command(["osadecompile", str(f)], timeout=10) if result is not None and result.returncode == 0: scripts.append(f"{f.name}: {result.stdout[:200]}") else: diff --git a/src/mac2nix/scanners/network.py b/src/mac2nix/scanners/network.py index aa0dadf..3342b27 100644 --- a/src/mac2nix/scanners/network.py +++ b/src/mac2nix/scanners/network.py @@ -204,9 +204,7 @@ def _get_wifi_networks(self, interfaces: list[NetworkInterface]) -> list[str]: wifi_device = "en0" # Try preferred networks list first (gets all saved networks) - result = run_command( - ["networksetup", "-listpreferredwirelessnetworks", wifi_device] - ) + result = run_command(["networksetup", "-listpreferredwirelessnetworks", wifi_device]) if result is not None and result.returncode == 0: networks = [] for line in result.stdout.splitlines(): @@ -238,9 +236,7 @@ def _get_vpn_profiles(self) -> list[VpnProfile]: profiles: list[VpnProfile] = [] # Lines like: * (Connected) UUID "VPN Name" [IPSec] - vpn_pattern = re.compile( - r'^\*\s+\((\w+)\)\s+\S+\s+"([^"]+)"\s+\[(\w+)\]' - ) + vpn_pattern = re.compile(r'^\*\s+\((\w+)\)\s+\S+\s+"([^"]+)"\s+\[(\w+)\]') for line in result.stdout.splitlines(): match = vpn_pattern.match(line.strip()) if match: diff --git a/src/mac2nix/scanners/preferences.py b/src/mac2nix/scanners/preferences.py index 4d59290..454b4e9 100644 --- a/src/mac2nix/scanners/preferences.py +++ b/src/mac2nix/scanners/preferences.py @@ -59,9 +59,7 @@ def scan(self) -> PreferencesResult: return PreferencesResult(domains=domains) - def _discover_cfprefsd_domains( - self, domains: list[PreferencesDomain], seen: set[str] - ) -> None: + def _discover_cfprefsd_domains(self, domains: list[PreferencesDomain], seen: set[str]) -> None: """Find domains registered in cfprefsd but without on-disk plist files.""" result = run_command(["defaults", "domains"]) if result is None or result.returncode != 0: diff --git a/src/mac2nix/scanners/security.py b/src/mac2nix/scanners/security.py index badae7f..6a007a7 100644 --- a/src/mac2nix/scanners/security.py +++ b/src/mac2nix/scanners/security.py @@ -147,19 +147,35 @@ def _get_tcc_summary(self) -> dict[str, list[str]]: def _get_custom_certificates(self) -> list[str]: """Discover custom/corporate certificates in System keychain.""" - result = run_command( - ["security", "find-certificate", "-a", "/Library/Keychains/System.keychain"] - ) + result = run_command(["security", "find-certificate", "-a", "/Library/Keychains/System.keychain"]) if result is None or result.returncode != 0: return [] # Well-known CA issuers to filter out - known_cas = frozenset({ - "apple", "digicert", "verisign", "entrust", "globalsign", "comodo", - "geotrust", "thawte", "symantec", "godaddy", "letsencrypt", - "usertrust", "sectigo", "baltimore", "cybertrust", "certum", - "starfield", "amazontrust", "microsoftroot", "microsoft", - }) + known_cas = frozenset( + { + "apple", + "digicert", + "verisign", + "entrust", + "globalsign", + "comodo", + "geotrust", + "thawte", + "symantec", + "godaddy", + "letsencrypt", + "usertrust", + "sectigo", + "baltimore", + "cybertrust", + "certum", + "starfield", + "amazontrust", + "microsoftroot", + "microsoft", + } + ) certificates: list[str] = [] cert_name_pattern = re.compile(r'"labl"="(.+)"') diff --git a/src/mac2nix/scanners/shell.py b/src/mac2nix/scanners/shell.py index e55375b..ed20671 100644 --- a/src/mac2nix/scanners/shell.py +++ b/src/mac2nix/scanners/shell.py @@ -207,17 +207,13 @@ def _parse_rc_file( self._parse_posix_line(stripped, parsed) self._check_source_posix(stripped, parsed, home, seen_files) - def _check_source_posix( - self, line: str, parsed: _ParsedShellData, home: Path, seen_files: set[Path] - ) -> None: + def _check_source_posix(self, line: str, parsed: _ParsedShellData, home: Path, seen_files: set[Path]) -> None: match = _SOURCE_PATTERN.match(line) if not match: return self._resolve_and_track_source(match.group(1).strip("'\""), parsed, home, seen_files) - def _check_source_fish( - self, line: str, parsed: _ParsedShellData, home: Path, seen_files: set[Path] - ) -> None: + def _check_source_fish(self, line: str, parsed: _ParsedShellData, home: Path, seen_files: set[Path]) -> None: match = _FISH_SOURCE_PATTERN.match(line) if not match: return diff --git a/src/mac2nix/scanners/system_scanner.py b/src/mac2nix/scanners/system_scanner.py index b5de3d7..16001af 100644 --- a/src/mac2nix/scanners/system_scanner.py +++ b/src/mac2nix/scanners/system_scanner.py @@ -163,9 +163,7 @@ def _get_hardware_info( self, ) -> tuple[str | None, str | None, str | None, str | None]: """Parse system_profiler SPHardwareDataType for hardware info.""" - result = run_command( - ["system_profiler", "SPHardwareDataType", "-json"], timeout=15 - ) + result = run_command(["system_profiler", "SPHardwareDataType", "-json"], timeout=15) if result is None or result.returncode != 0: return None, None, None, None diff --git a/tests/scanners/test_applications.py b/tests/scanners/test_applications.py index a18d911..ab42b77 100644 --- a/tests/scanners/test_applications.py +++ b/tests/scanners/test_applications.py @@ -199,51 +199,35 @@ def test_sbin_dir(self) -> None: assert source == BinarySource.SYSTEM def test_brew_by_path(self) -> None: - source = ApplicationsScanner._classify_binary_source( - Path("/opt/homebrew/bin/rg") - ) + source = ApplicationsScanner._classify_binary_source(Path("/opt/homebrew/bin/rg")) assert source == BinarySource.BREW def test_brew_by_cellar_path(self) -> None: - source = ApplicationsScanner._classify_binary_source( - Path("/opt/homebrew/Cellar/ripgrep/14.0/bin/rg") - ) + source = ApplicationsScanner._classify_binary_source(Path("/opt/homebrew/Cellar/ripgrep/14.0/bin/rg")) assert source == BinarySource.BREW def test_cargo_source(self) -> None: - source = ApplicationsScanner._classify_binary_source( - Path("/Users/user/.cargo/bin/fd") - ) + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.cargo/bin/fd")) assert source == BinarySource.CARGO def test_go_source(self) -> None: - source = ApplicationsScanner._classify_binary_source( - Path("/Users/user/go/bin/golangci-lint") - ) + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/go/bin/golangci-lint")) assert source == BinarySource.GO def test_pipx_source(self) -> None: - source = ApplicationsScanner._classify_binary_source( - Path("/Users/user/.local/bin/black") - ) + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.local/bin/black")) assert source == BinarySource.PIPX def test_npm_source(self) -> None: - source = ApplicationsScanner._classify_binary_source( - Path("/Users/user/.npm/bin/eslint") - ) + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.npm/bin/eslint")) assert source == BinarySource.NPM def test_gem_source(self) -> None: - source = ApplicationsScanner._classify_binary_source( - Path("/Users/user/.gem/ruby/3.2.0/bin/rubocop") - ) + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.gem/ruby/3.2.0/bin/rubocop")) assert source == BinarySource.GEM def test_unknown_defaults_manual(self) -> None: - source = ApplicationsScanner._classify_binary_source( - Path("/some/random/path/tool") - ) + source = ApplicationsScanner._classify_binary_source(Path("/some/random/path/tool")) assert source == BinarySource.MANUAL diff --git a/tests/scanners/test_display.py b/tests/scanners/test_display.py index 55a7876..a3ccedd 100644 --- a/tests/scanners/test_display.py +++ b/tests/scanners/test_display.py @@ -347,6 +347,7 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces def test_night_shift_nested_key(self, cmd_result) -> None: """Test fallback for Night Shift data nested under a user key.""" + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: if "SPDisplaysDataType" in cmd: return cmd_result(json.dumps({"SPDisplaysDataType": []})) diff --git a/tests/scanners/test_homebrew.py b/tests/scanners/test_homebrew.py index f068793..30087dd 100644 --- a/tests/scanners/test_homebrew.py +++ b/tests/scanners/test_homebrew.py @@ -40,10 +40,10 @@ def test_is_available_brew_absent(self) -> None: def _scan_side_effects(self, cmd_result, brewfile=_BREWFILE, versions=_VERSIONS): """Build side_effect list for all 5 run_command calls in scan().""" return [ - cmd_result(brewfile), # brew bundle dump - cmd_result(versions), # brew list --versions - cmd_result(""), # brew list --pinned - cmd_result("[]"), # brew services list --json + cmd_result(brewfile), # brew bundle dump + cmd_result(versions), # brew list --versions + cmd_result(""), # brew list --pinned + cmd_result("[]"), # brew services list --json cmd_result("/opt/homebrew"), # brew --prefix ] @@ -150,12 +150,18 @@ def test_pinned_formulae(self, cmd_result) -> None: assert git_formula.pinned is False def test_services_parsing(self, cmd_result) -> None: - services_json = json.dumps([ - {"name": "mysql", "status": "started", "user": "wgordon", - "file": "/opt/homebrew/opt/mysql/homebrew.mysql.plist", "exit_code": None}, - {"name": "redis", "status": "stopped", "user": None, - "file": None, "exit_code": None}, - ]) + services_json = json.dumps( + [ + { + "name": "mysql", + "status": "started", + "user": "wgordon", + "file": "/opt/homebrew/opt/mysql/homebrew.mysql.plist", + "exit_code": None, + }, + {"name": "redis", "status": "stopped", "user": None, "file": None, "exit_code": None}, + ] + ) side_effects = [ cmd_result(_BREWFILE), cmd_result(_VERSIONS), @@ -242,10 +248,11 @@ def test_prefix_command_fails(self) -> None: assert result.prefix is None def test_services_null_user_file(self, cmd_result) -> None: - services_json = json.dumps([ - {"name": "dnsmasq", "status": "started", "user": None, - "file": None, "exit_code": None}, - ]) + services_json = json.dumps( + [ + {"name": "dnsmasq", "status": "started", "user": None, "file": None, "exit_code": None}, + ] + ) side_effects = [ cmd_result(_BREWFILE), cmd_result(_VERSIONS), diff --git a/tests/scanners/test_network.py b/tests/scanners/test_network.py index 305cc0d..f0acebc 100644 --- a/tests/scanners/test_network.py +++ b/tests/scanners/test_network.py @@ -348,12 +348,7 @@ def test_network_locations(self, cmd_result) -> None: assert result.current_location == "Work" def test_wifi_preferred_networks(self, cmd_result) -> None: - preferred = ( - "Preferred networks on en0:\n" - "\tHomeNetwork\n" - "\tOfficeWifi\n" - "\tCoffeeShop\n" - ) + preferred = "Preferred networks on en0:\n\tHomeNetwork\n\tOfficeWifi\n\tCoffeeShop\n" responses = { ("networksetup", "-listallhardwareports"): cmd_result( "Hardware Port: Wi-Fi\nDevice: en0\nEthernet Address: aa:bb:cc:dd:ee:ff\n" diff --git a/tests/scanners/test_security.py b/tests/scanners/test_security.py index 4eeda35..c47e637 100644 --- a/tests/scanners/test_security.py +++ b/tests/scanners/test_security.py @@ -326,9 +326,7 @@ def test_custom_certificates_command_fails(self) -> None: def test_custom_certificates_no_custom(self, cmd_result) -> None: cert_output = ( - '"labl"="DigiCert Global Root G2"\n' - '"labl"="Apple Root CA"\n' - '"labl"="VeriSign Class 3"\n' + '"labl"="DigiCert Global Root G2"\n"labl"="Apple Root CA"\n"labl"="VeriSign Class 3"\n' ) def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: diff --git a/tests/scanners/test_system_scanner.py b/tests/scanners/test_system_scanner.py index a0edc74..70981bd 100644 --- a/tests/scanners/test_system_scanner.py +++ b/tests/scanners/test_system_scanner.py @@ -142,11 +142,7 @@ def test_returns_system_config(self) -> None: assert isinstance(result, SystemConfig) def test_macos_version(self, cmd_result) -> None: - sw_vers_output = ( - "ProductName:\tmacOS\n" - "ProductVersion:\t15.3.1\n" - "BuildVersion:\t24D70\n" - ) + sw_vers_output = "ProductName:\tmacOS\nProductVersion:\t15.3.1\nBuildVersion:\t24D70\n" def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: if cmd == ["scutil", "--get", "ComputerName"]: @@ -475,10 +471,7 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces assert result.network_time_enabled is False def test_printers(self, cmd_result) -> None: - lpstat_a = ( - "HP_LaserJet accepting requests since Mon Mar 9\n" - "Brother_HL accepting requests since Mon Mar 9\n" - ) + lpstat_a = "HP_LaserJet accepting requests since Mon Mar 9\nBrother_HL accepting requests since Mon Mar 9\n" lpstat_d = "system default destination: HP_LaserJet\n" lpoptions_hp = "PageSize/Media Size: Letter *A4 Legal\nDuplex/Double-Sided: None *DuplexNoTumble\n" lpoptions_brother = "PageSize/Media Size: *Letter A4\n" From fb939256eaa7508cc2839d62f2130648b54895f7 Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 11 Mar 2026 11:50:59 -0400 Subject: [PATCH 04/17] feat(scanners): nix-state, version/package managers, containers Implement 4 new scanners detecting non-macOS-native package management: - nix_state: Nix installation, profiles, nix-darwin, home-manager, channels, flakes, registries, config, devbox/devenv/direnv - version_managers: asdf, mise, nvm, pyenv, rbenv, jenv, sdkman - package_managers: MacPorts, Conda/Mamba - containers: Docker, Podman, Colima, OrbStack, Lima Also extends existing scanners: - system_scanner: Rosetta 2, System Extensions, iCloud, MDM detection - applications: 9 new BinarySource values + 19 path patterns New models in package_managers.py (27 models). SystemConfig extended with rosetta_installed, system_extensions, icloud, mdm_enrolled. SystemState wired with 4 new domain fields. 19 total scanners. 242 new tests (734 total), ruff clean, pyright 0 errors. --- src/mac2nix/models/__init__.py | 60 ++ src/mac2nix/models/application.py | 16 +- src/mac2nix/models/package_managers.py | 209 +++++ src/mac2nix/models/system.py | 23 +- src/mac2nix/models/system_state.py | 10 + src/mac2nix/scanners/__init__.py | 4 + src/mac2nix/scanners/applications.py | 21 + src/mac2nix/scanners/containers.py | 215 +++++ src/mac2nix/scanners/nix_state.py | 525 +++++++++++ .../scanners/package_managers_scanner.py | 218 +++++ src/mac2nix/scanners/system_scanner.py | 111 ++- src/mac2nix/scanners/version_managers.py | 438 ++++++++++ tests/models/test_package_managers.py | 525 +++++++++++ tests/models/test_remaining.py | 105 ++- tests/scanners/test_applications.py | 70 ++ tests/scanners/test_containers.py | 490 +++++++++++ tests/scanners/test_nix_state.py | 825 ++++++++++++++++++ tests/scanners/test_package_managers.py | 432 +++++++++ tests/scanners/test_system_scanner.py | 260 ++++++ tests/scanners/test_version_managers.py | 723 +++++++++++++++ 20 files changed, 5272 insertions(+), 8 deletions(-) create mode 100644 src/mac2nix/models/package_managers.py create mode 100644 src/mac2nix/scanners/containers.py create mode 100644 src/mac2nix/scanners/nix_state.py create mode 100644 src/mac2nix/scanners/package_managers_scanner.py create mode 100644 src/mac2nix/scanners/version_managers.py create mode 100644 tests/models/test_package_managers.py create mode 100644 tests/scanners/test_containers.py create mode 100644 tests/scanners/test_nix_state.py create mode 100644 tests/scanners/test_package_managers.py create mode 100644 tests/scanners/test_version_managers.py diff --git a/src/mac2nix/models/__init__.py b/src/mac2nix/models/__init__.py index 7a8cb69..79e0ef6 100644 --- a/src/mac2nix/models/__init__.py +++ b/src/mac2nix/models/__init__.py @@ -37,6 +37,35 @@ Monitor, NightShiftConfig, ) +from mac2nix.models.package_managers import ( + CondaEnvironment, + CondaPackage, + CondaState, + ContainerRuntimeInfo, + ContainerRuntimeType, + ContainersResult, + DevboxProject, + DevenvProject, + HomeManagerState, + MacPortsPackage, + MacPortsState, + ManagedRuntime, + NixChannel, + NixConfig, + NixDarwinState, + NixDirenvConfig, + NixFlakeInput, + NixInstallation, + NixInstallType, + NixProfile, + NixProfilePackage, + NixRegistryEntry, + NixState, + PackageManagersResult, + VersionManagerInfo, + VersionManagersResult, + VersionManagerType, +) from mac2nix.models.preferences import PreferencesDomain, PreferencesResult, PreferenceValue from mac2nix.models.services import ( CronEntry, @@ -50,11 +79,13 @@ ) from mac2nix.models.system import ( FirewallAppRule, + ICloudState, NetworkConfig, NetworkInterface, PrinterInfo, SecurityState, SystemConfig, + SystemExtension, TimeMachineConfig, VpnProfile, ) @@ -72,8 +103,16 @@ "BrewFormula", "BrewService", "BundleEntry", + "CondaEnvironment", + "CondaPackage", + "CondaState", "ConfigFileType", + "ContainerRuntimeInfo", + "ContainerRuntimeType", + "ContainersResult", "CronEntry", + "DevboxProject", + "DevenvProject", "DisplayConfig", "DotfileEntry", "DotfileManager", @@ -83,7 +122,9 @@ "FontEntry", "FontSource", "FontsResult", + "HomeManagerState", "HomebrewState", + "ICloudState", "InstalledApp", "KeyBindingEntry", "LaunchAgentEntry", @@ -93,11 +134,26 @@ "LibraryAuditResult", "LibraryDirEntry", "LibraryFileEntry", + "MacPortsPackage", + "MacPortsState", + "ManagedRuntime", "MasApp", "Monitor", "NetworkConfig", "NetworkInterface", "NightShiftConfig", + "NixChannel", + "NixConfig", + "NixDarwinState", + "NixDirenvConfig", + "NixFlakeInput", + "NixInstallType", + "NixInstallation", + "NixProfile", + "NixProfilePackage", + "NixRegistryEntry", + "NixState", + "PackageManagersResult", "PathBinary", "PreferenceValue", "PreferencesDomain", @@ -108,8 +164,12 @@ "ShellConfig", "ShellFramework", "SystemConfig", + "SystemExtension", "SystemState", "TimeMachineConfig", + "VersionManagerInfo", + "VersionManagerType", + "VersionManagersResult", "VpnProfile", "WorkflowEntry", ] diff --git a/src/mac2nix/models/application.py b/src/mac2nix/models/application.py index 58fc5b3..6022b54 100644 --- a/src/mac2nix/models/application.py +++ b/src/mac2nix/models/application.py @@ -15,14 +15,24 @@ class AppSource(StrEnum): class BinarySource(StrEnum): + ASDF = "asdf" BREW = "brew" CARGO = "cargo" + CONDA = "conda" + GEM = "gem" GO = "go" - PIPX = "pipx" + JENV = "jenv" + MACPORTS = "macports" + MANUAL = "manual" + MISE = "mise" + NIX = "nix" NPM = "npm" - GEM = "gem" + NVM = "nvm" + PIPX = "pipx" + PYENV = "pyenv" + RBENV = "rbenv" + SDKMAN = "sdkman" SYSTEM = "system" - MANUAL = "manual" class InstalledApp(BaseModel): diff --git a/src/mac2nix/models/package_managers.py b/src/mac2nix/models/package_managers.py new file mode 100644 index 0000000..3398b42 --- /dev/null +++ b/src/mac2nix/models/package_managers.py @@ -0,0 +1,209 @@ +"""Nix, version manager, and third-party package manager models.""" + +from __future__ import annotations + +from enum import StrEnum +from pathlib import Path + +from pydantic import BaseModel, Field + + +class NixInstallType(StrEnum): + SINGLE_USER = "single_user" + MULTI_USER = "multi_user" + DETERMINATE = "determinate" + UNKNOWN = "unknown" + + +class NixInstallation(BaseModel): + present: bool = False + version: str | None = None + store_path: Path = Path("/nix/store") + install_type: NixInstallType = NixInstallType.UNKNOWN + daemon_running: bool = False + + +class NixProfilePackage(BaseModel): + name: str + version: str | None = None + store_path: Path | None = None + + +class NixProfile(BaseModel): + name: str + path: Path + packages: list[NixProfilePackage] = [] + + +class NixDarwinState(BaseModel): + present: bool = False + generation: int | None = None + config_path: Path | None = None + system_packages: list[str] = [] + + +class HomeManagerState(BaseModel): + present: bool = False + generation: int | None = None + config_path: Path | None = None + packages: list[str] = [] + + +class NixChannel(BaseModel): + name: str + url: str + + +class NixFlakeInput(BaseModel): + name: str + url: str | None = None + locked_rev: str | None = None + + +class NixRegistryEntry(BaseModel): + from_name: str + to_url: str + + +class NixConfig(BaseModel): + """Key settings from nix.conf. + + SECURITY: access-tokens and netrc-file values MUST be redacted before storing. + """ + + experimental_features: list[str] = [] + substituters: list[str] = [] + trusted_users: list[str] = [] + max_jobs: int | None = None + sandbox: bool | None = None + extra_config: dict[str, str] = Field(default_factory=dict) + + +class DevboxProject(BaseModel): + path: Path + packages: list[str] = [] + + +class DevenvProject(BaseModel): + path: Path + has_lock: bool = False + + +class NixDirenvConfig(BaseModel): + """Tracks .envrc files that use nix-direnv or use_nix.""" + + path: Path + use_flake: bool = False + use_nix: bool = False + + +class NixState(BaseModel): + """Aggregate Nix ecosystem state.""" + + installation: NixInstallation = Field(default_factory=NixInstallation) + profiles: list[NixProfile] = [] + darwin: NixDarwinState = Field(default_factory=NixDarwinState) + home_manager: HomeManagerState = Field(default_factory=HomeManagerState) + channels: list[NixChannel] = [] + flake_inputs: list[NixFlakeInput] = [] + registries: list[NixRegistryEntry] = [] + config: NixConfig = Field(default_factory=NixConfig) + devbox_projects: list[DevboxProject] = [] + devenv_projects: list[DevenvProject] = [] + direnv_configs: list[NixDirenvConfig] = [] + + +class VersionManagerType(StrEnum): + ASDF = "asdf" + MISE = "mise" + NVM = "nvm" + PYENV = "pyenv" + RBENV = "rbenv" + JENV = "jenv" + SDKMAN = "sdkman" + + +class ManagedRuntime(BaseModel): + """A single runtime version managed by a version manager.""" + + manager: VersionManagerType + language: str + version: str + path: Path | None = None + active: bool = False + + +class VersionManagerInfo(BaseModel): + """State of one version manager installation.""" + + manager_type: VersionManagerType + version: str | None = None + config_path: Path | None = None + runtimes: list[ManagedRuntime] = [] + + +class VersionManagersResult(BaseModel): + """Aggregate version manager state.""" + + managers: list[VersionManagerInfo] = [] + global_tool_versions: Path | None = None + + +class MacPortsPackage(BaseModel): + name: str + version: str | None = None + active: bool = True + variants: list[str] = [] + + +class MacPortsState(BaseModel): + present: bool = False + version: str | None = None + prefix: Path = Path("/opt/local") + packages: list[MacPortsPackage] = [] + + +class CondaPackage(BaseModel): + name: str + version: str | None = None + channel: str | None = None + + +class CondaEnvironment(BaseModel): + name: str + path: Path + is_active: bool = False + packages: list[CondaPackage] = [] + + +class CondaState(BaseModel): + present: bool = False + version: str | None = None + environments: list[CondaEnvironment] = [] + + +class PackageManagersResult(BaseModel): + """Third-party (non-Homebrew, non-Nix) package managers.""" + + macports: MacPortsState = Field(default_factory=MacPortsState) + conda: CondaState = Field(default_factory=CondaState) + + +class ContainerRuntimeType(StrEnum): + DOCKER = "docker" + PODMAN = "podman" + COLIMA = "colima" + ORBSTACK = "orbstack" + LIMA = "lima" + + +class ContainerRuntimeInfo(BaseModel): + runtime_type: ContainerRuntimeType + version: str | None = None + running: bool = False + config_path: Path | None = None + socket_path: Path | None = None + + +class ContainersResult(BaseModel): + runtimes: list[ContainerRuntimeInfo] = [] diff --git a/src/mac2nix/models/system.py b/src/mac2nix/models/system.py index d4eb8e7..5d6d663 100644 --- a/src/mac2nix/models/system.py +++ b/src/mac2nix/models/system.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, Field class NetworkInterface(BaseModel): @@ -67,6 +67,23 @@ class PrinterInfo(BaseModel): options: dict[str, str] = {} +class SystemExtension(BaseModel): + """A system extension from /Library/SystemExtensions/.""" + + identifier: str + team_id: str | None = None + version: str | None = None + state: str | None = None + + +class ICloudState(BaseModel): + """iCloud sync status — scan-only, cannot be configured via nix-darwin.""" + + signed_in: bool = False + desktop_sync: bool = False + documents_sync: bool = False + + class SystemConfig(BaseModel): hostname: str timezone: str | None = None @@ -93,3 +110,7 @@ class SystemConfig(BaseModel): remote_login: bool | None = None screen_sharing: bool | None = None file_sharing: bool | None = None + rosetta_installed: bool | None = None + system_extensions: list[SystemExtension] = [] + icloud: ICloudState = Field(default_factory=ICloudState) + mdm_enrolled: bool | None = None diff --git a/src/mac2nix/models/system_state.py b/src/mac2nix/models/system_state.py index 075fb1f..9fa46d6 100644 --- a/src/mac2nix/models/system_state.py +++ b/src/mac2nix/models/system_state.py @@ -10,6 +10,12 @@ from mac2nix.models.application import ApplicationsResult, HomebrewState from mac2nix.models.files import AppConfigResult, DotfilesResult, FontsResult, LibraryAuditResult from mac2nix.models.hardware import AudioConfig, DisplayConfig +from mac2nix.models.package_managers import ( + ContainersResult, + NixState, + PackageManagersResult, + VersionManagersResult, +) from mac2nix.models.preferences import PreferencesResult from mac2nix.models.services import LaunchAgentsResult, ScheduledTasks, ShellConfig from mac2nix.models.system import NetworkConfig, SecurityState, SystemConfig @@ -42,6 +48,10 @@ class SystemState(BaseModel): audio: AudioConfig | None = None cron: ScheduledTasks | None = None library_audit: LibraryAuditResult | None = None + nix_state: NixState | None = None + version_managers: VersionManagersResult | None = None + package_managers: PackageManagersResult | None = None + containers: ContainersResult | None = None def to_json(self, path: Path | None = None) -> str: """Serialize to JSON string. Optionally write to file.""" diff --git a/src/mac2nix/scanners/__init__.py b/src/mac2nix/scanners/__init__.py index c673590..8d99231 100644 --- a/src/mac2nix/scanners/__init__.py +++ b/src/mac2nix/scanners/__init__.py @@ -4,6 +4,7 @@ app_config, applications, audio, + containers, cron, display, dotfiles, @@ -12,10 +13,13 @@ launch_agents, library_audit, network, + nix_state, + package_managers_scanner, preferences, security, shell, system_scanner, + version_managers, ) from mac2nix.scanners.base import ( SCANNER_REGISTRY, diff --git a/src/mac2nix/scanners/applications.py b/src/mac2nix/scanners/applications.py index 18df7cb..6b7ed46 100644 --- a/src/mac2nix/scanners/applications.py +++ b/src/mac2nix/scanners/applications.py @@ -33,6 +33,27 @@ ".npm": BinarySource.NPM, "node_modules/.bin": BinarySource.NPM, ".gem": BinarySource.GEM, + ".nix-profile/bin": BinarySource.NIX, + "nix/store": BinarySource.NIX, + # Version managers and package managers + "opt/local/bin": BinarySource.MACPORTS, + ".asdf/shims": BinarySource.ASDF, + ".asdf/installs": BinarySource.ASDF, + ".local/share/mise": BinarySource.MISE, + ".mise/shims": BinarySource.MISE, + ".nvm/versions": BinarySource.NVM, + ".pyenv/shims": BinarySource.PYENV, + ".pyenv/versions": BinarySource.PYENV, + ".rbenv/shims": BinarySource.RBENV, + ".rbenv/versions": BinarySource.RBENV, + "miniconda3/bin": BinarySource.CONDA, + "miniconda3/envs": BinarySource.CONDA, + "miniforge3/bin": BinarySource.CONDA, + "miniforge3/envs": BinarySource.CONDA, + "anaconda3/bin": BinarySource.CONDA, + "anaconda3/envs": BinarySource.CONDA, + ".sdkman/candidates": BinarySource.SDKMAN, + ".jenv/shims": BinarySource.JENV, } _SYSTEM_DIRS = frozenset({"/usr/bin", "/bin", "/usr/sbin", "/sbin"}) diff --git a/src/mac2nix/scanners/containers.py b/src/mac2nix/scanners/containers.py new file mode 100644 index 0000000..5aeaf4a --- /dev/null +++ b/src/mac2nix/scanners/containers.py @@ -0,0 +1,215 @@ +"""Container runtimes scanner — detects Docker, Podman, Colima, OrbStack, Lima.""" + +from __future__ import annotations + +import contextlib +import json +import logging +import shutil +from pathlib import Path + +from mac2nix.models.package_managers import ( + ContainerRuntimeInfo, + ContainerRuntimeType, + ContainersResult, +) +from mac2nix.scanners._utils import run_command +from mac2nix.scanners.base import BaseScannerPlugin, register + +logger = logging.getLogger(__name__) + + +@register("containers") +class ContainersScanner(BaseScannerPlugin): + @property + def name(self) -> str: + return "containers" + + def scan(self) -> ContainersResult: + runtimes: list[ContainerRuntimeInfo] = [] + for detector in [ + self._detect_docker, + self._detect_podman, + self._detect_colima, + self._detect_orbstack, + self._detect_lima, + ]: + info = detector() + if info is not None: + runtimes.append(info) + return ContainersResult(runtimes=runtimes) + + def _detect_docker(self) -> ContainerRuntimeInfo | None: + if shutil.which("docker") is None: + return None + + version: str | None = None + result = run_command(["docker", "--version"]) + if result and result.returncode == 0: + # "Docker version 24.0.7, build afdd53b" + parts = result.stdout.strip().split() + for i, part in enumerate(parts): + if part == "version": + version = parts[i + 1].rstrip(",") if i + 1 < len(parts) else None + break + + # Check socket existence for running status (avoids 10-30s docker info hang) + home = Path.home() + socket_path: Path | None = None + running = False + for candidate in [ + home / ".docker" / "run" / "docker.sock", + Path("/var/run/docker.sock"), + ]: + if candidate.exists(): + socket_path = candidate + running = True + break + + config_path: Path | None = None + config_candidate = home / ".docker" / "config.json" + if config_candidate.is_file(): + config_path = config_candidate + + return ContainerRuntimeInfo( + runtime_type=ContainerRuntimeType.DOCKER, + version=version, + running=running, + config_path=config_path, + socket_path=socket_path, + ) + + def _detect_podman(self) -> ContainerRuntimeInfo | None: + if shutil.which("podman") is None: + return None + + version: str | None = None + result = run_command(["podman", "--version"]) + if result and result.returncode == 0: + # "podman version 5.0.0" + parts = result.stdout.strip().split() + if len(parts) >= 3: + version = parts[2] + + # Check socket/machine for running status (mirrors Docker's approach) + home = Path.home() + running = False + socket_candidates = [ + home / ".local" / "share" / "containers" / "podman" / "machine" / "podman.sock", + Path("/var/run/podman/podman.sock"), + ] + for sock in socket_candidates: + if sock.exists(): + running = True + break + + config_path: Path | None = None + config_dir = home / ".config" / "containers" + if config_dir.is_dir(): + config_path = config_dir + + return ContainerRuntimeInfo( + runtime_type=ContainerRuntimeType.PODMAN, + version=version, + running=running, + config_path=config_path, + ) + + def _detect_colima(self) -> ContainerRuntimeInfo | None: + if shutil.which("colima") is None: + return None + + version: str | None = None + result = run_command(["colima", "version"]) + if result and result.returncode == 0: + # Parse version string — e.g. "colima version 0.6.8" + for line in result.stdout.strip().splitlines(): + parts = line.strip().split() + for i, part in enumerate(parts): + if part == "version" and i + 1 < len(parts): + version = parts[i + 1] + break + if version: + break + + running = False + status_result = run_command(["colima", "status"]) + if status_result and status_result.returncode == 0: + running = True + + config_path: Path | None = None + config_dir = Path.home() / ".colima" + if config_dir.is_dir(): + config_path = config_dir + + return ContainerRuntimeInfo( + runtime_type=ContainerRuntimeType.COLIMA, + version=version, + running=running, + config_path=config_path, + ) + + def _detect_orbstack(self) -> ContainerRuntimeInfo | None: + has_orbctl = shutil.which("orbctl") is not None + has_app = Path("/Applications/OrbStack.app").exists() + if not has_orbctl and not has_app: + return None + + version: str | None = None + running = False + + if has_orbctl: + result = run_command(["orbctl", "version"]) + if result and result.returncode == 0: + version = result.stdout.strip().split()[-1] if result.stdout.strip() else None + + status_result = run_command(["orbctl", "status"]) + if status_result and status_result.returncode == 0: + running = True + + config_path: Path | None = None + config_dir = Path.home() / "Library" / "Application Support" / "OrbStack" + if config_dir.is_dir(): + config_path = config_dir + + return ContainerRuntimeInfo( + runtime_type=ContainerRuntimeType.ORBSTACK, + version=version, + running=running, + config_path=config_path, + ) + + def _detect_lima(self) -> ContainerRuntimeInfo | None: + if shutil.which("limactl") is None: + return None + + version: str | None = None + result = run_command(["limactl", "--version"]) + if result and result.returncode == 0: + # e.g. "limactl version 0.20.0" + parts = result.stdout.strip().split() + if len(parts) >= 3: + version = parts[2] + + running = False + list_result = run_command(["limactl", "list", "--json"]) + if list_result and list_result.returncode == 0: + with contextlib.suppress(json.JSONDecodeError): + # limactl list --json outputs one JSON object per line + for line in list_result.stdout.strip().splitlines(): + instance = json.loads(line) + if instance.get("status") == "Running": + running = True + break + + config_path: Path | None = None + config_dir = Path.home() / ".lima" + if config_dir.is_dir(): + config_path = config_dir + + return ContainerRuntimeInfo( + runtime_type=ContainerRuntimeType.LIMA, + version=version, + running=running, + config_path=config_path, + ) diff --git a/src/mac2nix/scanners/nix_state.py b/src/mac2nix/scanners/nix_state.py new file mode 100644 index 0000000..9c4349c --- /dev/null +++ b/src/mac2nix/scanners/nix_state.py @@ -0,0 +1,525 @@ +"""Nix ecosystem state scanner.""" + +from __future__ import annotations + +import json +import logging +import re +import shutil +from pathlib import Path + +from mac2nix.models.package_managers import ( + DevboxProject, + DevenvProject, + HomeManagerState, + NixChannel, + NixConfig, + NixDarwinState, + NixDirenvConfig, + NixFlakeInput, + NixInstallation, + NixInstallType, + NixProfile, + NixProfilePackage, + NixRegistryEntry, + NixState, +) +from mac2nix.scanners._utils import run_command +from mac2nix.scanners.base import BaseScannerPlugin, register + +logger = logging.getLogger(__name__) + +_SENSITIVE_PATTERNS = {"KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTH", "NETRC"} + +_PACKAGE_CAP = 500 +_ADJACENT_CAP = 50 +_ADJACENT_MAX_DEPTH = 2 +_PRUNE_DIRS = {".git", "node_modules", ".direnv", "__pycache__", ".venv"} + +_VERSION_RE = re.compile(r"(\d+\.\d+[\w.]*)") +_REGISTRY_RE = re.compile(r"^\S+\s+flake:(\S+)\s+path:(\S+)") + + +@register("nix_state") +class NixStateScanner(BaseScannerPlugin): + @property + def name(self) -> str: + return "nix_state" + + def scan(self) -> NixState: + installation = self._detect_installation() + if not installation.present: + return NixState(installation=installation) + + profiles = self._detect_profiles() + darwin = self._detect_darwin() + home_manager = self._detect_home_manager() + channels, flake_inputs, registries = self._detect_channels_and_flakes() + config = self._detect_config() + devbox_projects, devenv_projects, direnv_configs = self._detect_nix_adjacent() + + return NixState( + installation=installation, + profiles=profiles, + darwin=darwin, + home_manager=home_manager, + channels=channels, + flake_inputs=flake_inputs, + registries=registries, + config=config, + devbox_projects=devbox_projects, + devenv_projects=devenv_projects, + direnv_configs=direnv_configs, + ) + + def _detect_installation(self) -> NixInstallation: + nix_store = Path("/nix/store") + if not nix_store.exists(): + return NixInstallation(present=False) + + version = self._get_nix_version() + install_type = self._get_install_type() + daemon_running = self._is_daemon_running() + + return NixInstallation( + present=True, + version=version, + install_type=install_type, + daemon_running=daemon_running, + ) + + def _get_nix_version(self) -> str | None: + result = run_command(["nix", "--version"]) + if result is None or result.returncode != 0: + # Fallback: try the default profile path + fallback_path = "/nix/var/nix/profiles/default/bin/nix" + if Path(fallback_path).exists(): + result = run_command([fallback_path, "--version"]) + if result is not None and result.returncode == 0: + match = _VERSION_RE.search(result.stdout) + if match: + return match.group(1) + return None + + @staticmethod + def _get_install_type() -> NixInstallType: + # Determinate installer + if Path("/nix/receipt.json").exists(): + return NixInstallType.DETERMINATE + if Path.home().joinpath(".config", "determinate").is_dir(): + return NixInstallType.DETERMINATE + # Multi-user + if Path("/Library/LaunchDaemons/org.nixos.nix-daemon.plist").exists(): + return NixInstallType.MULTI_USER + return NixInstallType.UNKNOWN + + @staticmethod + def _is_daemon_running() -> bool: + result = run_command(["launchctl", "list", "org.nixos.nix-daemon"]) + if result is None or result.returncode != 0: + return False + # launchctl list output: PID\tStatus\tLabel + # If PID is "-", the daemon is not running + first_line = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" + parts = first_line.split() + if len(parts) < 3: + return False + if parts[0] != "-": + try: + int(parts[0]) + return True + except ValueError: + pass + return False + + def _detect_profiles(self) -> list[NixProfile]: + profiles: list[NixProfile] = [] + + # Try nix profile list --json (Nix 2.4+) + result = run_command(["nix", "profile", "list", "--json"]) + if result is not None and result.returncode == 0: + try: + data = json.loads(result.stdout) + packages = self._parse_profile_json(data) + if packages: + nix_profile_path = Path.home() / ".nix-profile" + profiles.append( + NixProfile( + name="default", + path=nix_profile_path, + packages=packages[:_PACKAGE_CAP], + ) + ) + return profiles + except (json.JSONDecodeError, ValueError): + pass + + # Fallback: manifest.json + manifest_path = Path.home() / ".nix-profile" / "manifest.json" + if manifest_path.exists(): + try: + data = json.loads(manifest_path.read_text()) + packages = self._parse_profile_json(data) + if packages: + profiles.append( + NixProfile( + name="default", + path=Path.home() / ".nix-profile", + packages=packages[:_PACKAGE_CAP], + ) + ) + return profiles + except (json.JSONDecodeError, ValueError, OSError): + pass + + # Fallback: nix-env -q + result = run_command(["nix-env", "-q"]) + if result is not None and result.returncode == 0: + packages = [] + for line in result.stdout.strip().splitlines(): + pkg = line.strip() + if pkg: + packages.append(NixProfilePackage(name=pkg)) + if packages: + profiles.append( + NixProfile( + name="default", + path=Path.home() / ".nix-profile", + packages=packages[:_PACKAGE_CAP], + ) + ) + + return profiles + + @staticmethod + def _parse_profile_json(data: dict) -> list[NixProfilePackage]: + packages: list[NixProfilePackage] = [] + # Nix 2.4+ format: {"elements": [...]} + elements = data.get("elements", []) + if isinstance(elements, list): + for elem in elements: + if not isinstance(elem, dict): + continue + store_paths = elem.get("storePaths", []) + store_path = Path(store_paths[0]) if store_paths else None + # Derive name from store path: /nix/store/hash-name-version + name = store_path.name.split("-", 1)[1] if store_path else elem.get("attrPath", "unknown") + packages.append( + NixProfilePackage( + name=name, + store_path=store_path, + ) + ) + return packages + + def _detect_darwin(self) -> NixDarwinState: + current_system = Path("/run/current-system") + has_darwin_rebuild = shutil.which("darwin-rebuild") is not None + + if not current_system.exists() and not has_darwin_rebuild: + return NixDarwinState(present=False) + + generation = self._get_darwin_generation() + config_path = self._find_darwin_config() + + return NixDarwinState( + present=True, + generation=generation, + config_path=config_path, + ) + + @staticmethod + def _get_darwin_generation() -> int | None: + result = run_command(["darwin-rebuild", "--list-generations"]) + if result is None or result.returncode != 0: + return None + lines = result.stdout.strip().splitlines() + if not lines: + return None + # Last line format: " 2024-03-01 12:00 : id 3 -> /nix/var/..." + last_line = lines[-1] + match = re.search(r"id\s+(\d+)", last_line) + if match: + return int(match.group(1)) + return None + + @staticmethod + def _find_darwin_config() -> Path | None: + # Legacy path + legacy = Path.home() / ".nixpkgs" / "darwin-configuration.nix" + if legacy.exists(): + return legacy + + # Flake-based: resolve /run/current-system/flake symlink + flake_link = Path("/run/current-system/flake") + if flake_link.is_symlink(): + try: + flake_dir = flake_link.resolve().parent + flake_nix = flake_dir / "flake.nix" + if flake_nix.exists(): + return flake_nix + except OSError: + pass + + return None + + def _detect_home_manager(self) -> HomeManagerState: + if shutil.which("home-manager") is None: + return HomeManagerState(present=False) + + generation = self._get_hm_generation() + config_path = self._find_hm_config() + packages = self._get_hm_packages() + + return HomeManagerState( + present=True, + generation=generation, + config_path=config_path, + packages=packages, + ) + + @staticmethod + def _get_hm_generation() -> int | None: + result = run_command(["home-manager", "generations"]) + if result is None or result.returncode != 0: + return None + lines = result.stdout.strip().splitlines() + if not lines: + return None + # First line is the newest generation: "2024-01-01 : id 42 -> ..." + first_line = lines[0] + match = re.search(r"id\s+(\d+)", first_line) + if match: + return int(match.group(1)) + return None + + @staticmethod + def _find_hm_config() -> Path | None: + candidates = [ + Path.home() / ".config" / "home-manager" / "home.nix", + Path.home() / ".config" / "home-manager" / "flake.nix", + Path.home() / ".config" / "nixpkgs" / "home.nix", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + @staticmethod + def _get_hm_packages() -> list[str]: + result = run_command(["home-manager", "packages"]) + if result is None or result.returncode != 0: + return [] + packages = [line.strip() for line in result.stdout.strip().splitlines() if line.strip()] + return packages[:_PACKAGE_CAP] + + def _detect_channels_and_flakes( + self, + ) -> tuple[list[NixChannel], list[NixFlakeInput], list[NixRegistryEntry]]: + channels = self._get_channels() + flake_inputs = self._get_flake_inputs() + registries = self._get_registries() + return channels, flake_inputs, registries + + @staticmethod + def _get_channels() -> list[NixChannel]: + result = run_command(["nix-channel", "--list"]) + if result is None or result.returncode != 0: + return [] + channels: list[NixChannel] = [] + for line in result.stdout.strip().splitlines(): + parts = line.split(None, 1) + if len(parts) == 2: + channels.append(NixChannel(name=parts[0], url=parts[1])) + return channels + + @staticmethod + def _get_flake_inputs() -> list[NixFlakeInput]: + lock_paths = [ + Path("/run/current-system/flake.lock"), + Path.home() / ".config" / "home-manager" / "flake.lock", + ] + inputs: list[NixFlakeInput] = [] + seen_names: set[str] = set() + + for lock_path in lock_paths: + if not lock_path.exists(): + continue + try: + data = json.loads(lock_path.read_text()) + except (json.JSONDecodeError, OSError): + continue + + nodes = data.get("nodes", {}) + for node_name, node_data in nodes.items(): + if node_name == "root" or node_name in seen_names: + continue + if not isinstance(node_data, dict): + continue + seen_names.add(node_name) + + locked = node_data.get("locked", {}) + original = node_data.get("original", {}) + locked_rev = locked.get("rev") if isinstance(locked, dict) else None + url = original.get("url") if isinstance(original, dict) else None + # Build URL from original type/owner/repo if url is not set + if not url and isinstance(original, dict): + owner = original.get("owner") + repo = original.get("repo") + if owner and repo: + url = f"github:{owner}/{repo}" + + inputs.append( + NixFlakeInput( + name=node_name, + url=url, + locked_rev=locked_rev, + ) + ) + + return inputs + + @staticmethod + def _get_registries() -> list[NixRegistryEntry]: + result = run_command(["nix", "registry", "list"]) + if result is None or result.returncode != 0: + return [] + entries: list[NixRegistryEntry] = [] + for line in result.stdout.strip().splitlines(): + match = _REGISTRY_RE.match(line) + if match: + entries.append(NixRegistryEntry(from_name=match.group(1), to_url=match.group(2))) + return entries + + def _detect_config(self) -> NixConfig: + config_files = [ + Path("/etc/nix/nix.conf"), + Path.home() / ".config" / "nix" / "nix.conf", + ] + + merged: dict[str, str] = {} + for config_file in config_files: + if not config_file.exists(): + continue + try: + content = config_file.read_text() + except OSError: + continue + for line in content.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if "=" not in stripped: + continue + key, _, value = stripped.partition("=") + key = key.strip() + value = value.strip() + + # Redact sensitive values + normalized_key = key.upper().replace("-", "_") + if any(p in normalized_key for p in _SENSITIVE_PATTERNS): + value = "**REDACTED**" + + merged[key] = value + + return NixConfig( + experimental_features=merged.get("experimental-features", "").split() + if merged.get("experimental-features") + else [], + substituters=merged.get("substituters", "").split() if merged.get("substituters") else [], + trusted_users=merged.get("trusted-users", "").split() if merged.get("trusted-users") else [], + max_jobs=self._parse_max_jobs(merged.get("max-jobs")), + sandbox=merged["sandbox"] == "true" if merged.get("sandbox") else None, + extra_config={ + k: v + for k, v in merged.items() + if k + not in { + "experimental-features", + "substituters", + "trusted-users", + "max-jobs", + "sandbox", + } + }, + ) + + @staticmethod + def _parse_max_jobs(value: str | None) -> int | None: + if not value: + return None + try: + return int(value) + except ValueError: + return None + + def _detect_nix_adjacent( + self, + ) -> tuple[list[DevboxProject], list[DevenvProject], list[NixDirenvConfig]]: + devbox_projects: list[DevboxProject] = [] + devenv_projects: list[DevenvProject] = [] + direnv_configs: list[NixDirenvConfig] = [] + + home = Path.home() + self._walk_for_adjacent(home, 0, devbox_projects, devenv_projects, direnv_configs) + + return devbox_projects, devenv_projects, direnv_configs + + def _walk_for_adjacent( + self, + directory: Path, + depth: int, + devbox_projects: list[DevboxProject], + devenv_projects: list[DevenvProject], + direnv_configs: list[NixDirenvConfig], + ) -> None: + if depth > _ADJACENT_MAX_DEPTH: + return + + try: + entries = sorted(directory.iterdir()) + except (PermissionError, OSError): + return + + for entry in entries: + if ( + len(devbox_projects) >= _ADJACENT_CAP + and len(devenv_projects) >= _ADJACENT_CAP + and len(direnv_configs) >= _ADJACENT_CAP + ): + break + if entry.is_dir(): + if entry.name in _PRUNE_DIRS: + continue + self._walk_for_adjacent(entry, depth + 1, devbox_projects, devenv_projects, direnv_configs) + elif entry.is_file(): + if entry.name == "devbox.json" and len(devbox_projects) < _ADJACENT_CAP: + packages = self._parse_devbox_json(entry) + devbox_projects.append(DevboxProject(path=entry.parent, packages=packages)) + elif entry.name == "devenv.nix" and len(devenv_projects) < _ADJACENT_CAP: + has_lock = (entry.parent / "devenv.lock").exists() + devenv_projects.append(DevenvProject(path=entry.parent, has_lock=has_lock)) + elif entry.name == ".envrc" and len(direnv_configs) < _ADJACENT_CAP: + self._check_envrc(entry, direnv_configs) + + @staticmethod + def _parse_devbox_json(path: Path) -> list[str]: + try: + data = json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return [] + packages = data.get("packages", []) + if isinstance(packages, list): + return [str(p) for p in packages] + return [] + + @staticmethod + def _check_envrc(path: Path, direnv_configs: list[NixDirenvConfig]) -> None: + try: + content = path.read_text() + except OSError: + return + use_flake = "use flake" in content + use_nix = "use_nix" in content or "use nix" in content + if use_flake or use_nix: + direnv_configs.append(NixDirenvConfig(path=path, use_flake=use_flake, use_nix=use_nix)) diff --git a/src/mac2nix/scanners/package_managers_scanner.py b/src/mac2nix/scanners/package_managers_scanner.py new file mode 100644 index 0000000..069677f --- /dev/null +++ b/src/mac2nix/scanners/package_managers_scanner.py @@ -0,0 +1,218 @@ +"""Package managers scanner — detects MacPorts and Conda/Mamba.""" + +from __future__ import annotations + +import json +import logging +import re +import shutil +from pathlib import Path + +from mac2nix.models.package_managers import ( + CondaEnvironment, + CondaPackage, + CondaState, + MacPortsPackage, + MacPortsState, + PackageManagersResult, +) +from mac2nix.scanners._utils import run_command +from mac2nix.scanners.base import BaseScannerPlugin, register + +logger = logging.getLogger(__name__) + +_MAX_CONDA_ENVS = 20 +_MAX_MACPORTS_PACKAGES = 1000 + + +@register("package_managers") +class PackageManagersScanner(BaseScannerPlugin): + @property + def name(self) -> str: + return "package_managers" + + def scan(self) -> PackageManagersResult: + return PackageManagersResult( + macports=self._detect_macports(), + conda=self._detect_conda(), + ) + + def _detect_macports(self) -> MacPortsState: + port_bin = Path("/opt/local/bin/port") + if not port_bin.exists() and shutil.which("port") is None: + return MacPortsState(present=False) + + version = self._get_macports_version() + packages = self._get_macports_packages() + + return MacPortsState( + present=True, + version=version, + packages=packages, + ) + + @staticmethod + def _get_macports_version() -> str | None: + result = run_command(["port", "version"]) + if result is None or result.returncode != 0: + return None + # Output: "Version: 2.9.3" + match = re.search(r"Version:\s*(\S+)", result.stdout) + if match: + return match.group(1) + return None + + @staticmethod + def _get_macports_packages() -> list[MacPortsPackage]: + result = run_command(["port", "installed"]) + if result is None or result.returncode != 0: + return [] + + packages: list[MacPortsPackage] = [] + for line in result.stdout.splitlines(): + # Skip header line + if not line.startswith(" "): + continue + stripped = line.strip() + if not stripped: + continue + + # Format: " curl @8.5.0_0 (active)" + # or: " python312 @3.12.1_0+lto+optimizations (active)" + parts = stripped.split() + if len(parts) < 2: + continue + + name = parts[0] + version_part = parts[1].lstrip("@") if parts[1].startswith("@") else parts[1] + + # Extract variants: +name tokens embedded in version string + variants: list[str] = [] + if "+" in version_part: + segments = version_part.split("+") + version_str = segments[0] + variants = [f"+{v}" for v in segments[1:] if v] + else: + version_str = version_part + + active = "(active)" in line + + packages.append( + MacPortsPackage( + name=name, + version=version_str, + active=active, + variants=variants, + ) + ) + if len(packages) >= _MAX_MACPORTS_PACKAGES: + break + + return packages + + def _detect_conda(self) -> CondaState: + # Prefer mamba over conda + conda_cmd = None + if shutil.which("mamba") is not None: + conda_cmd = "mamba" + elif shutil.which("conda") is not None: + conda_cmd = "conda" + + if conda_cmd is None: + return CondaState(present=False) + + version = self._get_conda_version(conda_cmd) + environments = self._get_conda_environments(conda_cmd) + + return CondaState( + present=True, + version=version, + environments=environments, + ) + + @staticmethod + def _get_conda_version(conda_cmd: str) -> str | None: + result = run_command([conda_cmd, "--version"]) + if result is None or result.returncode != 0: + return None + # Output: "conda 24.1.0" or "mamba 1.5.0" + match = re.search(r"\S+\s+(\S+)", result.stdout) + if match: + return match.group(1) + return None + + def _get_conda_environments(self, conda_cmd: str) -> list[CondaEnvironment]: + result = run_command([conda_cmd, "info", "--json"]) + if result is None or result.returncode != 0: + return [] + + try: + data = json.loads(result.stdout) + except (json.JSONDecodeError, ValueError): + return [] + + env_paths = data.get("envs", []) + if not isinstance(env_paths, list): + return [] + + default_prefix = data.get("default_prefix", "") + root_prefix = data.get("root_prefix", "") + + environments: list[CondaEnvironment] = [] + for env_path_str in env_paths[:_MAX_CONDA_ENVS]: + if not isinstance(env_path_str, str): + continue + env_path = Path(env_path_str) + env_name = env_path.name + is_base = env_path_str == root_prefix + if is_base: + env_name = "base" + + is_active = env_path_str == default_prefix + + # Only fetch packages for active or base env to avoid N+1 calls + packages: list[CondaPackage] = [] + if (is_active or is_base) and env_path.is_dir(): + packages = self._get_conda_packages(conda_cmd, env_path_str) + + environments.append( + CondaEnvironment( + name=env_name, + path=env_path, + is_active=is_active, + packages=packages, + ) + ) + + return environments + + @staticmethod + def _get_conda_packages(conda_cmd: str, env_path: str) -> list[CondaPackage]: + result = run_command([conda_cmd, "list", "--json", "-p", env_path]) + if result is None or result.returncode != 0: + return [] + + try: + data = json.loads(result.stdout) + except (json.JSONDecodeError, ValueError): + return [] + + if not isinstance(data, list): + return [] + + packages: list[CondaPackage] = [] + for entry in data: + if not isinstance(entry, dict): + continue + name = entry.get("name") + if not name: + continue + packages.append( + CondaPackage( + name=name, + version=entry.get("version"), + channel=entry.get("channel"), + ) + ) + + return packages diff --git a/src/mac2nix/scanners/system_scanner.py b/src/mac2nix/scanners/system_scanner.py index 16001af..e7944b3 100644 --- a/src/mac2nix/scanners/system_scanner.py +++ b/src/mac2nix/scanners/system_scanner.py @@ -9,7 +9,13 @@ from pathlib import Path from typing import Any -from mac2nix.models.system import PrinterInfo, SystemConfig, TimeMachineConfig +from mac2nix.models.system import ( + ICloudState, + PrinterInfo, + SystemConfig, + SystemExtension, + TimeMachineConfig, +) from mac2nix.scanners._utils import read_plist_safe, run_command from mac2nix.scanners.base import BaseScannerPlugin, register @@ -44,6 +50,10 @@ def scan(self) -> SystemConfig: ntp_enabled, ntp_server = self._get_network_time() printers = self._get_printers() remote_login, screen_sharing, file_sharing = self._get_remote_access() + rosetta_installed = self._detect_rosetta() + system_extensions = self._detect_system_extensions() + icloud = self._detect_icloud() + mdm_enrolled = self._detect_mdm() return SystemConfig( hostname=hostname, @@ -71,6 +81,10 @@ def scan(self) -> SystemConfig: remote_login=remote_login, screen_sharing=screen_sharing, file_sharing=file_sharing, + rosetta_installed=rosetta_installed, + system_extensions=system_extensions, + icloud=icloud, + mdm_enrolled=mdm_enrolled, ) def _get_hostname(self) -> str: @@ -395,3 +409,98 @@ def _get_remote_access(self) -> tuple[bool | None, bool | None, bool | None]: file_sharing = result.returncode == 0 return remote_login, screen_sharing, file_sharing + + def _detect_rosetta(self) -> bool | None: + """Check if Rosetta 2 is installed.""" + 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) + if result is not None: + return result.returncode == 0 + return None + + def _detect_system_extensions(self) -> list[SystemExtension]: + """List installed system extensions.""" + result = run_command(["systemextensionsctl", "list"]) + if result is None or result.returncode != 0: + return [] + extensions: list[SystemExtension] = [] + for raw_line in result.stdout.splitlines(): + stripped = raw_line.strip() + if not stripped or stripped.startswith(("---", "*")): + continue + parts = stripped.split() + if len(parts) < 3: + continue + parsed = self._parse_extension_line(parts) + if parsed: + extensions.append(parsed) + return extensions + + @staticmethod + def _parse_extension_line(parts: list[str]) -> SystemExtension | None: + """Parse a single systemextensionsctl output line into a SystemExtension.""" + identifier = None + team_id = None + version = None + state_parts: list[str] = [] + for part in parts: + if "." in part and not part.startswith("(") and not part.endswith(")"): + if identifier is None and len(part.split(".")) >= 3: + identifier = part + elif team_id is None: + team_id = part + elif part.startswith("(") and part.endswith(")"): + version = part.strip("()") + elif part in { + "enabled", + "disabled", + "activated_enabled", + "activated_disabled", + }: + state_parts.append(part) + elif len(part) == 10 and part.isalnum() and team_id is None: + team_id = part + if not identifier: + return None + return SystemExtension( + identifier=identifier, + team_id=team_id, + version=version, + state="_".join(state_parts) if state_parts else None, + ) + + def _detect_icloud(self) -> ICloudState: + """Detect iCloud sign-in and sync status.""" + signed_in = False + desktop_sync = False + documents_sync = False + + result = run_command(["defaults", "read", "MobileMeAccounts", "Accounts"]) + if result is not None and result.returncode == 0: + output = result.stdout.strip() + signed_in = bool(output) and output != "(\n)" + + cloud_docs = Path.home() / "Library" / "Mobile Documents" / "com~apple~CloudDocs" + if cloud_docs.is_dir(): + desktop_sync = (cloud_docs / "Desktop").is_dir() + documents_sync = (cloud_docs / "Documents").is_dir() + + return ICloudState( + signed_in=signed_in, + desktop_sync=desktop_sync, + documents_sync=documents_sync, + ) + + def _detect_mdm(self) -> bool | None: + """Check if device is MDM enrolled.""" + result = run_command(["profiles", "status", "-type", "enrollment"]) + if result is None or result.returncode != 0: + return None + output = result.stdout.lower() + if "yes" in output: + return True + if "no" in output: + return False + return None diff --git a/src/mac2nix/scanners/version_managers.py b/src/mac2nix/scanners/version_managers.py new file mode 100644 index 0000000..1c3ff0b --- /dev/null +++ b/src/mac2nix/scanners/version_managers.py @@ -0,0 +1,438 @@ +"""Version managers scanner — detects asdf, mise, nvm, pyenv, rbenv, jenv, sdkman.""" + +from __future__ import annotations + +import contextlib +import json +import logging +import os +import shutil +from pathlib import Path + +from mac2nix.models.package_managers import ( + ManagedRuntime, + VersionManagerInfo, + VersionManagersResult, + VersionManagerType, +) +from mac2nix.scanners._utils import run_command +from mac2nix.scanners.base import BaseScannerPlugin, register + +logger = logging.getLogger(__name__) + +_MAX_RUNTIMES = 200 + + +@register("version_managers") +class VersionManagersScanner(BaseScannerPlugin): + @property + def name(self) -> str: + return "version_managers" + + def scan(self) -> VersionManagersResult: + managers: list[VersionManagerInfo] = [] + detectors = [ + self._detect_asdf, + self._detect_mise, + self._detect_nvm, + self._detect_pyenv, + self._detect_rbenv, + self._detect_jenv, + self._detect_sdkman, + ] + for detector in detectors: + info = detector() + if info is not None: + managers.append(info) + + global_tool_versions: Path | None = None + tv = Path.home() / ".tool-versions" + if tv.is_file(): + global_tool_versions = tv + + return VersionManagersResult( + managers=managers, + global_tool_versions=global_tool_versions, + ) + + def _detect_asdf(self) -> VersionManagerInfo | None: + if shutil.which("asdf") is None: + return None + + version: str | None = None + result = run_command(["asdf", "version"]) + if result is not None and result.returncode == 0: + version = result.stdout.strip() + + config_path: Path | None = None + tool_versions = Path.home() / ".tool-versions" + if tool_versions.is_file(): + config_path = tool_versions + + runtimes = self._parse_asdf_list() + + return VersionManagerInfo( + manager_type=VersionManagerType.ASDF, + version=version, + config_path=config_path, + runtimes=runtimes, + ) + + def _parse_asdf_list(self) -> list[ManagedRuntime]: + result = run_command(["asdf", "list"]) + if result is None or result.returncode != 0: + return [] + + runtimes: list[ManagedRuntime] = [] + current_language: str | None = None + + for line in result.stdout.splitlines(): + stripped = line.strip() + if not stripped: + continue + # Lines without leading whitespace are plugin/language names + if not line.startswith(" ") and not line.startswith("\t"): + current_language = stripped + elif current_language: + active = stripped.startswith("*") + ver = stripped.lstrip("* ") + if ver: + runtimes.append( + ManagedRuntime( + manager=VersionManagerType.ASDF, + language=current_language, + version=ver, + active=active, + ) + ) + + return runtimes + + def _detect_mise(self) -> VersionManagerInfo | None: + if shutil.which("mise") is None: + return None + + version: str | None = None + result = run_command(["mise", "--version"]) + if result is not None and result.returncode == 0: + # Output may be "2024.1.0 linux-x64" or just "2024.1.0" + version = result.stdout.strip().split()[0] if result.stdout.strip() else None + + config_path: Path | None = None + mise_config = Path.home() / ".config" / "mise" / "config.toml" + if mise_config.is_file(): + config_path = mise_config + + runtimes = self._parse_mise_list() + + return VersionManagerInfo( + manager_type=VersionManagerType.MISE, + version=version, + config_path=config_path, + runtimes=runtimes, + ) + + def _parse_mise_list(self) -> list[ManagedRuntime]: + result = run_command(["mise", "list", "--json"]) + if result is None or result.returncode != 0: + return [] + + try: + data = json.loads(result.stdout) + except (json.JSONDecodeError, ValueError): + return [] + + runtimes: list[ManagedRuntime] = [] + if not isinstance(data, dict): + return [] + + for tool_name, versions in data.items(): + if not isinstance(versions, list): + continue + for entry in versions: + if not isinstance(entry, dict): + continue + ver = entry.get("version", "") + if not ver: + continue + install_path = entry.get("install_path") + runtimes.append( + ManagedRuntime( + manager=VersionManagerType.MISE, + language=tool_name, + version=str(ver), + path=Path(install_path) if install_path else None, + active=bool(entry.get("active", False)), + ) + ) + + return runtimes + + def _detect_nvm(self) -> VersionManagerInfo | None: + nvm_dir_env = os.environ.get("NVM_DIR") + nvm_dir = Path(nvm_dir_env) if nvm_dir_env else Path.home() / ".nvm" + + if not nvm_dir.is_dir(): + return None + + config_path: Path | None = None + nvmrc = Path.home() / ".nvmrc" + if nvmrc.is_file(): + config_path = nvmrc + + runtimes = self._parse_nvm_versions(nvm_dir) + + return VersionManagerInfo( + manager_type=VersionManagerType.NVM, + version=None, # nvm is a shell function, no binary version + config_path=config_path, + runtimes=runtimes, + ) + + @staticmethod + def _parse_nvm_versions(nvm_dir: Path) -> list[ManagedRuntime]: + versions_dir = nvm_dir / "versions" / "node" + if not versions_dir.is_dir(): + return [] + + # Check for active version via default alias or current symlink + active_version: str | None = None + alias_default = nvm_dir / "alias" / "default" + if alias_default.is_file(): + with contextlib.suppress(OSError): + active_version = alias_default.read_text().strip() + + runtimes: list[ManagedRuntime] = [] + try: + for entry in sorted(versions_dir.iterdir()): + if entry.is_dir(): + ver = entry.name + runtimes.append( + ManagedRuntime( + manager=VersionManagerType.NVM, + language="node", + version=ver, + path=entry, + active=ver == active_version, + ) + ) + if len(runtimes) >= _MAX_RUNTIMES: + break + except (PermissionError, OSError): + pass + + return runtimes + + def _detect_pyenv(self) -> VersionManagerInfo | None: + has_binary = shutil.which("pyenv") is not None + pyenv_root = Path.home() / ".pyenv" + + if not has_binary and not pyenv_root.is_dir(): + return None + + version: str | None = None + if has_binary: + result = run_command(["pyenv", "--version"]) + if result is not None and result.returncode == 0: + # Output: "pyenv 2.3.36" + parts = result.stdout.strip().split() + version = parts[1] if len(parts) >= 2 else result.stdout.strip() + + runtimes = self._parse_pyenv_versions(has_binary) + + return VersionManagerInfo( + manager_type=VersionManagerType.PYENV, + version=version, + runtimes=runtimes, + ) + + @staticmethod + def _parse_pyenv_versions(has_binary: bool) -> list[ManagedRuntime]: + if not has_binary: + return [] + + result = run_command(["pyenv", "versions", "--bare"]) + if result is None or result.returncode != 0: + return [] + + # Get active version + active_version: str | None = None + active_result = run_command(["pyenv", "version-name"]) + if active_result is not None and active_result.returncode == 0: + active_version = active_result.stdout.strip() + + runtimes: list[ManagedRuntime] = [] + for line in result.stdout.strip().splitlines(): + ver = line.strip() + if ver: + runtimes.append( + ManagedRuntime( + manager=VersionManagerType.PYENV, + language="python", + version=ver, + active=ver == active_version, + ) + ) + + return runtimes + + def _detect_rbenv(self) -> VersionManagerInfo | None: + has_binary = shutil.which("rbenv") is not None + rbenv_root = Path.home() / ".rbenv" + + if not has_binary and not rbenv_root.is_dir(): + return None + + version: str | None = None + if has_binary: + result = run_command(["rbenv", "--version"]) + if result is not None and result.returncode == 0: + # Output: "rbenv 1.2.0" + parts = result.stdout.strip().split() + version = parts[1] if len(parts) >= 2 else result.stdout.strip() + + runtimes = self._parse_rbenv_versions(has_binary) + + return VersionManagerInfo( + manager_type=VersionManagerType.RBENV, + version=version, + runtimes=runtimes, + ) + + @staticmethod + def _parse_rbenv_versions(has_binary: bool) -> list[ManagedRuntime]: + if not has_binary: + return [] + + result = run_command(["rbenv", "versions", "--bare"]) + if result is None or result.returncode != 0: + return [] + + active_version: str | None = None + active_result = run_command(["rbenv", "version-name"]) + if active_result is not None and active_result.returncode == 0: + active_version = active_result.stdout.strip() + + runtimes: list[ManagedRuntime] = [] + for line in result.stdout.strip().splitlines(): + ver = line.strip() + if ver: + runtimes.append( + ManagedRuntime( + manager=VersionManagerType.RBENV, + language="ruby", + version=ver, + active=ver == active_version, + ) + ) + + return runtimes + + def _detect_jenv(self) -> VersionManagerInfo | None: + has_binary = shutil.which("jenv") is not None + jenv_root = Path.home() / ".jenv" + + if not has_binary and not jenv_root.is_dir(): + return None + + runtimes = self._parse_jenv_versions(has_binary) + + return VersionManagerInfo( + manager_type=VersionManagerType.JENV, + version=None, # jenv doesn't have a version command + runtimes=runtimes, + ) + + @staticmethod + def _parse_jenv_versions(has_binary: bool) -> list[ManagedRuntime]: + if not has_binary: + return [] + + result = run_command(["jenv", "versions"]) + if result is None or result.returncode != 0: + return [] + + runtimes: list[ManagedRuntime] = [] + for line in result.stdout.strip().splitlines(): + stripped = line.strip() + if not stripped or stripped == "system": + continue + active = stripped.startswith("*") + ver = stripped.lstrip("* ").split("(")[0].strip() + if ver and ver != "system": + runtimes.append( + ManagedRuntime( + manager=VersionManagerType.JENV, + language="java", + version=ver, + active=active, + ) + ) + + return runtimes + + def _detect_sdkman(self) -> VersionManagerInfo | None: + sdkman_dir_env = os.environ.get("SDKMAN_DIR") + sdkman_dir = Path(sdkman_dir_env) if sdkman_dir_env else Path.home() / ".sdkman" + + if not sdkman_dir.is_dir(): + return None + + version: str | None = None + version_file = sdkman_dir / "var" / "version" + if version_file.is_file(): + with contextlib.suppress(OSError): + version = version_file.read_text().strip() + + runtimes = self._parse_sdkman_candidates(sdkman_dir) + + return VersionManagerInfo( + manager_type=VersionManagerType.SDKMAN, + version=version, + runtimes=runtimes, + ) + + @staticmethod + def _parse_sdkman_candidates(sdkman_dir: Path) -> list[ManagedRuntime]: + candidates_dir = sdkman_dir / "candidates" + if not candidates_dir.is_dir(): + return [] + + runtimes: list[ManagedRuntime] = [] + try: + for candidate in sorted(candidates_dir.iterdir()): + if not candidate.is_dir(): + continue + language = candidate.name + + # Check for active version via current symlink + current_link = candidate / "current" + active_version: str | None = None + if current_link.is_symlink(): + with contextlib.suppress(OSError): + active_version = current_link.resolve().name + + try: + for version_dir in sorted(candidate.iterdir()): + if not version_dir.is_dir() or version_dir.name == "current": + continue + ver = version_dir.name + runtimes.append( + ManagedRuntime( + manager=VersionManagerType.SDKMAN, + language=language, + version=ver, + path=version_dir, + active=ver == active_version, + ) + ) + if len(runtimes) >= _MAX_RUNTIMES: + break + except (PermissionError, OSError): + pass + if len(runtimes) >= _MAX_RUNTIMES: + break + except (PermissionError, OSError): + pass + + return runtimes diff --git a/tests/models/test_package_managers.py b/tests/models/test_package_managers.py new file mode 100644 index 0000000..7806d1e --- /dev/null +++ b/tests/models/test_package_managers.py @@ -0,0 +1,525 @@ +"""Tests for Nix, version manager, and third-party package manager models.""" + +from __future__ import annotations + +from pathlib import Path + +from mac2nix.models.package_managers import ( + CondaEnvironment, + CondaPackage, + CondaState, + ContainerRuntimeInfo, + ContainerRuntimeType, + ContainersResult, + DevboxProject, + DevenvProject, + HomeManagerState, + MacPortsPackage, + MacPortsState, + ManagedRuntime, + NixChannel, + NixConfig, + NixDarwinState, + NixDirenvConfig, + NixFlakeInput, + NixInstallation, + NixInstallType, + NixProfile, + NixProfilePackage, + NixRegistryEntry, + NixState, + PackageManagersResult, + VersionManagerInfo, + VersionManagersResult, + VersionManagerType, +) + + +class TestNixInstallation: + def test_defaults(self) -> None: + inst = NixInstallation() + assert inst.present is False + assert inst.version is None + assert inst.store_path == Path("/nix/store") + assert inst.install_type == NixInstallType.UNKNOWN + assert inst.daemon_running is False + + def test_with_values(self) -> None: + inst = NixInstallation( + present=True, + version="2.18.1", + install_type=NixInstallType.MULTI_USER, + daemon_running=True, + ) + assert inst.present is True + assert inst.version == "2.18.1" + assert inst.install_type == NixInstallType.MULTI_USER + assert inst.daemon_running is True + + def test_determinate_type(self) -> None: + inst = NixInstallation( + present=True, + version="2.24.0", + install_type=NixInstallType.DETERMINATE, + ) + assert inst.install_type == NixInstallType.DETERMINATE + + +class TestNixProfilePackage: + def test_minimal(self) -> None: + pkg = NixProfilePackage(name="ripgrep") + assert pkg.name == "ripgrep" + assert pkg.version is None + assert pkg.store_path is None + + def test_with_store_path(self) -> None: + pkg = NixProfilePackage( + name="ripgrep", + version="14.1.0", + store_path=Path("/nix/store/abc123-ripgrep-14.1.0"), + ) + assert pkg.version == "14.1.0" + assert pkg.store_path == Path("/nix/store/abc123-ripgrep-14.1.0") + + +class TestNixProfile: + def test_empty_profile(self) -> None: + profile = NixProfile(name="default", path=Path("/nix/var/nix/profiles/default")) + assert profile.name == "default" + assert profile.packages == [] + + def test_with_packages(self) -> None: + profile = NixProfile( + name="default", + path=Path("/nix/var/nix/profiles/default"), + packages=[ + NixProfilePackage(name="ripgrep", version="14.1.0"), + NixProfilePackage(name="fd", version="9.0.0"), + ], + ) + assert len(profile.packages) == 2 + assert profile.packages[0].name == "ripgrep" + + +class TestNixDarwinState: + def test_defaults(self) -> None: + darwin = NixDarwinState() + assert darwin.present is False + assert darwin.generation is None + assert darwin.config_path is None + assert darwin.system_packages == [] + + def test_with_values(self) -> None: + darwin = NixDarwinState( + present=True, + generation=42, + config_path=Path("/etc/nix-darwin"), + system_packages=["vim", "git"], + ) + assert darwin.present is True + assert darwin.generation == 42 + assert len(darwin.system_packages) == 2 + + +class TestHomeManagerState: + def test_defaults(self) -> None: + hm = HomeManagerState() + assert hm.present is False + assert hm.generation is None + assert hm.config_path is None + assert hm.packages == [] + + def test_with_values(self) -> None: + hm = HomeManagerState( + present=True, + generation=7, + config_path=Path("/Users/test/.config/home-manager"), + packages=["htop", "jq", "bat"], + ) + assert hm.present is True + assert len(hm.packages) == 3 + + +class TestNixChannel: + def test_construction(self) -> None: + ch = NixChannel(name="nixpkgs", url="https://nixos.org/channels/nixpkgs-unstable") + assert ch.name == "nixpkgs" + assert "nixpkgs-unstable" in ch.url + + +class TestNixFlakeInput: + def test_minimal(self) -> None: + inp = NixFlakeInput(name="nixpkgs") + assert inp.name == "nixpkgs" + assert inp.url is None + assert inp.locked_rev is None + + def test_with_locked_rev(self) -> None: + inp = NixFlakeInput( + name="nixpkgs", + url="github:NixOS/nixpkgs/nixpkgs-unstable", + locked_rev="abc123def456", + ) + assert inp.locked_rev == "abc123def456" + + +class TestNixRegistryEntry: + def test_construction(self) -> None: + entry = NixRegistryEntry(from_name="nixpkgs", to_url="github:NixOS/nixpkgs") + assert entry.from_name == "nixpkgs" + assert entry.to_url == "github:NixOS/nixpkgs" + + +class TestNixConfig: + def test_defaults(self) -> None: + cfg = NixConfig() + assert cfg.experimental_features == [] + assert cfg.substituters == [] + assert cfg.trusted_users == [] + assert cfg.max_jobs is None + assert cfg.sandbox is None + assert cfg.extra_config == {} + + def test_with_values(self) -> None: + cfg = NixConfig( + experimental_features=["nix-command", "flakes"], + substituters=["https://cache.nixos.org"], + trusted_users=["root", "testuser"], + max_jobs=8, + sandbox=True, + extra_config={"warn-dirty": "false"}, + ) + assert len(cfg.experimental_features) == 2 + assert cfg.max_jobs == 8 + assert cfg.sandbox is True + + +class TestDevboxProject: + def test_construction(self) -> None: + proj = DevboxProject(path=Path("/home/user/myproject"), packages=["python3", "nodejs"]) + assert proj.path == Path("/home/user/myproject") + assert len(proj.packages) == 2 + + +class TestDevenvProject: + def test_defaults(self) -> None: + proj = DevenvProject(path=Path("/home/user/devenv-proj")) + assert proj.has_lock is False + + def test_with_lock(self) -> None: + proj = DevenvProject(path=Path("/home/user/devenv-proj"), has_lock=True) + assert proj.has_lock is True + + +class TestNixDirenvConfig: + def test_defaults(self) -> None: + cfg = NixDirenvConfig(path=Path("/home/user/project/.envrc")) + assert cfg.use_flake is False + assert cfg.use_nix is False + + def test_use_flake(self) -> None: + cfg = NixDirenvConfig( + path=Path("/home/user/project/.envrc"), + use_flake=True, + ) + assert cfg.use_flake is True + assert cfg.use_nix is False + + +class TestNixState: + def test_defaults(self) -> None: + state = NixState() + assert state.installation.present is False + assert state.profiles == [] + assert state.darwin.present is False + assert state.home_manager.present is False + assert state.channels == [] + assert state.flake_inputs == [] + assert state.registries == [] + assert state.config.experimental_features == [] + assert state.devbox_projects == [] + assert state.devenv_projects == [] + assert state.direnv_configs == [] + + def test_roundtrip(self) -> None: + state = NixState( + installation=NixInstallation( + present=True, + version="2.18.1", + install_type=NixInstallType.MULTI_USER, + daemon_running=True, + ), + profiles=[ + NixProfile( + name="default", + path=Path("/nix/var/nix/profiles/default"), + packages=[NixProfilePackage(name="ripgrep", version="14.1.0")], + ), + ], + darwin=NixDarwinState(present=True, generation=42, system_packages=["vim"]), + home_manager=HomeManagerState(present=True, generation=7, packages=["htop"]), + channels=[NixChannel(name="nixpkgs", url="https://nixos.org/channels/nixpkgs-unstable")], + flake_inputs=[ + NixFlakeInput(name="nixpkgs", url="github:NixOS/nixpkgs", locked_rev="abc123"), + ], + registries=[NixRegistryEntry(from_name="nixpkgs", to_url="github:NixOS/nixpkgs")], + config=NixConfig( + experimental_features=["nix-command", "flakes"], + max_jobs=8, + ), + devbox_projects=[DevboxProject(path=Path("/tmp/proj"), packages=["python3"])], + devenv_projects=[DevenvProject(path=Path("/tmp/devenv"), has_lock=True)], + direnv_configs=[NixDirenvConfig(path=Path("/tmp/.envrc"), use_flake=True)], + ) + json_str = state.model_dump_json() + restored = NixState.model_validate_json(json_str) + assert restored.installation.present is True + assert restored.installation.version == "2.18.1" + assert restored.installation.install_type == NixInstallType.MULTI_USER + assert len(restored.profiles) == 1 + assert restored.profiles[0].packages[0].name == "ripgrep" + assert restored.darwin.present is True + assert restored.darwin.generation == 42 + assert restored.home_manager.present is True + assert len(restored.channels) == 1 + assert restored.flake_inputs[0].locked_rev == "abc123" + assert restored.registries[0].from_name == "nixpkgs" + assert restored.config.max_jobs == 8 + assert len(restored.devbox_projects) == 1 + assert restored.devenv_projects[0].has_lock is True + assert restored.direnv_configs[0].use_flake is True + + def test_mutable_defaults_isolated(self) -> None: + """Ensure Field(default_factory=...) prevents shared mutable state.""" + state1 = NixState() + state2 = NixState() + state1.profiles.append(NixProfile(name="test", path=Path("/nix/var/nix/profiles/test"))) + assert len(state2.profiles) == 0 + + +class TestVersionManagerType: + def test_enum_values(self) -> None: + assert VersionManagerType.ASDF == "asdf" + assert VersionManagerType.MISE == "mise" + assert VersionManagerType.NVM == "nvm" + assert VersionManagerType.PYENV == "pyenv" + assert VersionManagerType.RBENV == "rbenv" + assert VersionManagerType.JENV == "jenv" + assert VersionManagerType.SDKMAN == "sdkman" + + +class TestManagedRuntime: + def test_construction(self) -> None: + rt = ManagedRuntime( + manager=VersionManagerType.PYENV, + language="python", + version="3.12.1", + path=Path("/Users/user/.pyenv/versions/3.12.1"), + active=True, + ) + assert rt.manager == VersionManagerType.PYENV + assert rt.language == "python" + assert rt.version == "3.12.1" + assert rt.active is True + + def test_defaults(self) -> None: + rt = ManagedRuntime( + manager=VersionManagerType.NVM, + language="node", + version="20.11.1", + ) + assert rt.path is None + assert rt.active is False + + def test_roundtrip(self) -> None: + rt = ManagedRuntime( + manager=VersionManagerType.RBENV, + language="ruby", + version="3.3.0", + active=True, + ) + json_str = rt.model_dump_json() + restored = ManagedRuntime.model_validate_json(json_str) + assert restored.manager == VersionManagerType.RBENV + assert restored.active is True + + +class TestVersionManagerInfo: + def test_construction(self) -> None: + info = VersionManagerInfo( + manager_type=VersionManagerType.ASDF, + version="0.14.0", + config_path=Path("/Users/user/.tool-versions"), + runtimes=[ + ManagedRuntime( + manager=VersionManagerType.ASDF, + language="python", + version="3.12.1", + ), + ], + ) + assert info.manager_type == VersionManagerType.ASDF + assert info.version == "0.14.0" + assert len(info.runtimes) == 1 + + def test_defaults(self) -> None: + info = VersionManagerInfo(manager_type=VersionManagerType.MISE) + assert info.version is None + assert info.config_path is None + assert info.runtimes == [] + + +class TestVersionManagersResult: + def test_defaults(self) -> None: + result = VersionManagersResult() + assert result.managers == [] + assert result.global_tool_versions is None + + def test_with_managers(self) -> None: + result = VersionManagersResult( + managers=[ + VersionManagerInfo(manager_type=VersionManagerType.PYENV), + VersionManagerInfo(manager_type=VersionManagerType.NVM), + ], + global_tool_versions=Path("/Users/user/.tool-versions"), + ) + assert len(result.managers) == 2 + assert result.global_tool_versions is not None + + def test_roundtrip(self) -> None: + result = VersionManagersResult( + managers=[ + VersionManagerInfo( + manager_type=VersionManagerType.ASDF, + runtimes=[ + ManagedRuntime( + manager=VersionManagerType.ASDF, + language="nodejs", + version="20.0.0", + ), + ], + ), + ], + ) + json_str = result.model_dump_json() + restored = VersionManagersResult.model_validate_json(json_str) + assert len(restored.managers) == 1 + assert len(restored.managers[0].runtimes) == 1 + + +class TestMacPortsPackage: + def test_construction(self) -> None: + pkg = MacPortsPackage( + name="curl", + version="8.5.0_0", + active=True, + variants=["+ssl"], + ) + assert pkg.name == "curl" + assert pkg.version == "8.5.0_0" + assert pkg.active is True + assert pkg.variants == ["+ssl"] + + def test_defaults(self) -> None: + pkg = MacPortsPackage(name="zlib") + assert pkg.version is None + assert pkg.active is True + assert pkg.variants == [] + + +class TestMacPortsState: + def test_defaults(self) -> None: + state = MacPortsState() + assert state.present is False + assert state.prefix == Path("/opt/local") + assert state.packages == [] + + +class TestCondaState: + def test_defaults(self) -> None: + state = CondaState() + assert state.present is False + assert state.environments == [] + + def test_with_environments(self) -> None: + state = CondaState( + present=True, + version="24.1.0", + environments=[ + CondaEnvironment( + name="base", + path=Path("/Users/user/miniconda3"), + is_active=True, + packages=[CondaPackage(name="numpy", version="1.26.0", channel="defaults")], + ), + ], + ) + assert len(state.environments) == 1 + assert state.environments[0].is_active is True + + +class TestPackageManagersResult: + def test_defaults(self) -> None: + result = PackageManagersResult() + assert result.macports.present is False + assert result.conda.present is False + + def test_roundtrip(self) -> None: + result = PackageManagersResult( + macports=MacPortsState(present=True, version="2.9.3"), + conda=CondaState(present=True, version="24.1.0"), + ) + json_str = result.model_dump_json() + restored = PackageManagersResult.model_validate_json(json_str) + assert restored.macports.present is True + assert restored.conda.present is True + + +class TestContainerRuntimeType: + def test_enum_values(self) -> None: + assert ContainerRuntimeType.DOCKER == "docker" + assert ContainerRuntimeType.PODMAN == "podman" + assert ContainerRuntimeType.COLIMA == "colima" + assert ContainerRuntimeType.ORBSTACK == "orbstack" + assert ContainerRuntimeType.LIMA == "lima" + + +class TestContainerRuntimeInfo: + def test_construction(self) -> None: + info = ContainerRuntimeInfo( + runtime_type=ContainerRuntimeType.DOCKER, + version="24.0.7", + running=True, + config_path=Path("/Users/user/.docker/config.json"), + socket_path=Path("/var/run/docker.sock"), + ) + assert info.runtime_type == ContainerRuntimeType.DOCKER + assert info.running is True + + def test_defaults(self) -> None: + info = ContainerRuntimeInfo(runtime_type=ContainerRuntimeType.PODMAN) + assert info.version is None + assert info.running is False + assert info.config_path is None + assert info.socket_path is None + + +class TestContainersResult: + def test_defaults(self) -> None: + result = ContainersResult() + assert result.runtimes == [] + + def test_roundtrip(self) -> None: + result = ContainersResult( + runtimes=[ + ContainerRuntimeInfo( + runtime_type=ContainerRuntimeType.DOCKER, + version="24.0.7", + running=True, + ), + ], + ) + json_str = result.model_dump_json() + restored = ContainersResult.model_validate_json(json_str) + assert len(restored.runtimes) == 1 + assert restored.runtimes[0].running is True diff --git a/tests/models/test_remaining.py b/tests/models/test_remaining.py index cb1ecda..ea9711c 100644 --- a/tests/models/test_remaining.py +++ b/tests/models/test_remaining.py @@ -28,11 +28,13 @@ ) from mac2nix.models.system import ( FirewallAppRule, + ICloudState, NetworkConfig, NetworkInterface, PrinterInfo, SecurityState, SystemConfig, + SystemExtension, TimeMachineConfig, VpnProfile, ) @@ -269,14 +271,24 @@ def test_audio_config_roundtrip(self) -> None: class TestBinarySource: def test_enum_values(self) -> None: + assert BinarySource.ASDF == "asdf" assert BinarySource.BREW == "brew" assert BinarySource.CARGO == "cargo" + assert BinarySource.CONDA == "conda" + assert BinarySource.GEM == "gem" assert BinarySource.GO == "go" - assert BinarySource.PIPX == "pipx" + assert BinarySource.JENV == "jenv" + assert BinarySource.MACPORTS == "macports" + assert BinarySource.MANUAL == "manual" + assert BinarySource.MISE == "mise" + assert BinarySource.NIX == "nix" assert BinarySource.NPM == "npm" - assert BinarySource.GEM == "gem" + assert BinarySource.NVM == "nvm" + assert BinarySource.PIPX == "pipx" + assert BinarySource.PYENV == "pyenv" + assert BinarySource.RBENV == "rbenv" + assert BinarySource.SDKMAN == "sdkman" assert BinarySource.SYSTEM == "system" - assert BinarySource.MANUAL == "manual" def test_is_str(self) -> None: assert isinstance(BinarySource.BREW, str) @@ -820,3 +832,90 @@ def test_cron_env(self) -> None: def test_cron_env_default(self) -> None: tasks = ScheduledTasks() assert tasks.cron_env == {} + + +class TestSystemExtension: + def test_construction(self) -> None: + ext = SystemExtension( + identifier="com.crowdstrike.falcon.Agent", + team_id="X9E956P446", + version="6.50.16306", + state="activated_enabled", + ) + assert ext.identifier == "com.crowdstrike.falcon.Agent" + assert ext.team_id == "X9E956P446" + assert ext.version == "6.50.16306" + assert ext.state == "activated_enabled" + + def test_defaults(self) -> None: + ext = SystemExtension(identifier="com.example.ext") + assert ext.team_id is None + assert ext.version is None + assert ext.state is None + + def test_roundtrip(self) -> None: + ext = SystemExtension( + identifier="com.apple.DriverKit", + version="1.0", + state="activated_enabled", + ) + json_str = ext.model_dump_json() + restored = SystemExtension.model_validate_json(json_str) + assert restored.identifier == ext.identifier + assert restored.state == ext.state + + +class TestICloudState: + def test_defaults(self) -> None: + state = ICloudState() + assert state.signed_in is False + assert state.desktop_sync is False + assert state.documents_sync is False + + def test_with_sync_enabled(self) -> None: + state = ICloudState( + signed_in=True, + desktop_sync=True, + documents_sync=True, + ) + assert state.signed_in is True + assert state.desktop_sync is True + assert state.documents_sync is True + + def test_roundtrip(self) -> None: + state = ICloudState(signed_in=True, desktop_sync=True) + json_str = state.model_dump_json() + restored = ICloudState.model_validate_json(json_str) + assert restored.signed_in is True + assert restored.desktop_sync is True + assert restored.documents_sync is False + + +class TestSystemConfigNewFieldsRosetta: + def test_new_fields_defaults(self) -> None: + config = SystemConfig(hostname="macbook") + assert config.rosetta_installed is None + assert config.system_extensions == [] + assert config.icloud.signed_in is False + assert config.mdm_enrolled is None + + def test_with_rosetta_and_extensions(self) -> None: + config = SystemConfig( + hostname="macbook", + rosetta_installed=True, + system_extensions=[ + SystemExtension(identifier="com.crowdstrike.falcon.Agent"), + ], + mdm_enrolled=False, + ) + assert config.rosetta_installed is True + assert len(config.system_extensions) == 1 + assert config.mdm_enrolled is False + + def test_with_icloud(self) -> None: + config = SystemConfig( + hostname="macbook", + icloud=ICloudState(signed_in=True, desktop_sync=True), + ) + assert config.icloud.signed_in is True + assert config.icloud.desktop_sync is True diff --git a/tests/scanners/test_applications.py b/tests/scanners/test_applications.py index ab42b77..7632a7d 100644 --- a/tests/scanners/test_applications.py +++ b/tests/scanners/test_applications.py @@ -226,6 +226,76 @@ def test_gem_source(self) -> None: source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.gem/ruby/3.2.0/bin/rubocop")) assert source == BinarySource.GEM + def test_nix_store_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/nix/store/abc123-pkg/bin/foo")) + assert source == BinarySource.NIX + + def test_nix_profile_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.nix-profile/bin/nix-env")) + assert source == BinarySource.NIX + + def test_macports_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/opt/local/bin/port")) + assert source == BinarySource.MACPORTS + + def test_asdf_shims_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.asdf/shims/python")) + assert source == BinarySource.ASDF + + def test_asdf_installs_source(self) -> None: + source = ApplicationsScanner._classify_binary_source( + Path("/Users/user/.asdf/installs/python/3.12.1/bin/python3") + ) + assert source == BinarySource.ASDF + + def test_mise_source(self) -> None: + source = ApplicationsScanner._classify_binary_source( + Path("/Users/user/.local/share/mise/installs/python/3.12/bin/python3") + ) + assert source == BinarySource.MISE + + def test_mise_shims_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.mise/shims/python")) + assert source == BinarySource.MISE + + def test_nvm_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.nvm/versions/node/v20.11.1/bin/node")) + assert source == BinarySource.NVM + + def test_pyenv_shims_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.pyenv/shims/python3")) + assert source == BinarySource.PYENV + + def test_pyenv_versions_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.pyenv/versions/3.12.1/bin/python3")) + assert source == BinarySource.PYENV + + def test_rbenv_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.rbenv/shims/ruby")) + assert source == BinarySource.RBENV + + def test_conda_miniconda_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/miniconda3/bin/conda")) + assert source == BinarySource.CONDA + + def test_conda_miniforge_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/miniforge3/envs/ml/bin/python")) + assert source == BinarySource.CONDA + + def test_conda_anaconda_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/anaconda3/bin/jupyter")) + assert source == BinarySource.CONDA + + def test_sdkman_source(self) -> None: + source = ApplicationsScanner._classify_binary_source( + Path("/Users/user/.sdkman/candidates/java/current/bin/java") + ) + assert source == BinarySource.SDKMAN + + def test_jenv_source(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/Users/user/.jenv/shims/java")) + assert source == BinarySource.JENV + def test_unknown_defaults_manual(self) -> None: source = ApplicationsScanner._classify_binary_source(Path("/some/random/path/tool")) assert source == BinarySource.MANUAL diff --git a/tests/scanners/test_containers.py b/tests/scanners/test_containers.py new file mode 100644 index 0000000..0f53df0 --- /dev/null +++ b/tests/scanners/test_containers.py @@ -0,0 +1,490 @@ +"""Tests for containers scanner.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +from mac2nix.models.package_managers import ( + ContainerRuntimeType, + ContainersResult, +) +from mac2nix.scanners.containers import ContainersScanner + +# --------------------------------------------------------------------------- +# Scanner basics +# --------------------------------------------------------------------------- + + +class TestScannerBasics: + def test_name_property(self) -> None: + assert ContainersScanner().name == "containers" + + def test_is_available_always_true(self) -> None: + assert ContainersScanner().is_available() is True + + def test_scan_returns_containers_result(self) -> None: + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value=None), + patch.object(Path, "exists", return_value=False), + ): + result = ContainersScanner().scan() + assert isinstance(result, ContainersResult) + + def test_empty_scan(self) -> None: + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value=None), + patch.object(Path, "exists", return_value=False), + ): + result = ContainersScanner().scan() + assert result.runtimes == [] + + +# --------------------------------------------------------------------------- +# Docker detection +# --------------------------------------------------------------------------- + + +class TestDockerDetection: + def test_not_present(self) -> None: + with patch("mac2nix.scanners.containers.shutil.which", return_value=None): + result = ContainersScanner()._detect_docker() + assert result is None + + def test_present_with_version(self, cmd_result) -> None: + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/docker"), + patch( + "mac2nix.scanners.containers.run_command", + return_value=cmd_result("Docker version 24.0.7, build afdd53b"), + ), + patch.object(Path, "exists", return_value=False), + patch.object(Path, "is_file", return_value=False), + ): + result = ContainersScanner()._detect_docker() + + assert result is not None + assert result.runtime_type == ContainerRuntimeType.DOCKER + assert result.version == "24.0.7" + + def test_running_via_socket(self, cmd_result, tmp_path: Path) -> None: + # Create the home docker socket path + sock_dir = tmp_path / ".docker" / "run" + sock_dir.mkdir(parents=True) + sock = sock_dir / "docker.sock" + sock.touch() + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/docker"), + patch("mac2nix.scanners.containers.run_command", return_value=cmd_result("Docker version 24.0.7, build x")), + patch("mac2nix.scanners.containers.Path.home", return_value=tmp_path), + ): + result = ContainersScanner()._detect_docker() + + assert result is not None + assert result.running is True + assert result.socket_path == sock + + def test_config_path_detected(self, cmd_result, tmp_path: Path) -> None: + docker_dir = tmp_path / ".docker" + docker_dir.mkdir() + config = docker_dir / "config.json" + config.write_text("{}") + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/docker"), + patch("mac2nix.scanners.containers.run_command", return_value=cmd_result("Docker version 24.0.7, build x")), + patch("mac2nix.scanners.containers.Path.home", return_value=tmp_path), + patch.object(Path, "exists", return_value=False), + ): + result = ContainersScanner()._detect_docker() + + assert result is not None + assert result.config_path == config + + def test_version_command_fails(self) -> None: + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/docker"), + patch("mac2nix.scanners.containers.run_command", return_value=None), + patch.object(Path, "exists", return_value=False), + patch.object(Path, "is_file", return_value=False), + ): + result = ContainersScanner()._detect_docker() + + assert result is not None + assert result.version is None + assert result.running is False + + +# --------------------------------------------------------------------------- +# Podman detection +# --------------------------------------------------------------------------- + + +class TestPodmanDetection: + def test_not_present(self) -> None: + with patch("mac2nix.scanners.containers.shutil.which", return_value=None): + result = ContainersScanner()._detect_podman() + assert result is None + + def test_present_with_version(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["podman", "--version"]: + return cmd_result("podman version 5.0.0") + return None + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/podman"), + patch("mac2nix.scanners.containers.run_command", side_effect=side_effect), + patch.object(Path, "exists", return_value=False), + patch.object(Path, "is_dir", return_value=False), + ): + result = ContainersScanner()._detect_podman() + + assert result is not None + assert result.runtime_type == ContainerRuntimeType.PODMAN + assert result.version == "5.0.0" + assert result.running is False + + def test_running_via_socket(self, cmd_result, tmp_path: Path) -> None: + sock = tmp_path / ".local" / "share" / "containers" / "podman" / "machine" / "podman.sock" + sock.parent.mkdir(parents=True) + sock.touch() + + def side_effect(cmd, **_kwargs): + if cmd == ["podman", "--version"]: + return cmd_result("podman version 5.0.0") + return None + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/podman"), + patch("mac2nix.scanners.containers.run_command", side_effect=side_effect), + patch("mac2nix.scanners.containers.Path.home", return_value=tmp_path), + ): + result = ContainersScanner()._detect_podman() + + assert result is not None + assert result.running is True + + def test_config_path_detected(self, cmd_result, tmp_path: Path) -> None: + containers_dir = tmp_path / ".config" / "containers" + containers_dir.mkdir(parents=True) + + def side_effect(cmd, **_kwargs): + if cmd == ["podman", "--version"]: + return cmd_result("podman version 5.0.0") + return None + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/podman"), + patch("mac2nix.scanners.containers.run_command", side_effect=side_effect), + patch("mac2nix.scanners.containers.Path.home", return_value=tmp_path), + ): + result = ContainersScanner()._detect_podman() + + assert result is not None + assert result.config_path == containers_dir + + +# --------------------------------------------------------------------------- +# Colima detection +# --------------------------------------------------------------------------- + + +class TestColimaDetection: + def test_not_present(self) -> None: + with patch("mac2nix.scanners.containers.shutil.which", return_value=None): + result = ContainersScanner()._detect_colima() + assert result is None + + def test_present_with_version(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["colima", "version"]: + return cmd_result("colima version 0.6.8") + if cmd == ["colima", "status"]: + return cmd_result("", returncode=1) + return None + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/colima"), + patch("mac2nix.scanners.containers.run_command", side_effect=side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = ContainersScanner()._detect_colima() + + assert result is not None + assert result.runtime_type == ContainerRuntimeType.COLIMA + assert result.version == "0.6.8" + + def test_running_detection(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["colima", "version"]: + return cmd_result("colima version 0.6.8") + if cmd == ["colima", "status"]: + return cmd_result("INFO[0000] colima is running") + return None + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/colima"), + patch("mac2nix.scanners.containers.run_command", side_effect=side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = ContainersScanner()._detect_colima() + + assert result is not None + assert result.running is True + + def test_config_path_detected(self, tmp_path: Path) -> None: + colima_dir = tmp_path / ".colima" + colima_dir.mkdir() + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/colima"), + patch("mac2nix.scanners.containers.run_command", return_value=None), + patch("mac2nix.scanners.containers.Path.home", return_value=tmp_path), + ): + result = ContainersScanner()._detect_colima() + + assert result is not None + assert result.config_path == colima_dir + + def test_version_command_fails(self) -> None: + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/colima"), + patch("mac2nix.scanners.containers.run_command", return_value=None), + patch.object(Path, "is_dir", return_value=False), + ): + result = ContainersScanner()._detect_colima() + + assert result is not None + assert result.version is None + + +# --------------------------------------------------------------------------- +# OrbStack detection +# --------------------------------------------------------------------------- + + +class TestOrbStackDetection: + def test_not_present(self) -> None: + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value=None), + patch.object(Path, "exists", return_value=False), + ): + result = ContainersScanner()._detect_orbstack() + assert result is None + + def test_present_via_orbctl(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["orbctl", "version"]: + return cmd_result("OrbStack 1.4.2") + if cmd == ["orbctl", "status"]: + return cmd_result("", returncode=1) + return None + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/orbctl"), + patch("mac2nix.scanners.containers.run_command", side_effect=side_effect), + patch.object(Path, "exists", return_value=False), + patch.object(Path, "is_dir", return_value=False), + ): + result = ContainersScanner()._detect_orbstack() + + assert result is not None + assert result.runtime_type == ContainerRuntimeType.ORBSTACK + assert result.version == "1.4.2" + + def test_present_via_app_only(self) -> None: + original_exists = Path.exists + + def exists_side_effect(path_self): + if str(path_self) == "/Applications/OrbStack.app": + return True + return original_exists(path_self) + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value=None), + patch.object(Path, "exists", exists_side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = ContainersScanner()._detect_orbstack() + + assert result is not None + assert result.runtime_type == ContainerRuntimeType.ORBSTACK + assert result.version is None + + def test_running_detection(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["orbctl", "version"]: + return cmd_result("OrbStack 1.4.2") + if cmd == ["orbctl", "status"]: + return cmd_result("Running") + return None + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/orbctl"), + patch("mac2nix.scanners.containers.run_command", side_effect=side_effect), + patch.object(Path, "exists", return_value=False), + patch.object(Path, "is_dir", return_value=False), + ): + result = ContainersScanner()._detect_orbstack() + + assert result is not None + assert result.running is True + + def test_config_path_detected(self, tmp_path: Path) -> None: + orbstack_dir = tmp_path / "Library" / "Application Support" / "OrbStack" + orbstack_dir.mkdir(parents=True) + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/orbctl"), + patch("mac2nix.scanners.containers.run_command", return_value=None), + patch.object(Path, "exists", return_value=False), + patch("mac2nix.scanners.containers.Path.home", return_value=tmp_path), + ): + result = ContainersScanner()._detect_orbstack() + + assert result is not None + assert result.config_path == orbstack_dir + + +# --------------------------------------------------------------------------- +# Lima detection +# --------------------------------------------------------------------------- + + +class TestLimaDetection: + def test_not_present(self) -> None: + with patch("mac2nix.scanners.containers.shutil.which", return_value=None): + result = ContainersScanner()._detect_lima() + assert result is None + + def test_present_with_version(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["limactl", "--version"]: + return cmd_result("limactl version 0.20.0") + if cmd == ["limactl", "list", "--json"]: + return cmd_result("") + return None + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/limactl"), + patch("mac2nix.scanners.containers.run_command", side_effect=side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = ContainersScanner()._detect_lima() + + assert result is not None + assert result.runtime_type == ContainerRuntimeType.LIMA + assert result.version == "0.20.0" + + def test_running_detection(self, cmd_result) -> None: + instances = [ + json.dumps({"name": "default", "status": "Running"}), + ] + + def side_effect(cmd, **_kwargs): + if cmd == ["limactl", "--version"]: + return cmd_result("limactl version 0.20.0") + if cmd == ["limactl", "list", "--json"]: + return cmd_result("\n".join(instances)) + return None + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/limactl"), + patch("mac2nix.scanners.containers.run_command", side_effect=side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = ContainersScanner()._detect_lima() + + assert result is not None + assert result.running is True + + def test_not_running(self, cmd_result) -> None: + instances = [ + json.dumps({"name": "default", "status": "Stopped"}), + ] + + def side_effect(cmd, **_kwargs): + if cmd == ["limactl", "--version"]: + return cmd_result("limactl version 0.20.0") + if cmd == ["limactl", "list", "--json"]: + return cmd_result("\n".join(instances)) + return None + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/limactl"), + patch("mac2nix.scanners.containers.run_command", side_effect=side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = ContainersScanner()._detect_lima() + + assert result is not None + assert result.running is False + + def test_config_path_detected(self, tmp_path: Path) -> None: + lima_dir = tmp_path / ".lima" + lima_dir.mkdir() + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/limactl"), + patch("mac2nix.scanners.containers.run_command", return_value=None), + patch("mac2nix.scanners.containers.Path.home", return_value=tmp_path), + ): + result = ContainersScanner()._detect_lima() + + assert result is not None + assert result.config_path == lima_dir + + def test_invalid_json_output(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["limactl", "--version"]: + return cmd_result("limactl version 0.20.0") + if cmd == ["limactl", "list", "--json"]: + return cmd_result("not valid json") + return None + + with ( + patch("mac2nix.scanners.containers.shutil.which", return_value="/usr/local/bin/limactl"), + patch("mac2nix.scanners.containers.run_command", side_effect=side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = ContainersScanner()._detect_lima() + + assert result is not None + assert result.running is False + + +# --------------------------------------------------------------------------- +# Full scan integration +# --------------------------------------------------------------------------- + + +class TestFullScan: + def test_multiple_runtimes_detected(self, cmd_result) -> None: + def which_side_effect(name): + if name in ("docker", "podman"): + return f"/usr/local/bin/{name}" + return None + + def run_side_effect(cmd, **_kwargs): + if cmd == ["docker", "--version"]: + return cmd_result("Docker version 24.0.7, build afdd53b") + if cmd == ["podman", "--version"]: + return cmd_result("podman version 5.0.0") + return None + + with ( + patch("mac2nix.scanners.containers.shutil.which", side_effect=which_side_effect), + patch("mac2nix.scanners.containers.run_command", side_effect=run_side_effect), + patch.object(Path, "exists", return_value=False), + patch.object(Path, "is_file", return_value=False), + patch.object(Path, "is_dir", return_value=False), + ): + result = ContainersScanner().scan() + + assert len(result.runtimes) == 2 + types = {r.runtime_type for r in result.runtimes} + assert ContainerRuntimeType.DOCKER in types + assert ContainerRuntimeType.PODMAN in types diff --git a/tests/scanners/test_nix_state.py b/tests/scanners/test_nix_state.py new file mode 100644 index 0000000..2ad03f4 --- /dev/null +++ b/tests/scanners/test_nix_state.py @@ -0,0 +1,825 @@ +"""Tests for nix_state scanner.""" + +import json +from pathlib import Path +from unittest.mock import patch + +from mac2nix.models.package_managers import ( + NixInstallType, + NixState, +) +from mac2nix.scanners.nix_state import NixStateScanner + +# --------------------------------------------------------------------------- +# Scanner basics +# --------------------------------------------------------------------------- + + +class TestScannerBasics: + def test_name_property(self) -> None: + assert NixStateScanner().name == "nix_state" + + def test_is_available_always_true(self) -> None: + assert NixStateScanner().is_available() is True + + def test_scan_returns_nix_state(self) -> None: + with ( + patch("mac2nix.scanners.nix_state.Path.home", return_value=Path("/nonexistent")), + patch("mac2nix.scanners.nix_state.run_command", return_value=None), + patch("mac2nix.scanners.nix_state.Path.exists", return_value=False), + ): + result = NixStateScanner().scan() + assert isinstance(result, NixState) + + +# --------------------------------------------------------------------------- +# Installation detection +# --------------------------------------------------------------------------- + + +class TestNixInstallation: + def test_nix_not_installed(self) -> None: + with ( + patch.object(Path, "exists", return_value=False), + patch("mac2nix.scanners.nix_state.run_command", return_value=None), + ): + result = NixStateScanner().scan() + + assert result.installation.present is False + + def test_nix_installed_version_parsed(self, cmd_result, tmp_path: Path) -> None: + original_exists = Path.exists + + def exists_side_effect(self_path): + path_str = str(self_path) + if "/nix/store" in path_str: + return True + if "receipt.json" in path_str: + return False + if "nix-daemon.plist" in path_str: + return False + return original_exists(self_path) + + def run_side_effect(cmd, **_kwargs): + if cmd == ["nix", "--version"]: + return cmd_result("nix (Nix) 2.18.1\n") + if cmd[0] == "launchctl": + return None + return None + + with ( + patch.object(Path, "exists", exists_side_effect), + patch.object(Path, "is_dir", return_value=False), + patch("mac2nix.scanners.nix_state.run_command", side_effect=run_side_effect), + patch("mac2nix.scanners.nix_state.shutil.which", return_value=None), + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + ): + result = NixStateScanner().scan() + + assert result.installation.present is True + assert result.installation.version == "2.18.1" + + def test_version_fallback_path(self, cmd_result) -> None: + scanner = NixStateScanner() + + def run_side_effect(cmd, **_kwargs): + if cmd == ["nix", "--version"]: + return None # nix not in PATH + if cmd[0] == "/nix/var/nix/profiles/default/bin/nix": + return cmd_result("nix (Nix) 2.20.0\n") + return None + + with ( + patch("mac2nix.scanners.nix_state.run_command", side_effect=run_side_effect), + patch.object(Path, "exists", return_value=True), + ): + version = scanner._get_nix_version() + + assert version == "2.20.0" + + def test_version_unparseable(self, cmd_result) -> None: + scanner = NixStateScanner() + with patch( + "mac2nix.scanners.nix_state.run_command", + return_value=cmd_result("some garbage output"), + ): + version = scanner._get_nix_version() + assert version is None + + def test_install_type_determinate_receipt(self) -> None: + def exists_side_effect(self_path): + return "receipt.json" in str(self_path) + + with patch.object(Path, "exists", exists_side_effect): + result = NixStateScanner._get_install_type() + assert result == NixInstallType.DETERMINATE + + def test_install_type_determinate_config(self) -> None: + def is_dir_side_effect(self_path): + return "determinate" in str(self_path) + + with ( + patch.object(Path, "exists", return_value=False), + patch.object(Path, "is_dir", is_dir_side_effect), + ): + result = NixStateScanner._get_install_type() + assert result == NixInstallType.DETERMINATE + + def test_install_type_multi_user(self) -> None: + def exists_side_effect(self_path): + return "nix-daemon.plist" in str(self_path) + + with ( + patch.object(Path, "exists", exists_side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = NixStateScanner._get_install_type() + assert result == NixInstallType.MULTI_USER + + def test_install_type_unknown_fallback(self) -> None: + with ( + patch.object(Path, "exists", return_value=False), + patch.object(Path, "is_dir", return_value=False), + ): + result = NixStateScanner._get_install_type() + assert result == NixInstallType.UNKNOWN + + def test_daemon_running(self, cmd_result) -> None: + with patch( + "mac2nix.scanners.nix_state.run_command", + return_value=cmd_result("12345\t0\torg.nixos.nix-daemon"), + ): + assert NixStateScanner._is_daemon_running() is True + + def test_daemon_not_running(self, cmd_result) -> None: + with patch( + "mac2nix.scanners.nix_state.run_command", + return_value=cmd_result("-\t0\torg.nixos.nix-daemon"), + ): + assert NixStateScanner._is_daemon_running() is False + + def test_daemon_command_fails(self) -> None: + with patch("mac2nix.scanners.nix_state.run_command", return_value=None): + assert NixStateScanner._is_daemon_running() is False + + +# --------------------------------------------------------------------------- +# Profile detection +# --------------------------------------------------------------------------- + + +class TestProfileDetection: + def test_no_profiles(self, tmp_path: Path) -> None: + scanner = NixStateScanner() + with ( + patch("mac2nix.scanners.nix_state.run_command", return_value=None), + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + ): + result = scanner._detect_profiles() + assert result == [] + + def test_json_profile_list(self, cmd_result, tmp_path: Path) -> None: + profile_json = json.dumps( + { + "elements": [ + { + "storePaths": ["/nix/store/abc123-hello-2.12"], + "attrPath": "hello", + }, + { + "storePaths": ["/nix/store/def456-git-2.42.0"], + "attrPath": "git", + }, + ] + } + ) + scanner = NixStateScanner() + with ( + patch( + "mac2nix.scanners.nix_state.run_command", + return_value=cmd_result(profile_json), + ), + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + ): + result = scanner._detect_profiles() + + assert len(result) == 1 + assert result[0].name == "default" + assert len(result[0].packages) == 2 + assert result[0].packages[0].name == "hello-2.12" + + def test_legacy_nix_env_fallback(self, cmd_result, tmp_path: Path) -> None: + def run_side_effect(cmd, **_kwargs): + if cmd[:3] == ["nix", "profile", "list"]: + return None + if cmd == ["nix-env", "-q"]: + return cmd_result("hello-2.12\ngit-2.42.0\n") + return None + + scanner = NixStateScanner() + with ( + patch("mac2nix.scanners.nix_state.run_command", side_effect=run_side_effect), + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + ): + result = scanner._detect_profiles() + + assert len(result) == 1 + assert len(result[0].packages) == 2 + assert result[0].packages[0].name == "hello-2.12" + + def test_manifest_json_fallback(self, tmp_path: Path) -> None: + manifest_dir = tmp_path / ".nix-profile" + manifest_dir.mkdir() + manifest = manifest_dir / "manifest.json" + manifest.write_text( + json.dumps( + { + "elements": [ + { + "storePaths": ["/nix/store/xyz-curl-8.0"], + "attrPath": "curl", + } + ] + } + ) + ) + + scanner = NixStateScanner() + with ( + patch("mac2nix.scanners.nix_state.run_command", return_value=None), + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + ): + result = scanner._detect_profiles() + + assert len(result) == 1 + assert result[0].packages[0].name == "curl-8.0" + + def test_package_cap(self, cmd_result, tmp_path: Path) -> None: + elements = [{"storePaths": [f"/nix/store/hash-pkg{i}-1.0"], "attrPath": f"pkg{i}"} for i in range(600)] + profile_json = json.dumps({"elements": elements}) + + scanner = NixStateScanner() + with ( + patch( + "mac2nix.scanners.nix_state.run_command", + return_value=cmd_result(profile_json), + ), + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + ): + result = scanner._detect_profiles() + + assert len(result[0].packages) == 500 + + +# --------------------------------------------------------------------------- +# nix-darwin detection +# --------------------------------------------------------------------------- + + +class TestNixDarwin: + def test_not_present(self) -> None: + scanner = NixStateScanner() + with ( + patch.object(Path, "exists", return_value=False), + patch("mac2nix.scanners.nix_state.shutil.which", return_value=None), + ): + result = scanner._detect_darwin() + assert result.present is False + + def test_present_via_current_system(self, tmp_path: Path) -> None: + def exists_side_effect(self_path): + return "/run/current-system" in str(self_path) + + scanner = NixStateScanner() + with ( + patch.object(Path, "exists", exists_side_effect), + patch.object(Path, "is_symlink", return_value=False), + patch("mac2nix.scanners.nix_state.shutil.which", return_value=None), + patch("mac2nix.scanners.nix_state.run_command", return_value=None), + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + ): + result = scanner._detect_darwin() + assert result.present is True + + def test_present_via_darwin_rebuild(self, tmp_path: Path) -> None: + scanner = NixStateScanner() + with ( + patch.object(Path, "exists", return_value=False), + patch.object(Path, "is_symlink", return_value=False), + patch( + "mac2nix.scanners.nix_state.shutil.which", + return_value="/run/current-system/sw/bin/darwin-rebuild", + ), + patch("mac2nix.scanners.nix_state.run_command", return_value=None), + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + ): + result = scanner._detect_darwin() + assert result.present is True + + def test_generation_parsing(self, cmd_result) -> None: + output = ( + " 2024-01-01 12:00 : id 1 -> /nix/var/nix/profiles/system-1-link\n" + " 2024-02-01 12:00 : id 2 -> /nix/var/nix/profiles/system-2-link\n" + " 2024-03-01 12:00 : id 3 -> /nix/var/nix/profiles/system-3-link\n" + ) + with patch( + "mac2nix.scanners.nix_state.run_command", + return_value=cmd_result(output), + ): + result = NixStateScanner._get_darwin_generation() + assert result == 3 + + def test_generation_command_fails(self) -> None: + with patch("mac2nix.scanners.nix_state.run_command", return_value=None): + result = NixStateScanner._get_darwin_generation() + assert result is None + + def test_config_legacy_path(self, tmp_path: Path) -> None: + nixpkgs_dir = tmp_path / ".nixpkgs" + nixpkgs_dir.mkdir() + config = nixpkgs_dir / "darwin-configuration.nix" + config.write_text("{ ... }: {}") + + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + result = NixStateScanner._find_darwin_config() + assert result == config + + +# --------------------------------------------------------------------------- +# Home Manager detection +# --------------------------------------------------------------------------- + + +class TestHomeManager: + def test_not_present(self) -> None: + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.shutil.which", return_value=None): + result = scanner._detect_home_manager() + assert result.present is False + + def test_present_with_generation(self, cmd_result) -> None: + def run_side_effect(cmd, **_kwargs): + if cmd == ["home-manager", "generations"]: + return cmd_result( + "2024-03-01 12:00 : id 42 -> /nix/var/nix/profiles/per-user/user/home-manager-42-link\n" + "2024-02-01 12:00 : id 41 -> /nix/var/nix/profiles/per-user/user/home-manager-41-link\n" + ) + if cmd == ["home-manager", "packages"]: + return cmd_result("hello-2.12\ngit-2.42.0\n") + return None + + scanner = NixStateScanner() + with ( + patch( + "mac2nix.scanners.nix_state.shutil.which", + return_value="/nix/store/bin/home-manager", + ), + patch("mac2nix.scanners.nix_state.run_command", side_effect=run_side_effect), + patch.object(Path, "exists", return_value=False), + ): + result = scanner._detect_home_manager() + + assert result.present is True + assert result.generation == 42 + assert "hello-2.12" in result.packages + assert "git-2.42.0" in result.packages + + def test_config_path_detection(self, tmp_path: Path) -> None: + hm_dir = tmp_path / ".config" / "home-manager" + hm_dir.mkdir(parents=True) + home_nix = hm_dir / "home.nix" + home_nix.write_text("{ ... }: {}") + + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + result = NixStateScanner._find_hm_config() + assert result == home_nix + + def test_config_flake_path(self, tmp_path: Path) -> None: + hm_dir = tmp_path / ".config" / "home-manager" + hm_dir.mkdir(parents=True) + flake_nix = hm_dir / "flake.nix" + flake_nix.write_text("{}") + + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + result = NixStateScanner._find_hm_config() + assert result == flake_nix + + def test_config_legacy_nixpkgs_path(self, tmp_path: Path) -> None: + nixpkgs_dir = tmp_path / ".config" / "nixpkgs" + nixpkgs_dir.mkdir(parents=True) + home_nix = nixpkgs_dir / "home.nix" + home_nix.write_text("{ ... }: {}") + + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + result = NixStateScanner._find_hm_config() + assert result == home_nix + + def test_packages_command_fails(self) -> None: + with patch("mac2nix.scanners.nix_state.run_command", return_value=None): + result = NixStateScanner._get_hm_packages() + assert result == [] + + +# --------------------------------------------------------------------------- +# Channels / flakes / registries +# --------------------------------------------------------------------------- + + +class TestChannelsFlakesRegistries: + def test_no_channels(self) -> None: + with patch("mac2nix.scanners.nix_state.run_command", return_value=None): + result = NixStateScanner._get_channels() + assert result == [] + + def test_channel_list_parsing(self, cmd_result) -> None: + output = ( + "nixpkgs https://nixos.org/channels/nixpkgs-unstable\n" + "nixos-hardware https://github.com/NixOS/nixos-hardware/archive/master.tar.gz\n" + ) + with patch( + "mac2nix.scanners.nix_state.run_command", + return_value=cmd_result(output), + ): + result = NixStateScanner._get_channels() + assert len(result) == 2 + assert result[0].name == "nixpkgs" + assert "nixpkgs-unstable" in result[0].url + + def test_flake_lock_parsing(self, tmp_path: Path) -> None: + lock_data = { + "nodes": { + "root": {"inputs": {"nixpkgs": "nixpkgs"}}, + "nixpkgs": { + "locked": {"rev": "abc123def456"}, + "original": {"owner": "NixOS", "repo": "nixpkgs"}, + }, + "flake-utils": { + "locked": {"rev": "deadbeef1234"}, + "original": {"url": "github:numtide/flake-utils"}, + }, + } + } + + hm_dir = tmp_path / ".config" / "home-manager" + hm_dir.mkdir(parents=True) + lock_file = hm_dir / "flake.lock" + lock_file.write_text(json.dumps(lock_data)) + + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + result = NixStateScanner._get_flake_inputs() + + assert len(result) == 2 + names = {i.name for i in result} + assert "nixpkgs" in names + assert "flake-utils" in names + + nixpkgs = next(i for i in result if i.name == "nixpkgs") + assert nixpkgs.locked_rev == "abc123def456" + assert nixpkgs.url == "github:NixOS/nixpkgs" + + flake_utils = next(i for i in result if i.name == "flake-utils") + assert flake_utils.url == "github:numtide/flake-utils" + + def test_no_flake_locks(self, tmp_path: Path) -> None: + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + result = NixStateScanner._get_flake_inputs() + assert result == [] + + def test_registry_list_parsing(self, cmd_result) -> None: + output = "global flake:nixpkgs path:/nix/store/abc-source\nuser flake:myflake path:/home/user/myflake\n" + with patch( + "mac2nix.scanners.nix_state.run_command", + return_value=cmd_result(output), + ): + result = NixStateScanner._get_registries() + assert len(result) == 2 + assert result[0].from_name == "nixpkgs" + assert result[1].from_name == "myflake" + + def test_registry_command_fails(self) -> None: + with patch("mac2nix.scanners.nix_state.run_command", return_value=None): + result = NixStateScanner._get_registries() + assert result == [] + + +# --------------------------------------------------------------------------- +# Config parsing +# --------------------------------------------------------------------------- + + +class TestConfigParsing: + def test_empty_config(self, tmp_path: Path) -> None: + scanner = NixStateScanner() + with ( + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + patch.object(Path, "exists", return_value=False), + ): + result = scanner._detect_config() + assert result.experimental_features == [] + assert result.substituters == [] + + def test_basic_config(self, tmp_path: Path) -> None: + nix_conf_dir = tmp_path / ".config" / "nix" + nix_conf_dir.mkdir(parents=True) + nix_conf = nix_conf_dir / "nix.conf" + nix_conf.write_text( + "experimental-features = nix-command flakes\n" + "max-jobs = 4\n" + "sandbox = true\n" + "substituters = https://cache.nixos.org https://nix-community.cachix.org\n" + "trusted-users = root user\n" + ) + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + result = scanner._detect_config() + + assert result.experimental_features == ["nix-command", "flakes"] + assert result.max_jobs == 4 + assert result.sandbox is True + assert len(result.substituters) == 2 + assert result.trusted_users == ["root", "user"] + + def test_sensitive_key_redaction(self, tmp_path: Path) -> None: + nix_conf_dir = tmp_path / ".config" / "nix" + nix_conf_dir.mkdir(parents=True) + nix_conf = nix_conf_dir / "nix.conf" + nix_conf.write_text( + "access-tokens = github.com=ghp_secret123\n" + "netrc-file = /etc/nix/netrc\n" + "extra-secret-key = my-key-data\n" + "max-jobs = 8\n" + ) + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + result = scanner._detect_config() + + assert result.extra_config.get("access-tokens") == "**REDACTED**" + assert result.extra_config.get("extra-secret-key") == "**REDACTED**" + assert result.max_jobs == 8 + + def test_comments_and_blanks_skipped(self, tmp_path: Path) -> None: + nix_conf_dir = tmp_path / ".config" / "nix" + nix_conf_dir.mkdir(parents=True) + nix_conf = nix_conf_dir / "nix.conf" + nix_conf.write_text("# comment\n\nmax-jobs = 2\n# another comment\n") + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + result = scanner._detect_config() + assert result.max_jobs == 2 + + def test_multiple_config_files_user_overrides(self, tmp_path: Path) -> None: + user_dir = tmp_path / ".config" / "nix" + user_dir.mkdir(parents=True) + (user_dir / "nix.conf").write_text("max-jobs = 8\n") + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + result = scanner._detect_config() + assert result.max_jobs == 8 + + def test_max_jobs_auto_handled(self, tmp_path: Path) -> None: + nix_conf_dir = tmp_path / ".config" / "nix" + nix_conf_dir.mkdir(parents=True) + (nix_conf_dir / "nix.conf").write_text("max-jobs = auto\n") + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + result = scanner._detect_config() + assert result.max_jobs is None + + +# --------------------------------------------------------------------------- +# Nix-adjacent detection +# --------------------------------------------------------------------------- + + +class TestNixAdjacent: + def test_devbox_json_found(self, tmp_path: Path) -> None: + project_dir = tmp_path / "myproject" + project_dir.mkdir() + devbox = project_dir / "devbox.json" + devbox.write_text(json.dumps({"packages": ["python3", "nodejs"]})) + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + devbox_projects, _, _ = scanner._detect_nix_adjacent() + + assert len(devbox_projects) == 1 + assert devbox_projects[0].path == project_dir + assert "python3" in devbox_projects[0].packages + + def test_devenv_nix_found(self, tmp_path: Path) -> None: + project_dir = tmp_path / "myproject" + project_dir.mkdir() + (project_dir / "devenv.nix").write_text("{ ... }: {}") + (project_dir / "devenv.lock").write_text("{}") + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + _, devenv_projects, _ = scanner._detect_nix_adjacent() + + assert len(devenv_projects) == 1 + assert devenv_projects[0].has_lock is True + + def test_devenv_nix_no_lock(self, tmp_path: Path) -> None: + project_dir = tmp_path / "myproject" + project_dir.mkdir() + (project_dir / "devenv.nix").write_text("{ ... }: {}") + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + _, devenv_projects, _ = scanner._detect_nix_adjacent() + + assert len(devenv_projects) == 1 + assert devenv_projects[0].has_lock is False + + def test_envrc_with_use_flake(self, tmp_path: Path) -> None: + project_dir = tmp_path / "myproject" + project_dir.mkdir() + (project_dir / ".envrc").write_text("use flake\n") + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + _, _, direnv_configs = scanner._detect_nix_adjacent() + + assert len(direnv_configs) == 1 + assert direnv_configs[0].use_flake is True + assert direnv_configs[0].use_nix is False + + def test_envrc_with_use_nix(self, tmp_path: Path) -> None: + project_dir = tmp_path / "myproject" + project_dir.mkdir() + (project_dir / ".envrc").write_text("use_nix\n") + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + _, _, direnv_configs = scanner._detect_nix_adjacent() + + assert len(direnv_configs) == 1 + assert direnv_configs[0].use_nix is True + + def test_envrc_without_nix_ignored(self, tmp_path: Path) -> None: + project_dir = tmp_path / "myproject" + project_dir.mkdir() + (project_dir / ".envrc").write_text("export FOO=bar\n") + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + _, _, direnv_configs = scanner._detect_nix_adjacent() + + assert len(direnv_configs) == 0 + + def test_pruned_dirs_skipped(self, tmp_path: Path) -> None: + git_dir = tmp_path / ".git" + git_dir.mkdir() + (git_dir / "devbox.json").write_text(json.dumps({"packages": ["foo"]})) + + node_modules = tmp_path / "node_modules" + node_modules.mkdir() + (node_modules / "devbox.json").write_text(json.dumps({"packages": ["bar"]})) + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + devbox_projects, _, _ = scanner._detect_nix_adjacent() + + assert len(devbox_projects) == 0 + + def test_depth_limit(self, tmp_path: Path) -> None: + # depth 3 (home -> a -> b -> c) -- should not be found + deep = tmp_path / "a" / "b" / "c" + deep.mkdir(parents=True) + (deep / "devbox.json").write_text(json.dumps({"packages": ["deep"]})) + + # depth 2 (home -> a -> b) -- should be found + (tmp_path / "a" / "b" / "devbox.json").write_text(json.dumps({"packages": ["ok"]})) + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + devbox_projects, _, _ = scanner._detect_nix_adjacent() + + paths = [str(p.path) for p in devbox_projects] + assert str(tmp_path / "a" / "b") in paths + assert str(deep) not in paths + + def test_devbox_json_malformed(self, tmp_path: Path) -> None: + project_dir = tmp_path / "myproject" + project_dir.mkdir() + (project_dir / "devbox.json").write_text("not json at all") + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + devbox_projects, _, _ = scanner._detect_nix_adjacent() + + assert len(devbox_projects) == 1 + assert devbox_projects[0].packages == [] + + def test_cap_limit(self, tmp_path: Path) -> None: + for i in range(55): + d = tmp_path / f"proj{i}" + d.mkdir() + (d / "devbox.json").write_text(json.dumps({"packages": []})) + + scanner = NixStateScanner() + with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + devbox_projects, _, _ = scanner._detect_nix_adjacent() + + assert len(devbox_projects) == 50 + + +# --------------------------------------------------------------------------- +# Full scan integration tests +# --------------------------------------------------------------------------- + + +class TestFullScan: + def test_nix_not_installed_returns_empty(self, tmp_path: Path) -> None: + with ( + patch.object(Path, "exists", return_value=False), + patch("mac2nix.scanners.nix_state.run_command", return_value=None), + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + ): + result = NixStateScanner().scan() + + assert result.installation.present is False + assert result.profiles == [] + assert result.darwin.present is False + assert result.home_manager.present is False + assert result.channels == [] + + def test_nix_installed_full_scan(self, cmd_result, tmp_path: Path) -> None: + original_exists = Path.exists + + def exists_side_effect(self_path): + path_str = str(self_path) + if "/nix/store" in path_str: + return True + if "receipt.json" in path_str: + return False + if "nix-daemon.plist" in path_str: + return True + if "/run/current-system" in path_str: + return False + return original_exists(self_path) + + def run_side_effect(cmd, **_kwargs): # noqa: PLR0911 + if cmd == ["nix", "--version"]: + return cmd_result("nix (Nix) 2.18.1\n") + if cmd[0] == "launchctl": + return cmd_result("12345\t0\torg.nixos.nix-daemon") + if cmd[:3] == ["nix", "profile", "list"]: + return None + if cmd == ["nix-env", "-q"]: + return None + if cmd == ["nix-channel", "--list"]: + return cmd_result("nixpkgs https://nixos.org/channels/nixpkgs-unstable\n") + if cmd[:3] == ["nix", "registry", "list"]: + return None + return None + + with ( + patch.object(Path, "exists", exists_side_effect), + patch.object(Path, "is_dir", return_value=False), + patch.object(Path, "is_symlink", return_value=False), + patch("mac2nix.scanners.nix_state.shutil.which", return_value=None), + patch("mac2nix.scanners.nix_state.run_command", side_effect=run_side_effect), + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + ): + result = NixStateScanner().scan() + + assert result.installation.present is True + assert result.installation.version == "2.18.1" + assert result.installation.install_type == NixInstallType.MULTI_USER + assert result.installation.daemon_running is True + assert len(result.channels) == 1 + assert result.channels[0].name == "nixpkgs" + + def test_scan_with_adjacent_projects(self, tmp_path: Path) -> None: + original_exists = Path.exists + + def exists_side_effect(self_path): + path_str = str(self_path) + if "/nix/store" in path_str: + return True + if "receipt.json" in path_str: + return True # Determinate + return original_exists(self_path) + + proj = tmp_path / "myproj" + proj.mkdir() + (proj / "devbox.json").write_text(json.dumps({"packages": ["ripgrep"]})) + + with ( + patch.object(Path, "exists", exists_side_effect), + patch.object(Path, "is_dir", return_value=False), + patch.object(Path, "is_symlink", return_value=False), + patch("mac2nix.scanners.nix_state.shutil.which", return_value=None), + patch("mac2nix.scanners.nix_state.run_command", return_value=None), + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + ): + result = NixStateScanner().scan() + + assert result.installation.present is True + assert result.installation.install_type == NixInstallType.DETERMINATE diff --git a/tests/scanners/test_package_managers.py b/tests/scanners/test_package_managers.py new file mode 100644 index 0000000..9626dc7 --- /dev/null +++ b/tests/scanners/test_package_managers.py @@ -0,0 +1,432 @@ +"""Tests for package_managers scanner.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +from mac2nix.models.package_managers import PackageManagersResult +from mac2nix.scanners.package_managers_scanner import PackageManagersScanner + +_SCANNER_MODULE = "mac2nix.scanners.package_managers_scanner" + +_PORT_INSTALLED = """\ +The following ports are currently installed: + curl @8.5.0_0 (active) + python312 @3.12.1_0+lto+optimizations (active) + zlib @1.3.1_0 +""" + + +class TestPackageManagersScanner: + def test_name(self) -> None: + assert PackageManagersScanner().name == "package_managers" + + def test_is_available(self) -> None: + assert PackageManagersScanner().is_available() is True + + def test_returns_result_type(self) -> None: + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value=None), + patch.object(Path, "exists", return_value=False), + ): + result = PackageManagersScanner().scan() + assert isinstance(result, PackageManagersResult) + + def test_both_absent(self) -> None: + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value=None), + patch.object(Path, "exists", return_value=False), + ): + result = PackageManagersScanner().scan() + assert result.macports.present is False + assert result.conda.present is False + + +class TestMacPortsDetection: + def test_not_present(self) -> None: + with ( + patch.object(Path, "exists", return_value=False), + patch(f"{_SCANNER_MODULE}.shutil.which", return_value=None), + ): + result = PackageManagersScanner()._detect_macports() + assert result.present is False + + def test_present_via_path(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["port", "version"]: + return cmd_result("Version: 2.9.3") + if cmd == ["port", "installed"]: + return cmd_result(_PORT_INSTALLED) + return None + + with ( + patch.object(Path, "exists", return_value=True), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_macports() + assert result.present is True + assert result.version == "2.9.3" + + def test_present_via_which(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["port", "version"]: + return cmd_result("Version: 2.9.3") + if cmd == ["port", "installed"]: + return cmd_result("") + return None + + with ( + patch.object(Path, "exists", return_value=False), + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/opt/local/bin/port"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_macports() + assert result.present is True + + def test_parses_packages(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["port", "version"]: + return cmd_result("Version: 2.9.3") + if cmd == ["port", "installed"]: + return cmd_result(_PORT_INSTALLED) + return None + + with ( + patch.object(Path, "exists", return_value=True), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_macports() + + assert len(result.packages) == 3 + + curl = next(p for p in result.packages if p.name == "curl") + assert curl.active is True + assert curl.version == "8.5.0_0" + assert curl.variants == [] + + python = next(p for p in result.packages if p.name == "python312") + assert python.active is True + assert python.version == "3.12.1_0" + assert python.variants == ["+lto", "+optimizations"] + + zlib = next(p for p in result.packages if p.name == "zlib") + assert zlib.active is False + assert zlib.version == "1.3.1_0" + + def test_version_command_fails(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["port", "version"]: + return None + if cmd == ["port", "installed"]: + return cmd_result("") + return None + + with ( + patch.object(Path, "exists", return_value=True), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_macports() + assert result.present is True + assert result.version is None + + def test_installed_command_fails(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["port", "version"]: + return cmd_result("Version: 2.9.3") + if cmd == ["port", "installed"]: + return None + return None + + with ( + patch.object(Path, "exists", return_value=True), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_macports() + assert result.present is True + assert result.packages == [] + + def test_empty_installed_output(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["port", "version"]: + return cmd_result("Version: 2.9.3") + if cmd == ["port", "installed"]: + return cmd_result("None are installed.\n") + return None + + with ( + patch.object(Path, "exists", return_value=True), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_macports() + assert result.packages == [] + + +class TestCondaDetection: + def test_not_present(self) -> None: + with patch(f"{_SCANNER_MODULE}.shutil.which", return_value=None): + result = PackageManagersScanner()._detect_conda() + assert result.present is False + + def test_present_via_conda(self, cmd_result) -> None: + conda_info = json.dumps( + { + "envs": ["/Users/user/miniconda3"], + "default_prefix": "/Users/user/miniconda3", + "root_prefix": "/Users/user/miniconda3", + } + ) + conda_list = json.dumps( + [ + {"name": "numpy", "version": "1.26.0", "channel": "defaults"}, + ] + ) + + def which_side_effect(name): + if name == "mamba": + return None + if name == "conda": + return "/Users/user/miniconda3/bin/conda" + return None + + def cmd_side_effect(cmd, **_kwargs): + if cmd == ["conda", "--version"]: + return cmd_result("conda 24.1.0") + if cmd == ["conda", "info", "--json"]: + return cmd_result(conda_info) + if cmd[0] == "conda" and "list" in cmd: + return cmd_result(conda_list) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", side_effect=which_side_effect), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=cmd_side_effect), + patch.object(Path, "is_dir", return_value=True), + ): + result = PackageManagersScanner()._detect_conda() + + assert result.present is True + assert result.version == "24.1.0" + assert len(result.environments) == 1 + assert result.environments[0].name == "base" + assert result.environments[0].is_active is True + assert len(result.environments[0].packages) == 1 + assert result.environments[0].packages[0].name == "numpy" + + def test_prefers_mamba(self, cmd_result) -> None: + conda_info = json.dumps( + { + "envs": [], + "default_prefix": "/Users/user/mambaforge", + "root_prefix": "/Users/user/mambaforge", + } + ) + + def which_side_effect(name): + if name == "mamba": + return "/Users/user/mambaforge/bin/mamba" + if name == "conda": + return "/Users/user/mambaforge/bin/conda" + return None + + def cmd_side_effect(cmd, **_kwargs): + if cmd == ["mamba", "--version"]: + return cmd_result("mamba 1.5.0") + if cmd == ["mamba", "info", "--json"]: + return cmd_result(conda_info) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", side_effect=which_side_effect), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=cmd_side_effect), + ): + result = PackageManagersScanner()._detect_conda() + assert result.present is True + + def test_multiple_environments(self, cmd_result) -> None: + conda_info = json.dumps( + { + "envs": [ + "/Users/user/miniconda3", + "/Users/user/miniconda3/envs/ml", + "/Users/user/miniconda3/envs/web", + ], + "default_prefix": "/Users/user/miniconda3/envs/ml", + "root_prefix": "/Users/user/miniconda3", + } + ) + conda_list = json.dumps([]) + + def which_side_effect(name): + if name == "mamba": + return None + if name == "conda": + return "/Users/user/miniconda3/bin/conda" + return None + + def cmd_side_effect(cmd, **_kwargs): + if cmd == ["conda", "--version"]: + return cmd_result("conda 24.1.0") + if cmd == ["conda", "info", "--json"]: + return cmd_result(conda_info) + if cmd[0] == "conda" and "list" in cmd: + return cmd_result(conda_list) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", side_effect=which_side_effect), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=cmd_side_effect), + patch.object(Path, "is_dir", return_value=True), + ): + result = PackageManagersScanner()._detect_conda() + + assert len(result.environments) == 3 + base = next(e for e in result.environments if e.name == "base") + assert base.is_active is False + ml = next(e for e in result.environments if e.name == "ml") + assert ml.is_active is True + + def test_version_command_fails(self, cmd_result) -> None: + conda_info = json.dumps( + { + "envs": [], + "default_prefix": "/Users/user/miniconda3", + "root_prefix": "/Users/user/miniconda3", + } + ) + + def which_side_effect(name): + if name == "mamba": + return None + if name == "conda": + return "/usr/bin/conda" + return None + + def cmd_side_effect(cmd, **_kwargs): + if cmd == ["conda", "--version"]: + return None + if cmd == ["conda", "info", "--json"]: + return cmd_result(conda_info) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", side_effect=which_side_effect), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=cmd_side_effect), + ): + result = PackageManagersScanner()._detect_conda() + assert result.present is True + assert result.version is None + + def test_info_command_fails(self, cmd_result) -> None: + def which_side_effect(name): + if name == "mamba": + return None + if name == "conda": + return "/usr/bin/conda" + return None + + def cmd_side_effect(cmd, **_kwargs): + if cmd == ["conda", "--version"]: + return cmd_result("conda 24.1.0") + if cmd == ["conda", "info", "--json"]: + return None + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", side_effect=which_side_effect), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=cmd_side_effect), + ): + result = PackageManagersScanner()._detect_conda() + assert result.present is True + assert result.environments == [] + + def test_env_cap(self, cmd_result) -> None: + envs = [f"/Users/user/miniconda3/envs/env{i}" for i in range(25)] + conda_info = json.dumps( + { + "envs": envs, + "default_prefix": "/Users/user/miniconda3", + "root_prefix": "/Users/user/miniconda3", + } + ) + conda_list = json.dumps([]) + + def which_side_effect(name): + if name == "mamba": + return None + if name == "conda": + return "/usr/bin/conda" + return None + + def cmd_side_effect(cmd, **_kwargs): + if cmd == ["conda", "--version"]: + return cmd_result("conda 24.1.0") + if cmd == ["conda", "info", "--json"]: + return cmd_result(conda_info) + if cmd[0] == "conda" and "list" in cmd: + return cmd_result(conda_list) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", side_effect=which_side_effect), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=cmd_side_effect), + ): + result = PackageManagersScanner()._detect_conda() + assert len(result.environments) == 20 + + def test_invalid_json_info(self, cmd_result) -> None: + def which_side_effect(name): + if name == "mamba": + return None + if name == "conda": + return "/usr/bin/conda" + return None + + def cmd_side_effect(cmd, **_kwargs): + if cmd == ["conda", "--version"]: + return cmd_result("conda 24.1.0") + if cmd == ["conda", "info", "--json"]: + return cmd_result("not json") + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", side_effect=which_side_effect), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=cmd_side_effect), + ): + result = PackageManagersScanner()._detect_conda() + assert result.present is True + assert result.environments == [] + + def test_invalid_json_list(self, cmd_result) -> None: + conda_info = json.dumps( + { + "envs": ["/Users/user/miniconda3"], + "default_prefix": "/Users/user/miniconda3", + "root_prefix": "/Users/user/miniconda3", + } + ) + + def which_side_effect(name): + if name == "mamba": + return None + if name == "conda": + return "/usr/bin/conda" + return None + + def cmd_side_effect(cmd, **_kwargs): + if cmd == ["conda", "--version"]: + return cmd_result("conda 24.1.0") + if cmd == ["conda", "info", "--json"]: + return cmd_result(conda_info) + if cmd[0] == "conda" and "list" in cmd: + return cmd_result("not json") + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", side_effect=which_side_effect), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=cmd_side_effect), + ): + result = PackageManagersScanner()._detect_conda() + assert len(result.environments) == 1 + assert result.environments[0].packages == [] diff --git a/tests/scanners/test_system_scanner.py b/tests/scanners/test_system_scanner.py index 70981bd..3d8be1e 100644 --- a/tests/scanners/test_system_scanner.py +++ b/tests/scanners/test_system_scanner.py @@ -588,3 +588,263 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces assert isinstance(result, SystemConfig) assert result.hardware_model is None + + +class TestRosettaDetection: + def test_rosetta_installed_via_directory(self) -> None: + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.Path.is_dir", return_value=True), + ): + scanner = SystemScanner() + result = scanner._detect_rosetta() + + assert result is True + + def test_rosetta_installed_via_arch(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd[0] == "arch": + return cmd_result("", returncode=0) + return None + + with ( + patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect), + patch("mac2nix.scanners.system_scanner.Path.is_dir", return_value=False), + ): + result = SystemScanner()._detect_rosetta() + + assert result is True + + def test_rosetta_not_installed(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd[0] == "arch": + return cmd_result("", returncode=1) + return None + + with ( + patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect), + patch("mac2nix.scanners.system_scanner.Path.is_dir", return_value=False), + ): + result = SystemScanner()._detect_rosetta() + + assert result is False + + def test_rosetta_unknown(self) -> None: + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.Path.is_dir", return_value=False), + ): + result = SystemScanner()._detect_rosetta() + + assert result is None + + def test_rosetta_wired_into_scan(self) -> None: + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.Path.is_dir", return_value=False), + ): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.rosetta_installed is None + + +class TestSystemExtensionsDetection: + def test_no_extensions_command_fails(self) -> None: + with patch("mac2nix.scanners.system_scanner.run_command", return_value=None): + result = SystemScanner()._detect_system_extensions() + + assert result == [] + + def test_extensions_nonzero_exit(self, cmd_result) -> None: + result_proc = cmd_result("", returncode=1) + with patch("mac2nix.scanners.system_scanner.run_command", return_value=result_proc): + result = SystemScanner()._detect_system_extensions() + + assert result == [] + + def test_extensions_parsed(self, cmd_result) -> None: + ext_output = ( + "--- com.apple.system_extension.driver_extension\n" + "enabled\tactive\tABCDEF1234\tcom.crowdstrike.falcon.Agent (6.50.16306)\tactivated_enabled\n" + ) + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["systemextensionsctl", "list"]: + return cmd_result(ext_output) + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner()._detect_system_extensions() + + assert len(result) >= 1 + ext = result[0] + assert ext.identifier == "com.crowdstrike.falcon.Agent" + assert ext.team_id == "ABCDEF1234" + + def test_extensions_skips_short_lines(self, cmd_result) -> None: + ext_output = "--- header\nab\n" + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["systemextensionsctl", "list"]: + return cmd_result(ext_output) + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner()._detect_system_extensions() + + assert result == [] + + def test_extensions_skips_star_lines(self, cmd_result) -> None: + ext_output = "* * some star line\n" + + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["systemextensionsctl", "list"]: + return cmd_result(ext_output) + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner()._detect_system_extensions() + + assert result == [] + + def test_extensions_wired_into_scan(self) -> None: + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.Path.is_dir", return_value=False), + ): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.system_extensions == [] + + +class TestICloudDetection: + def test_signed_in(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["defaults", "read", "MobileMeAccounts", "Accounts"]: + return cmd_result('(\n {\n AccountID = "user@icloud.com";\n }\n)') + return None + + with ( + patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect), + patch("mac2nix.scanners.system_scanner.Path.is_dir", return_value=False), + ): + result = SystemScanner()._detect_icloud() + + assert result.signed_in is True + + def test_not_signed_in_command_fails(self) -> None: + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.Path.is_dir", return_value=False), + ): + result = SystemScanner()._detect_icloud() + + assert result.signed_in is False + + def test_not_signed_in_empty_array(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["defaults", "read", "MobileMeAccounts", "Accounts"]: + return cmd_result("(\n)") + return None + + with ( + patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect), + patch("mac2nix.scanners.system_scanner.Path.is_dir", return_value=False), + ): + result = SystemScanner()._detect_icloud() + + assert result.signed_in is False + + def test_desktop_documents_sync(self, tmp_path) -> None: + cloud_docs = tmp_path / "Library" / "Mobile Documents" / "com~apple~CloudDocs" + (cloud_docs / "Desktop").mkdir(parents=True) + (cloud_docs / "Documents").mkdir(parents=True) + + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.Path.home", return_value=tmp_path), + ): + result = SystemScanner()._detect_icloud() + + assert result.desktop_sync is True + assert result.documents_sync is True + + def test_no_cloud_docs_dir(self, tmp_path) -> None: + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.Path.home", return_value=tmp_path), + ): + result = SystemScanner()._detect_icloud() + + assert result.desktop_sync is False + assert result.documents_sync is False + + def test_icloud_wired_into_scan(self) -> None: + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.Path.is_dir", return_value=False), + ): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.icloud.signed_in is False + + +class TestMDMDetection: + def test_mdm_enrolled(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["profiles", "status", "-type", "enrollment"]: + return cmd_result("Enrolled via DEP: Yes\nMDM enrollment: Yes (User Approved)") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner()._detect_mdm() + + assert result is True + + def test_mdm_not_enrolled(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["profiles", "status", "-type", "enrollment"]: + return cmd_result("Enrolled via DEP: No\nMDM enrollment: No") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner()._detect_mdm() + + assert result is False + + def test_mdm_unknown_command_fails(self) -> None: + with patch("mac2nix.scanners.system_scanner.run_command", return_value=None): + result = SystemScanner()._detect_mdm() + + assert result is None + + def test_mdm_nonzero_exit(self, cmd_result) -> None: + result_proc = cmd_result("", returncode=1) + with patch("mac2nix.scanners.system_scanner.run_command", return_value=result_proc): + result = SystemScanner()._detect_mdm() + + assert result is None + + def test_mdm_ambiguous_output(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["profiles", "status", "-type", "enrollment"]: + return cmd_result("Some unexpected output") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner()._detect_mdm() + + assert result is None + + def test_mdm_wired_into_scan(self) -> None: + with ( + patch("mac2nix.scanners.system_scanner.run_command", return_value=None), + patch("mac2nix.scanners.system_scanner.Path.is_dir", return_value=False), + ): + result = SystemScanner().scan() + + assert isinstance(result, SystemConfig) + assert result.mdm_enrolled is None diff --git a/tests/scanners/test_version_managers.py b/tests/scanners/test_version_managers.py new file mode 100644 index 0000000..9409266 --- /dev/null +++ b/tests/scanners/test_version_managers.py @@ -0,0 +1,723 @@ +"""Tests for version_managers scanner.""" + +import json +from pathlib import Path +from unittest.mock import patch + +from mac2nix.models.package_managers import ( + VersionManagersResult, + VersionManagerType, +) +from mac2nix.scanners.version_managers import VersionManagersScanner + +# --------------------------------------------------------------------------- +# Scanner basics +# --------------------------------------------------------------------------- + + +class TestScannerBasics: + def test_name_property(self) -> None: + assert VersionManagersScanner().name == "version_managers" + + def test_is_available_always_true(self) -> None: + assert VersionManagersScanner().is_available() is True + + def test_scan_returns_version_managers_result(self) -> None: + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value=None), + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=None), + patch.object(Path, "is_dir", return_value=False), + patch.object(Path, "is_file", return_value=False), + ): + result = VersionManagersScanner().scan() + assert isinstance(result, VersionManagersResult) + + def test_global_tool_versions_detected(self, tmp_path: Path) -> None: + tv = tmp_path / ".tool-versions" + tv.write_text("python 3.12.1\n") + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value=None), + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + patch.object(Path, "is_dir", return_value=False), + ): + result = VersionManagersScanner().scan() + + assert result.global_tool_versions == tv + + def test_no_global_tool_versions(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value=None), + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + patch.object(Path, "is_dir", return_value=False), + ): + result = VersionManagersScanner().scan() + + assert result.global_tool_versions is None + + +# --------------------------------------------------------------------------- +# asdf detection +# --------------------------------------------------------------------------- + + +class TestAsdfDetection: + def test_not_present(self) -> None: + with patch("mac2nix.scanners.version_managers.shutil.which", return_value=None): + result = VersionManagersScanner()._detect_asdf() + assert result is None + + def test_present_with_versions(self, cmd_result, tmp_path: Path) -> None: + asdf_list = "python\n 3.12.1\n *3.11.7\nnodejs\n 20.11.1\n" + + def side_effect(cmd, **_kwargs): + if cmd == ["asdf", "version"]: + return cmd_result("v0.14.0") + if cmd == ["asdf", "list"]: + return cmd_result(asdf_list) + return None + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/asdf"), + patch("mac2nix.scanners.version_managers.run_command", side_effect=side_effect), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_asdf() + + assert result is not None + assert result.manager_type == VersionManagerType.ASDF + assert result.version == "v0.14.0" + assert len(result.runtimes) == 3 + # Check active flag + active_runtimes = [r for r in result.runtimes if r.active] + assert len(active_runtimes) == 1 + assert active_runtimes[0].version == "3.11.7" + assert active_runtimes[0].language == "python" + + def test_version_command_fails(self, tmp_path: Path) -> None: + def side_effect(_cmd, **_kwargs): + return None + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/asdf"), + patch("mac2nix.scanners.version_managers.run_command", side_effect=side_effect), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_asdf() + + assert result is not None + assert result.version is None + assert result.runtimes == [] + + def test_config_path_detected(self, tmp_path: Path) -> None: + tv = tmp_path / ".tool-versions" + tv.write_text("python 3.12.1\n") + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/asdf"), + patch("mac2nix.scanners.version_managers.run_command", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_asdf() + + assert result is not None + assert result.config_path == tv + + def test_empty_list_output(self, cmd_result, tmp_path: Path) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["asdf", "version"]: + return cmd_result("v0.14.0") + if cmd == ["asdf", "list"]: + return cmd_result("") + return None + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/asdf"), + patch("mac2nix.scanners.version_managers.run_command", side_effect=side_effect), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_asdf() + + assert result is not None + assert result.runtimes == [] + + +# --------------------------------------------------------------------------- +# mise detection +# --------------------------------------------------------------------------- + + +class TestMiseDetection: + def test_not_present(self) -> None: + with patch("mac2nix.scanners.version_managers.shutil.which", return_value=None): + result = VersionManagersScanner()._detect_mise() + assert result is None + + def test_present_with_json_runtimes(self, cmd_result, tmp_path: Path) -> None: + mise_data = { + "python": [ + {"version": "3.12.1", "active": True, "install_path": "/tmp/mise/python/3.12.1"}, + {"version": "3.11.7", "active": False, "install_path": "/tmp/mise/python/3.11.7"}, + ], + "node": [ + {"version": "20.11.1", "active": True, "install_path": "/tmp/mise/node/20.11.1"}, + ], + } + mise_json = json.dumps(mise_data) + + def side_effect(cmd, **_kwargs): + if cmd == ["mise", "--version"]: + return cmd_result("2024.1.0 linux-x64") + if cmd == ["mise", "list", "--json"]: + return cmd_result(mise_json) + return None + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/mise"), + patch("mac2nix.scanners.version_managers.run_command", side_effect=side_effect), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_mise() + + assert result is not None + assert result.manager_type == VersionManagerType.MISE + assert result.version == "2024.1.0" + assert len(result.runtimes) == 3 + active = [r for r in result.runtimes if r.active] + assert len(active) == 2 + + def test_version_command_fails(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/mise"), + patch("mac2nix.scanners.version_managers.run_command", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_mise() + + assert result is not None + assert result.version is None + + def test_invalid_json_output(self, cmd_result, tmp_path: Path) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["mise", "--version"]: + return cmd_result("2024.1.0") + if cmd == ["mise", "list", "--json"]: + return cmd_result("not valid json") + return None + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/mise"), + patch("mac2nix.scanners.version_managers.run_command", side_effect=side_effect), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_mise() + + assert result is not None + assert result.runtimes == [] + + def test_config_path_detected(self, tmp_path: Path) -> None: + mise_dir = tmp_path / ".config" / "mise" + mise_dir.mkdir(parents=True) + config = mise_dir / "config.toml" + config.write_text("[tools]\npython = '3.12'\n") + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/mise"), + patch("mac2nix.scanners.version_managers.run_command", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_mise() + + assert result is not None + assert result.config_path == config + + +# --------------------------------------------------------------------------- +# nvm detection +# --------------------------------------------------------------------------- + + +class TestNvmDetection: + def test_not_present(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_nvm() + assert result is None + + def test_present_via_env_var(self, tmp_path: Path) -> None: + nvm_dir = tmp_path / ".nvm" + nvm_dir.mkdir() + versions_dir = nvm_dir / "versions" / "node" + versions_dir.mkdir(parents=True) + (versions_dir / "v18.19.0").mkdir() + (versions_dir / "v20.11.1").mkdir() + + with ( + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=str(nvm_dir)), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_nvm() + + assert result is not None + assert result.manager_type == VersionManagerType.NVM + assert result.version is None + assert len(result.runtimes) == 2 + assert all(r.language == "node" for r in result.runtimes) + + def test_present_via_home_dir(self, tmp_path: Path) -> None: + nvm_dir = tmp_path / ".nvm" + nvm_dir.mkdir() + + with ( + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_nvm() + + assert result is not None + assert result.manager_type == VersionManagerType.NVM + + def test_active_version_via_alias(self, tmp_path: Path) -> None: + nvm_dir = tmp_path / ".nvm" + nvm_dir.mkdir() + versions_dir = nvm_dir / "versions" / "node" + versions_dir.mkdir(parents=True) + (versions_dir / "v18.19.0").mkdir() + (versions_dir / "v20.11.1").mkdir() + + alias_dir = nvm_dir / "alias" + alias_dir.mkdir() + (alias_dir / "default").write_text("v20.11.1") + + with ( + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=str(nvm_dir)), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_nvm() + + assert result is not None + active = [r for r in result.runtimes if r.active] + assert len(active) == 1 + assert active[0].version == "v20.11.1" + + def test_nvmrc_config_detected(self, tmp_path: Path) -> None: + nvm_dir = tmp_path / ".nvm" + nvm_dir.mkdir() + nvmrc = tmp_path / ".nvmrc" + nvmrc.write_text("20\n") + + with ( + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_nvm() + + assert result is not None + assert result.config_path == nvmrc + + def test_no_versions_dir(self, tmp_path: Path) -> None: + nvm_dir = tmp_path / ".nvm" + nvm_dir.mkdir() + + with ( + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=str(nvm_dir)), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_nvm() + + assert result is not None + assert result.runtimes == [] + + +# --------------------------------------------------------------------------- +# pyenv detection +# --------------------------------------------------------------------------- + + +class TestPyenvDetection: + def test_not_present(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_pyenv() + assert result is None + + def test_present_with_versions(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["pyenv", "--version"]: + return cmd_result("pyenv 2.3.36") + if cmd == ["pyenv", "versions", "--bare"]: + return cmd_result("3.11.7\n3.12.1\n") + if cmd == ["pyenv", "version-name"]: + return cmd_result("3.12.1") + return None + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/pyenv"), + patch("mac2nix.scanners.version_managers.run_command", side_effect=side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = VersionManagersScanner()._detect_pyenv() + + assert result is not None + assert result.manager_type == VersionManagerType.PYENV + assert result.version == "2.3.36" + assert len(result.runtimes) == 2 + active = [r for r in result.runtimes if r.active] + assert len(active) == 1 + assert active[0].version == "3.12.1" + + def test_present_via_dir_only(self, tmp_path: Path) -> None: + pyenv_dir = tmp_path / ".pyenv" + pyenv_dir.mkdir() + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_pyenv() + + assert result is not None + assert result.version is None + assert result.runtimes == [] + + def test_version_command_fails(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["pyenv", "--version"]: + return None + if cmd == ["pyenv", "versions", "--bare"]: + return cmd_result("3.12.1\n") + if cmd == ["pyenv", "version-name"]: + return None + return None + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/pyenv"), + patch("mac2nix.scanners.version_managers.run_command", side_effect=side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = VersionManagersScanner()._detect_pyenv() + + assert result is not None + assert result.version is None + assert len(result.runtimes) == 1 + + +# --------------------------------------------------------------------------- +# rbenv detection +# --------------------------------------------------------------------------- + + +class TestRbenvDetection: + def test_not_present(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_rbenv() + assert result is None + + def test_present_with_versions(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["rbenv", "--version"]: + return cmd_result("rbenv 1.2.0") + if cmd == ["rbenv", "versions", "--bare"]: + return cmd_result("3.2.2\n3.3.0\n") + if cmd == ["rbenv", "version-name"]: + return cmd_result("3.3.0") + return None + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/rbenv"), + patch("mac2nix.scanners.version_managers.run_command", side_effect=side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = VersionManagersScanner()._detect_rbenv() + + assert result is not None + assert result.manager_type == VersionManagerType.RBENV + assert result.version == "1.2.0" + assert len(result.runtimes) == 2 + active = [r for r in result.runtimes if r.active] + assert len(active) == 1 + assert active[0].version == "3.3.0" + assert active[0].language == "ruby" + + def test_present_via_dir_only(self, tmp_path: Path) -> None: + rbenv_dir = tmp_path / ".rbenv" + rbenv_dir.mkdir() + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_rbenv() + + assert result is not None + assert result.version is None + assert result.runtimes == [] + + def test_versions_command_fails(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["rbenv", "--version"]: + return cmd_result("rbenv 1.2.0") + return None + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/rbenv"), + patch("mac2nix.scanners.version_managers.run_command", side_effect=side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = VersionManagersScanner()._detect_rbenv() + + assert result is not None + assert result.runtimes == [] + + +# --------------------------------------------------------------------------- +# jenv detection +# --------------------------------------------------------------------------- + + +class TestJenvDetection: + def test_not_present(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_jenv() + assert result is None + + def test_present_with_versions(self, cmd_result) -> None: + jenv_output = " system\n 17.0\n 17.0.1\n* 21.0.1 (set by /Users/user/.jenv/version)\n" + + def side_effect(cmd, **_kwargs): + if cmd == ["jenv", "versions"]: + return cmd_result(jenv_output) + return None + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/jenv"), + patch("mac2nix.scanners.version_managers.run_command", side_effect=side_effect), + patch.object(Path, "is_dir", return_value=False), + ): + result = VersionManagersScanner()._detect_jenv() + + assert result is not None + assert result.manager_type == VersionManagerType.JENV + assert result.version is None + assert len(result.runtimes) == 3 + active = [r for r in result.runtimes if r.active] + assert len(active) == 1 + assert active[0].version == "21.0.1" + assert active[0].language == "java" + + def test_present_via_dir_only(self, tmp_path: Path) -> None: + jenv_dir = tmp_path / ".jenv" + jenv_dir.mkdir() + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_jenv() + + assert result is not None + assert result.runtimes == [] + + def test_versions_command_fails(self) -> None: + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/jenv"), + patch("mac2nix.scanners.version_managers.run_command", return_value=None), + patch.object(Path, "is_dir", return_value=False), + ): + result = VersionManagersScanner()._detect_jenv() + + assert result is not None + assert result.runtimes == [] + + def test_system_entry_skipped(self, cmd_result) -> None: + jenv_output = " system\n 17.0\n" + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value="/usr/local/bin/jenv"), + patch( + "mac2nix.scanners.version_managers.run_command", + return_value=cmd_result(jenv_output), + ), + patch.object(Path, "is_dir", return_value=False), + ): + result = VersionManagersScanner()._detect_jenv() + + assert result is not None + assert len(result.runtimes) == 1 + assert result.runtimes[0].version == "17.0" + + +# --------------------------------------------------------------------------- +# sdkman detection +# --------------------------------------------------------------------------- + + +class TestSdkmanDetection: + def test_not_present(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_sdkman() + assert result is None + + def test_present_via_env_var(self, tmp_path: Path) -> None: + sdkman_dir = tmp_path / ".sdkman" + sdkman_dir.mkdir() + + var_dir = sdkman_dir / "var" + var_dir.mkdir() + (var_dir / "version").write_text("5.18.2") + + candidates_dir = sdkman_dir / "candidates" + candidates_dir.mkdir() + java_dir = candidates_dir / "java" + java_dir.mkdir() + (java_dir / "17.0.1").mkdir() + (java_dir / "21.0.1").mkdir() + # Create current symlink + (java_dir / "current").symlink_to(java_dir / "21.0.1") + + with ( + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=str(sdkman_dir)), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_sdkman() + + assert result is not None + assert result.manager_type == VersionManagerType.SDKMAN + assert result.version == "5.18.2" + assert len(result.runtimes) == 2 + active = [r for r in result.runtimes if r.active] + assert len(active) == 1 + assert active[0].version == "21.0.1" + assert active[0].language == "java" + + def test_present_via_home_dir(self, tmp_path: Path) -> None: + sdkman_dir = tmp_path / ".sdkman" + sdkman_dir.mkdir() + + with ( + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_sdkman() + + assert result is not None + assert result.version is None + + def test_multiple_candidates(self, tmp_path: Path) -> None: + sdkman_dir = tmp_path / ".sdkman" + sdkman_dir.mkdir() + + candidates_dir = sdkman_dir / "candidates" + candidates_dir.mkdir() + + for candidate, versions in [("java", ["17.0.1", "21.0.1"]), ("gradle", ["8.5"])]: + cdir = candidates_dir / candidate + cdir.mkdir() + for v in versions: + (cdir / v).mkdir() + + with ( + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=str(sdkman_dir)), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_sdkman() + + assert result is not None + assert len(result.runtimes) == 3 + languages = {r.language for r in result.runtimes} + assert languages == {"java", "gradle"} + + def test_no_candidates_dir(self, tmp_path: Path) -> None: + sdkman_dir = tmp_path / ".sdkman" + sdkman_dir.mkdir() + + with ( + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=str(sdkman_dir)), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_sdkman() + + assert result is not None + assert result.runtimes == [] + + def test_no_version_file(self, tmp_path: Path) -> None: + sdkman_dir = tmp_path / ".sdkman" + sdkman_dir.mkdir() + + with ( + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=str(sdkman_dir)), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner()._detect_sdkman() + + assert result is not None + assert result.version is None + + +# --------------------------------------------------------------------------- +# Full scan integration +# --------------------------------------------------------------------------- + + +class TestFullScan: + def test_no_managers_found(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.version_managers.shutil.which", return_value=None), + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=None), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner().scan() + + assert result.managers == [] + assert result.global_tool_versions is None + + def test_multiple_managers_detected(self, cmd_result, tmp_path: Path) -> None: + # Set up pyenv dir + pyenv_dir = tmp_path / ".pyenv" + pyenv_dir.mkdir() + + # Set up nvm dir + nvm_dir = tmp_path / ".nvm" + nvm_dir.mkdir() + + def which_side_effect(name): + if name == "pyenv": + return "/usr/local/bin/pyenv" + return None + + def run_side_effect(cmd, **_kwargs): + if cmd == ["pyenv", "--version"]: + return cmd_result("pyenv 2.3.36") + if cmd == ["pyenv", "versions", "--bare"]: + return cmd_result("3.12.1\n") + if cmd == ["pyenv", "version-name"]: + return cmd_result("3.12.1") + return None + + with ( + patch("mac2nix.scanners.version_managers.shutil.which", side_effect=which_side_effect), + patch("mac2nix.scanners.version_managers.os.environ.get", return_value=None), + patch("mac2nix.scanners.version_managers.run_command", side_effect=run_side_effect), + patch("mac2nix.scanners.version_managers.Path.home", return_value=tmp_path), + ): + result = VersionManagersScanner().scan() + + manager_types = {m.manager_type for m in result.managers} + assert VersionManagerType.PYENV in manager_types + assert VersionManagerType.NVM in manager_types + assert len(result.managers) == 2 From 1fe8b872fe6f37cf86a1f5e7621b859c7630e26a Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 11 Mar 2026 15:38:02 -0400 Subject: [PATCH 05/17] =?UTF-8?q?fix(scanners):=20smoke-test=20bugs=20?= =?UTF-8?q?=E2=80=94=20redaction,=20daemon,=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mac2nix/scanners/containers.py | 2 +- src/mac2nix/scanners/nix_state.py | 42 +++++++++++++++++------------- tests/scanners/test_nix_state.py | 4 +++ 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/mac2nix/scanners/containers.py b/src/mac2nix/scanners/containers.py index 5aeaf4a..4a57437 100644 --- a/src/mac2nix/scanners/containers.py +++ b/src/mac2nix/scanners/containers.py @@ -89,7 +89,7 @@ def _detect_podman(self) -> ContainerRuntimeInfo | None: # "podman version 5.0.0" parts = result.stdout.strip().split() if len(parts) >= 3: - version = parts[2] + version = parts[2].rstrip(",") # Check socket/machine for running status (mirrors Docker's approach) home = Path.home() diff --git a/src/mac2nix/scanners/nix_state.py b/src/mac2nix/scanners/nix_state.py index 9c4349c..fd50cdd 100644 --- a/src/mac2nix/scanners/nix_state.py +++ b/src/mac2nix/scanners/nix_state.py @@ -29,7 +29,8 @@ logger = logging.getLogger(__name__) -_SENSITIVE_PATTERNS = {"KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTH", "NETRC"} +_SENSITIVE_PATTERNS = {"ACCESS_TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "NETRC"} +_SENSITIVE_EXACT_KEYS = {"access-tokens", "netrc-file"} _PACKAGE_CAP = 500 _ADJACENT_CAP = 50 @@ -115,21 +116,23 @@ def _get_install_type() -> NixInstallType: @staticmethod def _is_daemon_running() -> bool: - result = run_command(["launchctl", "list", "org.nixos.nix-daemon"]) - if result is None or result.returncode != 0: - return False - # launchctl list output: PID\tStatus\tLabel - # If PID is "-", the daemon is not running - first_line = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" - parts = first_line.split() - if len(parts) < 3: - return False - if parts[0] != "-": - try: - int(parts[0]) - return True - except ValueError: - pass + # Try both official and Determinate installer service names + for service in ("org.nixos.nix-daemon", "systems.determinate.nix-daemon"): + result = run_command(["launchctl", "list", service]) + if result is None or result.returncode != 0: + continue + # launchctl list output: PID\tStatus\tLabel + # If PID is "-", the daemon is not running + first_line = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" + parts = first_line.split() + if len(parts) < 3: + continue + if parts[0] != "-": + try: + int(parts[0]) + return True + except ValueError: + pass return False def _detect_profiles(self) -> list[NixProfile]: @@ -416,9 +419,12 @@ def _detect_config(self) -> NixConfig: value = value.strip() # Redact sensitive values - normalized_key = key.upper().replace("-", "_") - if any(p in normalized_key for p in _SENSITIVE_PATTERNS): + if key in _SENSITIVE_EXACT_KEYS: value = "**REDACTED**" + else: + normalized_key = key.upper().replace("-", "_") + if any(p in normalized_key for p in _SENSITIVE_PATTERNS): + value = "**REDACTED**" merged[key] = value diff --git a/tests/scanners/test_nix_state.py b/tests/scanners/test_nix_state.py index 2ad03f4..6678f14 100644 --- a/tests/scanners/test_nix_state.py +++ b/tests/scanners/test_nix_state.py @@ -548,6 +548,7 @@ def test_sensitive_key_redaction(self, tmp_path: Path) -> None: "access-tokens = github.com=ghp_secret123\n" "netrc-file = /etc/nix/netrc\n" "extra-secret-key = my-key-data\n" + "extra-trusted-public-keys = cache.example.com:abc123\n" "max-jobs = 8\n" ) @@ -556,7 +557,10 @@ def test_sensitive_key_redaction(self, tmp_path: Path) -> None: result = scanner._detect_config() assert result.extra_config.get("access-tokens") == "**REDACTED**" + assert result.extra_config.get("netrc-file") == "**REDACTED**" assert result.extra_config.get("extra-secret-key") == "**REDACTED**" + # Public keys are NOT secrets — must not be redacted + assert result.extra_config.get("extra-trusted-public-keys") == "cache.example.com:abc123" assert result.max_jobs == 8 def test_comments_and_blanks_skipped(self, tmp_path: Path) -> None: From 51a7f9822fce7adb325cccc061c985ec052a1a36 Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 11 Mar 2026 16:11:09 -0400 Subject: [PATCH 06/17] fix(scanners): Nix 3.x profile dict format, Determinate daemon pgrep --- src/mac2nix/scanners/nix_state.py | 45 ++++++++++++------- tests/scanners/test_nix_state.py | 75 ++++++++++++++++++++++++++++--- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/mac2nix/scanners/nix_state.py b/src/mac2nix/scanners/nix_state.py index fd50cdd..c0ec6d7 100644 --- a/src/mac2nix/scanners/nix_state.py +++ b/src/mac2nix/scanners/nix_state.py @@ -116,7 +116,7 @@ def _get_install_type() -> NixInstallType: @staticmethod def _is_daemon_running() -> bool: - # Try both official and Determinate installer service names + # 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]) if result is None or result.returncode != 0: @@ -133,6 +133,12 @@ def _is_daemon_running() -> bool: return True except ValueError: pass + # 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]) + if result is not None and result.returncode == 0 and result.stdout.strip(): + return True return False def _detect_profiles(self) -> list[NixProfile]: @@ -197,22 +203,31 @@ def _detect_profiles(self) -> list[NixProfile]: @staticmethod def _parse_profile_json(data: dict) -> list[NixProfilePackage]: packages: list[NixProfilePackage] = [] - # Nix 2.4+ format: {"elements": [...]} elements = data.get("elements", []) - if isinstance(elements, list): - for elem in elements: - if not isinstance(elem, dict): - continue - store_paths = elem.get("storePaths", []) - store_path = Path(store_paths[0]) if store_paths else None - # Derive name from store path: /nix/store/hash-name-version - name = store_path.name.split("-", 1)[1] if store_path else elem.get("attrPath", "unknown") - packages.append( - NixProfilePackage( - name=name, - store_path=store_path, - ) + # Nix 3.x: elements is a dict keyed by package name + # Nix 2.4+: elements is a list of dicts + items: list[tuple[str | None, dict]] = [] + if isinstance(elements, dict): + items = [(name, elem) for name, elem in elements.items() if isinstance(elem, dict)] + elif isinstance(elements, list): + items = [(None, elem) for elem in elements if isinstance(elem, dict)] + + for pkg_name, elem in items: + store_paths = elem.get("storePaths", []) + store_path = Path(store_paths[0]) if store_paths else None + # Derive name: use dict key (Nix 3.x), or store path, or attrPath + if pkg_name: + name = pkg_name + elif store_path: + name = store_path.name.split("-", 1)[1] if "-" in store_path.name else store_path.name + else: + name = elem.get("attrPath", "unknown") + packages.append( + NixProfilePackage( + name=name, + store_path=store_path, ) + ) return packages def _detect_darwin(self) -> NixDarwinState: diff --git a/tests/scanners/test_nix_state.py b/tests/scanners/test_nix_state.py index 6678f14..07b42a3 100644 --- a/tests/scanners/test_nix_state.py +++ b/tests/scanners/test_nix_state.py @@ -144,21 +144,49 @@ def test_install_type_unknown_fallback(self) -> None: result = NixStateScanner._get_install_type() assert result == NixInstallType.UNKNOWN - def test_daemon_running(self, cmd_result) -> None: + def test_daemon_running_via_launchctl(self, cmd_result) -> None: with patch( "mac2nix.scanners.nix_state.run_command", return_value=cmd_result("12345\t0\torg.nixos.nix-daemon"), ): assert NixStateScanner._is_daemon_running() is True + def test_daemon_running_via_pgrep(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd[0] == "launchctl": + return cmd_result("", returncode=1) + if cmd == ["pgrep", "-x", "nix-daemon"]: + return cmd_result("12345") + return None + + with patch("mac2nix.scanners.nix_state.run_command", side_effect=side_effect): + assert NixStateScanner._is_daemon_running() is True + + def test_daemon_running_via_determinate_pgrep(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd[0] == "launchctl": + return cmd_result("", returncode=1) + if cmd == ["pgrep", "-x", "nix-daemon"]: + return cmd_result("", returncode=1) + if cmd == ["pgrep", "-x", "determinate-nixd"]: + return cmd_result("10000") + return None + + with patch("mac2nix.scanners.nix_state.run_command", side_effect=side_effect): + assert NixStateScanner._is_daemon_running() is True + def test_daemon_not_running(self, cmd_result) -> None: - with patch( - "mac2nix.scanners.nix_state.run_command", - return_value=cmd_result("-\t0\torg.nixos.nix-daemon"), - ): + def side_effect(cmd, **_kwargs): + if cmd[0] == "launchctl": + return cmd_result("-\t0\torg.nixos.nix-daemon") + if cmd[0] == "pgrep": + return cmd_result("", returncode=1) + return None + + with patch("mac2nix.scanners.nix_state.run_command", side_effect=side_effect): assert NixStateScanner._is_daemon_running() is False - def test_daemon_command_fails(self) -> None: + 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 @@ -208,6 +236,41 @@ def test_json_profile_list(self, cmd_result, tmp_path: Path) -> None: assert len(result[0].packages) == 2 assert result[0].packages[0].name == "hello-2.12" + def test_json_profile_list_nix3_dict_format(self, cmd_result, tmp_path: Path) -> None: + """Nix 3.x returns elements as a dict keyed by package name.""" + profile_json = json.dumps( + { + "elements": { + "hello": { + "storePaths": ["/nix/store/abc123-hello-2.12"], + "attrPath": "legacyPackages.aarch64-darwin.hello", + "active": True, + }, + "git": { + "storePaths": ["/nix/store/def456-git-2.42.0"], + "attrPath": "legacyPackages.aarch64-darwin.git", + "active": True, + }, + }, + "version": 3, + } + ) + scanner = NixStateScanner() + with ( + patch( + "mac2nix.scanners.nix_state.run_command", + return_value=cmd_result(profile_json), + ), + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + ): + result = scanner._detect_profiles() + + assert len(result) == 1 + assert len(result[0].packages) == 2 + names = {p.name for p in result[0].packages} + assert "hello" in names + assert "git" in names + def test_legacy_nix_env_fallback(self, cmd_result, tmp_path: Path) -> None: def run_side_effect(cmd, **_kwargs): if cmd[:3] == ["nix", "profile", "list"]: From 1e5d31f423ae1336e021362006ee07ccf214ec7e Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 11 Mar 2026 16:15:04 -0400 Subject: [PATCH 07/17] fix(scanners): nix config extra-* merging, version extraction --- src/mac2nix/scanners/nix_state.py | 51 ++++++++++++++++----------- tests/scanners/test_nix_state.py | 57 ++++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 24 deletions(-) diff --git a/src/mac2nix/scanners/nix_state.py b/src/mac2nix/scanners/nix_state.py index c0ec6d7..78982fb 100644 --- a/src/mac2nix/scanners/nix_state.py +++ b/src/mac2nix/scanners/nix_state.py @@ -36,6 +36,7 @@ _ADJACENT_CAP = 50 _ADJACENT_MAX_DEPTH = 2 _PRUNE_DIRS = {".git", "node_modules", ".direnv", "__pycache__", ".venv"} +_SYSTEM_NIX_CONF = Path("/etc/nix/nix.conf") _VERSION_RE = re.compile(r"(\d+\.\d+[\w.]*)") _REGISTRY_RE = re.compile(r"^\S+\s+flake:(\S+)\s+path:(\S+)") @@ -215,16 +216,24 @@ def _parse_profile_json(data: dict) -> list[NixProfilePackage]: for pkg_name, elem in items: store_paths = elem.get("storePaths", []) store_path = Path(store_paths[0]) if store_paths else None - # Derive name: use dict key (Nix 3.x), or store path, or attrPath + # Derive name and version from store path: /nix/store/hash-name-version + version: str | None = None if pkg_name: name = pkg_name elif store_path: name = store_path.name.split("-", 1)[1] if "-" in store_path.name else store_path.name else: name = elem.get("attrPath", "unknown") + # Extract version from store path (e.g., "awscli2-2.33.2" → "2.33.2") + if store_path and "-" in store_path.name: + store_name = store_path.name.split("-", 1)[1] # strip hash + match = _VERSION_RE.search(store_name) + if match: + version = match.group(1) packages.append( NixProfilePackage( name=name, + version=version, store_path=store_path, ) ) @@ -411,7 +420,7 @@ def _get_registries() -> list[NixRegistryEntry]: def _detect_config(self) -> NixConfig: config_files = [ - Path("/etc/nix/nix.conf"), + _SYSTEM_NIX_CONF, Path.home() / ".config" / "nix" / "nix.conf", ] @@ -443,26 +452,30 @@ def _detect_config(self) -> NixConfig: merged[key] = value + # Nix supports both "key" and "extra-key" variants — merge them + def _merge_list(key: str) -> list[str]: + base = merged.get(key, "").split() if merged.get(key) else [] + extra = merged.get(f"extra-{key}", "").split() if merged.get(f"extra-{key}") else [] + return base + extra + + known_keys = { + "experimental-features", + "extra-experimental-features", + "substituters", + "extra-substituters", + "trusted-users", + "extra-trusted-users", + "max-jobs", + "sandbox", + } + return NixConfig( - experimental_features=merged.get("experimental-features", "").split() - if merged.get("experimental-features") - else [], - substituters=merged.get("substituters", "").split() if merged.get("substituters") else [], - trusted_users=merged.get("trusted-users", "").split() if merged.get("trusted-users") else [], + experimental_features=_merge_list("experimental-features"), + substituters=_merge_list("substituters"), + trusted_users=_merge_list("trusted-users"), max_jobs=self._parse_max_jobs(merged.get("max-jobs")), sandbox=merged["sandbox"] == "true" if merged.get("sandbox") else None, - extra_config={ - k: v - for k, v in merged.items() - if k - not in { - "experimental-features", - "substituters", - "trusted-users", - "max-jobs", - "sandbox", - } - }, + extra_config={k: v for k, v in merged.items() if k not in known_keys}, ) @staticmethod diff --git a/tests/scanners/test_nix_state.py b/tests/scanners/test_nix_state.py index 07b42a3..f1b06f6 100644 --- a/tests/scanners/test_nix_state.py +++ b/tests/scanners/test_nix_state.py @@ -235,6 +235,7 @@ def test_json_profile_list(self, cmd_result, tmp_path: Path) -> None: assert result[0].name == "default" assert len(result[0].packages) == 2 assert result[0].packages[0].name == "hello-2.12" + assert result[0].packages[0].version == "2.12" def test_json_profile_list_nix3_dict_format(self, cmd_result, tmp_path: Path) -> None: """Nix 3.x returns elements as a dict keyed by package name.""" @@ -594,7 +595,11 @@ def test_basic_config(self, tmp_path: Path) -> None: ) scanner = NixStateScanner() - with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + no_sys_conf = tmp_path / "nonexistent" + with ( + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + patch("mac2nix.scanners.nix_state._SYSTEM_NIX_CONF", no_sys_conf), + ): result = scanner._detect_config() assert result.experimental_features == ["nix-command", "flakes"] @@ -603,6 +608,32 @@ def test_basic_config(self, tmp_path: Path) -> None: assert len(result.substituters) == 2 assert result.trusted_users == ["root", "user"] + def test_extra_prefix_merged(self, tmp_path: Path) -> None: + """Nix extra-* keys should be merged into their base fields.""" + nix_conf_dir = tmp_path / ".config" / "nix" + nix_conf_dir.mkdir(parents=True) + nix_conf = nix_conf_dir / "nix.conf" + nix_conf.write_text( + "extra-experimental-features = nix-command flakes\n" + "extra-substituters = https://install.determinate.systems\n" + "extra-trusted-users = admin\n" + ) + + scanner = NixStateScanner() + no_sys_conf = tmp_path / "nonexistent" + with ( + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + patch("mac2nix.scanners.nix_state._SYSTEM_NIX_CONF", no_sys_conf), + ): + result = scanner._detect_config() + + assert result.experimental_features == ["nix-command", "flakes"] + assert result.substituters == ["https://install.determinate.systems"] + assert result.trusted_users == ["admin"] + # extra-* keys should NOT appear in extra_config + assert "extra-experimental-features" not in result.extra_config + assert "extra-substituters" not in result.extra_config + def test_sensitive_key_redaction(self, tmp_path: Path) -> None: nix_conf_dir = tmp_path / ".config" / "nix" nix_conf_dir.mkdir(parents=True) @@ -616,7 +647,11 @@ def test_sensitive_key_redaction(self, tmp_path: Path) -> None: ) scanner = NixStateScanner() - with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + no_sys_conf = tmp_path / "nonexistent" + with ( + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + patch("mac2nix.scanners.nix_state._SYSTEM_NIX_CONF", no_sys_conf), + ): result = scanner._detect_config() assert result.extra_config.get("access-tokens") == "**REDACTED**" @@ -633,7 +668,11 @@ def test_comments_and_blanks_skipped(self, tmp_path: Path) -> None: nix_conf.write_text("# comment\n\nmax-jobs = 2\n# another comment\n") scanner = NixStateScanner() - with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + no_sys_conf = tmp_path / "nonexistent" + with ( + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + patch("mac2nix.scanners.nix_state._SYSTEM_NIX_CONF", no_sys_conf), + ): result = scanner._detect_config() assert result.max_jobs == 2 @@ -643,7 +682,11 @@ def test_multiple_config_files_user_overrides(self, tmp_path: Path) -> None: (user_dir / "nix.conf").write_text("max-jobs = 8\n") scanner = NixStateScanner() - with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + no_sys_conf = tmp_path / "nonexistent" + with ( + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + patch("mac2nix.scanners.nix_state._SYSTEM_NIX_CONF", no_sys_conf), + ): result = scanner._detect_config() assert result.max_jobs == 8 @@ -653,7 +696,11 @@ def test_max_jobs_auto_handled(self, tmp_path: Path) -> None: (nix_conf_dir / "nix.conf").write_text("max-jobs = auto\n") scanner = NixStateScanner() - with patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path): + no_sys_conf = tmp_path / "nonexistent" + with ( + patch("mac2nix.scanners.nix_state.Path.home", return_value=tmp_path), + patch("mac2nix.scanners.nix_state._SYSTEM_NIX_CONF", no_sys_conf), + ): result = scanner._detect_config() assert result.max_jobs is None From 0f9d41045f66a63be2df7c3ad6e4bf572213c5d3 Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 11 Mar 2026 19:28:18 -0400 Subject: [PATCH 08/17] fix(scanners): registry parser matches all URL types, not just path: --- src/mac2nix/scanners/nix_state.py | 2 +- tests/scanners/test_nix_state.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/mac2nix/scanners/nix_state.py b/src/mac2nix/scanners/nix_state.py index 78982fb..188f858 100644 --- a/src/mac2nix/scanners/nix_state.py +++ b/src/mac2nix/scanners/nix_state.py @@ -39,7 +39,7 @@ _SYSTEM_NIX_CONF = Path("/etc/nix/nix.conf") _VERSION_RE = re.compile(r"(\d+\.\d+[\w.]*)") -_REGISTRY_RE = re.compile(r"^\S+\s+flake:(\S+)\s+path:(\S+)") +_REGISTRY_RE = re.compile(r"^\S+\s+flake:(\S+)\s+(\S+)") @register("nix_state") diff --git a/tests/scanners/test_nix_state.py b/tests/scanners/test_nix_state.py index f1b06f6..4597196 100644 --- a/tests/scanners/test_nix_state.py +++ b/tests/scanners/test_nix_state.py @@ -550,15 +550,21 @@ def test_no_flake_locks(self, tmp_path: Path) -> None: assert result == [] def test_registry_list_parsing(self, cmd_result) -> None: - output = "global flake:nixpkgs path:/nix/store/abc-source\nuser flake:myflake path:/home/user/myflake\n" + output = ( + "global flake:nixpkgs github:NixOS/nixpkgs\n" + "global flake:disko github:nix-community/disko\n" + "user flake:myflake path:/home/user/myflake\n" + ) with patch( "mac2nix.scanners.nix_state.run_command", return_value=cmd_result(output), ): result = NixStateScanner._get_registries() - assert len(result) == 2 + assert len(result) == 3 assert result[0].from_name == "nixpkgs" - assert result[1].from_name == "myflake" + assert result[0].to_url == "github:NixOS/nixpkgs" + assert result[2].from_name == "myflake" + assert result[2].to_url == "path:/home/user/myflake" def test_registry_command_fails(self) -> None: with patch("mac2nix.scanners.nix_state.run_command", return_value=None): From 4e803cd1408b1f4cf91f9ae97c386b5f7a560474 Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 12 Mar 2026 12:25:41 -0400 Subject: [PATCH 09/17] fix(scanners): 13 bugs found by full live-system audit Crashes fixed: - preferences: catch AttributeError from malformed plist dates - library_audit: guard _redact_sensitive_keys with isinstance(dict) - _utils: handle plistlib.UID in convert_datetimes, fix return type Wrong data fixed: - display: retina detection via spdisplays_pixelresolution + display_type - display: parse refresh_rate from resolution string as fallback - system: system_extensions parser no longer skips *-prefixed data lines - system: parse [activated enabled] bracket state format - homebrew: parse stdout even on non-zero exit (broken cask refs) - shell: extract only path arg from fish_add_path, skip flags - containers: validate podman --version output isnt Docker shim Silently missing data fixed: - system: NTP fallback via launchctl + /etc/ntp.conf - system: sleep_settings fallback via pmset -g - version_managers: require binary for pyenv/rbenv/jenv detection - applications: detect Homebrew cask apps via Caskroom directory - display: night shift/true tone fallback via defaults export --- src/mac2nix/scanners/_utils.py | 10 +- src/mac2nix/scanners/applications.py | 36 ++++++- src/mac2nix/scanners/containers.py | 12 ++- src/mac2nix/scanners/display.py | 123 ++++++++++++++++------- src/mac2nix/scanners/homebrew.py | 6 +- src/mac2nix/scanners/library_audit.py | 9 +- src/mac2nix/scanners/preferences.py | 2 +- src/mac2nix/scanners/shell.py | 6 +- src/mac2nix/scanners/system_scanner.py | 68 ++++++++++--- src/mac2nix/scanners/version_managers.py | 37 +++---- tests/scanners/test_system_scanner.py | 13 ++- tests/scanners/test_version_managers.py | 20 ++-- 12 files changed, 242 insertions(+), 100 deletions(-) diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index 41e6d8e..2816656 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -25,13 +25,15 @@ def convert_datetimes(obj: Any) -> Any: """Recursively convert non-JSON-safe plist values. - plistlib returns datetime objects (for NSDate) and bytes objects (for NSData) - that are not JSON-serializable. Convert them to strings. + plistlib returns datetime objects (for NSDate), bytes objects (for NSData), + and UID objects that are not JSON-serializable. Convert them to strings/ints. """ if isinstance(obj, datetime): return obj.isoformat() if isinstance(obj, bytes): return f"" + if isinstance(obj, plistlib.UID): + return int(obj) if isinstance(obj, dict): return {k: convert_datetimes(v) for k, v in obj.items()} if isinstance(obj, list): @@ -65,7 +67,7 @@ def run_command( return None -def read_plist_safe(path: Path) -> dict[str, Any] | None: +def read_plist_safe(path: Path) -> dict[str, Any] | list[Any] | None: """Read a plist file safely, returning None on failure. Handles both binary and XML plists. Converts datetime values to ISO strings @@ -175,7 +177,7 @@ def read_launchd_plists() -> list[tuple[Path, str, dict[str, Any]]]: continue for plist_path in plist_files: data = read_plist_safe(plist_path) - if data is not None: + if isinstance(data, dict): results.append((plist_path, source_key, data)) return results diff --git a/src/mac2nix/scanners/applications.py b/src/mac2nix/scanners/applications.py index 6b7ed46..3b1ee38 100644 --- a/src/mac2nix/scanners/applications.py +++ b/src/mac2nix/scanners/applications.py @@ -68,6 +68,7 @@ def name(self) -> str: def scan(self) -> ApplicationsResult: apps: list[InstalledApp] = [] mas_names = self._get_mas_apps() if shutil.which("mas") else {} + cask_names = self._get_cask_apps() for app_dir in _APP_DIRS: if not app_dir.exists(): @@ -81,12 +82,17 @@ def scan(self) -> ApplicationsResult: if info_plist.exists(): data = read_plist_safe(info_plist) - if data is not None: + if isinstance(data, dict): bundle_id = data.get("CFBundleIdentifier") version = data.get("CFBundleShortVersionString") app_name = app_path.stem - source = AppSource.APPSTORE if app_name.lower() in mas_names else AppSource.MANUAL + if app_name.lower() in mas_names: + source = AppSource.APPSTORE + elif app_name.lower() in cask_names: + source = AppSource.CASK + else: + source = AppSource.MANUAL apps.append( InstalledApp( @@ -110,6 +116,32 @@ def scan(self) -> ApplicationsResult: clt_version=clt_version, ) + @staticmethod + def _get_cask_apps() -> set[str]: + """Get app names installed via Homebrew Cask by checking the Caskroom.""" + cask_names: set[str] = set() + for caskroom in [Path("/opt/homebrew/Caskroom"), Path("/usr/local/Caskroom")]: + if not caskroom.is_dir(): + continue + try: + for cask_dir in caskroom.iterdir(): + if not cask_dir.is_dir(): + continue + # Walk version subdirectories to find .app bundles + try: + for version_dir in cask_dir.iterdir(): + if not version_dir.is_dir(): + continue + for item in version_dir.iterdir(): + if item.suffix == ".app": + cask_names.add(item.stem.lower()) + except (PermissionError, OSError): + # Fall back to using the cask name itself + cask_names.add(cask_dir.name.lower()) + except PermissionError: + pass + return cask_names + def _get_mas_apps(self) -> dict[str, int]: """Get App Store app names (lowercased) from mas list.""" result = run_command(["mas", "list"]) diff --git a/src/mac2nix/scanners/containers.py b/src/mac2nix/scanners/containers.py index 4a57437..94d4344 100644 --- a/src/mac2nix/scanners/containers.py +++ b/src/mac2nix/scanners/containers.py @@ -86,10 +86,14 @@ def _detect_podman(self) -> ContainerRuntimeInfo | None: version: str | None = None result = run_command(["podman", "--version"]) if result and result.returncode == 0: - # "podman version 5.0.0" - parts = result.stdout.strip().split() - if len(parts) >= 3: - version = parts[2].rstrip(",") + output = result.stdout.strip() + # Validate format: "podman version 5.0.0" + # Docker Desktop provides a podman shim that outputs "Docker version X" + if output.lower().startswith("podman"): + parts = output.split() + if len(parts) >= 3: + version = parts[2].rstrip(",") + # else: Docker shim detected, leave version as None # Check socket/machine for running status (mirrors Docker's approach) home = Path.home() diff --git a/src/mac2nix/scanners/display.py b/src/mac2nix/scanners/display.py index b5d4c2b..43b9a4c 100644 --- a/src/mac2nix/scanners/display.py +++ b/src/mac2nix/scanners/display.py @@ -4,8 +4,10 @@ import json import logging +import plistlib import shutil from pathlib import Path +from typing import Any from mac2nix.models.hardware import DisplayConfig, Monitor, NightShiftConfig from mac2nix.scanners._utils import read_plist_safe, run_command @@ -60,17 +62,26 @@ def _parse_monitor(self, display: dict[str, object]) -> Monitor: resolution = display.get("_spdisplays_resolution", display.get("spdisplays_resolution")) resolution_str = str(resolution) if resolution is not None else None display_type = str(display.get("spdisplays_display_type", "")) - retina = "Retina" in (resolution_str or "") or display_type == "spdisplays_retina" + pixel_res = str(display.get("spdisplays_pixelresolution", "")) + retina = ( + "retina" in pixel_res.lower() + or "retina" in display_type.lower() + or "Retina" in (resolution_str or "") + ) arrangement = None if display.get("spdisplays_main") == "spdisplays_yes": arrangement = "primary" - # Refresh rate (12b) + # Refresh rate: try dedicated key, fall back to parsing resolution string refresh_rate = display.get("_spdisplays_refresh", display.get("spdisplays_refresh")) refresh_str = str(refresh_rate) if refresh_rate is not None else None + if refresh_str is None and resolution_str and "@" in resolution_str: + # Parse from "2560 x 1440 @ 100.00Hz" + hz_part = resolution_str.split("@", 1)[1].strip() + refresh_str = hz_part.replace("Hz", "").strip() - # Color profile (12c) + # Color profile color_profile = display.get("spdisplays_color_profile", display.get("_spdisplays_color_profile")) color_str = str(color_profile) if color_profile is not None else None @@ -85,53 +96,87 @@ def _parse_monitor(self, display: dict[str, object]) -> Monitor: def _get_night_shift(self) -> NightShiftConfig | None: """Detect Night Shift settings from CoreBrightness preferences.""" + # Try plist files first for plist_path in [ Path.home() / "Library" / "Preferences" / "com.apple.CoreBrightness.plist", Path("/private/var/root/Library/Preferences/com.apple.CoreBrightness.plist"), ]: data = read_plist_safe(plist_path) - if data is None: + if not isinstance(data, dict): continue + config = self._parse_night_shift(data) + if config is not None: + return config + + # Fall back to cfprefsd via defaults export + result = run_command(["defaults", "export", "com.apple.CoreBrightness", "-"]) + if result is not None and result.returncode == 0: + try: + data = plistlib.loads(result.stdout.encode()) + except Exception: + data = None + if isinstance(data, dict): + config = self._parse_night_shift(data) + if config is not None: + return config - # Night Shift data lives under CBBlueReductionStatus. On some - # macOS versions the plist is keyed by user UUID at the top level - # (e.g. {"": {"CBBlueReductionStatus": {...}}}), so we - # fall back to searching one level deep for the nested key. - ns_data = data.get("CBBlueReductionStatus", {}) - if not isinstance(ns_data, dict): - for val in data.values(): - if isinstance(val, dict) and "CBBlueReductionStatus" in val: - ns_data = val["CBBlueReductionStatus"] - break - - if not ns_data: - continue + return None - enabled = ns_data.get("BlueReductionEnabled") - mode = ns_data.get("BlueReductionMode") - schedule: str | None = None - if mode == 1: - schedule = "sunset-to-sunrise" - elif mode == 2: - schedule = "custom" - elif enabled is False or enabled == 0: - schedule = "off" - - return NightShiftConfig( - enabled=bool(enabled) if enabled is not None else None, - schedule=schedule, - ) + @staticmethod + def _parse_night_shift(data: dict[str, Any]) -> NightShiftConfig | None: + """Extract Night Shift config from CoreBrightness plist data.""" + # Night Shift data lives under CBBlueReductionStatus. On some + # macOS versions the plist is keyed by user UUID at the top level. + ns_data = data.get("CBBlueReductionStatus", {}) + if not isinstance(ns_data, dict): + for val in data.values(): + if isinstance(val, dict) and "CBBlueReductionStatus" in val: + ns_data = val["CBBlueReductionStatus"] + break + + if not ns_data: + return None - return None + enabled = ns_data.get("BlueReductionEnabled") + mode = ns_data.get("BlueReductionMode") + schedule: str | None = None + if mode == 1: + schedule = "sunset-to-sunrise" + elif mode == 2: + schedule = "custom" + elif enabled is False or enabled == 0: + schedule = "off" + + return NightShiftConfig( + enabled=bool(enabled) if enabled is not None else None, + schedule=schedule, + ) 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"]) - if result is None or result.returncode != 0: - return None - value = result.stdout.strip() - if value == "1": - return True - if value == "0": - return False + if result is not None and result.returncode == 0: + value = result.stdout.strip() + if value == "1": + return True + if value == "0": + return False + + # Fall back to full export and search + result = run_command(["defaults", "export", "com.apple.CoreBrightness", "-"]) + if result is not None and result.returncode == 0: + try: + data = plistlib.loads(result.stdout.encode()) + except Exception: + return None + if isinstance(data, dict): + # May be nested under a user UUID key + val = data.get("CBColorAdaptationEnabled") + if val is not None: + return bool(val) + for v in data.values(): + if isinstance(v, dict) and "CBColorAdaptationEnabled" in v: + return bool(v["CBColorAdaptationEnabled"]) + return None diff --git a/src/mac2nix/scanners/homebrew.py b/src/mac2nix/scanners/homebrew.py index b2f0d7e..9a2d007 100644 --- a/src/mac2nix/scanners/homebrew.py +++ b/src/mac2nix/scanners/homebrew.py @@ -105,12 +105,14 @@ 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"]) - if result is None or result.returncode != 0: + if result is None: return {} + # Parse stdout even on non-zero exit — brew may report errors about + # broken cask references while still outputting valid version data. versions: dict[str, str] = {} for line in result.stdout.splitlines(): parts = line.split() - if len(parts) >= 2: + if len(parts) >= 2 and not line.startswith("Error:"): versions[parts[0]] = parts[-1] return versions diff --git a/src/mac2nix/scanners/library_audit.py b/src/mac2nix/scanners/library_audit.py index 0b48465..3dcc47d 100644 --- a/src/mac2nix/scanners/library_audit.py +++ b/src/mac2nix/scanners/library_audit.py @@ -241,8 +241,9 @@ def _classify_file(self, filepath: Path) -> LibraryFileEntry | None: strategy = "hash_only" if suffix == ".plist": - plist_content = read_plist_safe(filepath) - if plist_content is not None: + raw_plist = read_plist_safe(filepath) + if isinstance(raw_plist, dict): + plist_content = raw_plist _redact_sensitive_keys(plist_content) strategy = "plist_capture" elif suffix in {".txt", ".md", ".cfg", ".conf", ".ini", ".yaml", ".yml", ".json", ".xml"}: @@ -352,7 +353,9 @@ def _parse_workflow(wf_path: Path) -> WorkflowEntry | None: doc_plist = wf_path / "Contents" / "document.wflow" if doc_plist.is_file(): - definition = read_plist_safe(doc_plist) + raw = read_plist_safe(doc_plist) + if isinstance(raw, dict): + definition = raw return WorkflowEntry( name=wf_path.stem, diff --git a/src/mac2nix/scanners/preferences.py b/src/mac2nix/scanners/preferences.py index 454b4e9..ab7ca4a 100644 --- a/src/mac2nix/scanners/preferences.py +++ b/src/mac2nix/scanners/preferences.py @@ -91,7 +91,7 @@ def _export_domain(domain_name: str) -> dict[str, PreferenceValue] | None: return None try: data = plistlib.loads(result.stdout.encode()) - except (plistlib.InvalidFileException, ValueError, KeyError, OverflowError): + except (plistlib.InvalidFileException, ValueError, KeyError, OverflowError, AttributeError): return None if not isinstance(data, dict): return None diff --git a/src/mac2nix/scanners/shell.py b/src/mac2nix/scanners/shell.py index ed20671..89ec647 100644 --- a/src/mac2nix/scanners/shell.py +++ b/src/mac2nix/scanners/shell.py @@ -255,7 +255,11 @@ def _parse_fish_line(self, line: str, parsed: _ParsedShellData) -> None: match = _FISH_ADD_PATH.match(line) if match: - parsed.path_components.append(match.group(1).strip("'\"")) + # Extract the actual path, skipping flags like --prepend --move --global + args = match.group(1).split() + path_arg = next((a for a in args if not a.startswith("-")), None) + if path_arg: + parsed.path_components.append(path_arg.strip("'\"")) return match = _FISH_FUNCTION_PATTERN.match(line) diff --git a/src/mac2nix/scanners/system_scanner.py b/src/mac2nix/scanners/system_scanner.py index e7944b3..731fc34 100644 --- a/src/mac2nix/scanners/system_scanner.py +++ b/src/mac2nix/scanners/system_scanner.py @@ -255,7 +255,7 @@ def _get_software_update(self) -> dict[str, Any]: """Read software update preferences.""" plist_path = Path("/Library/Preferences/com.apple.SoftwareUpdate.plist") data = read_plist_safe(plist_path) - if data is None: + if not isinstance(data, dict): return {} # Extract known keys of interest keys = [ @@ -282,6 +282,9 @@ def _get_sleep_settings(self) -> dict[str, str | int | None]: if result is None or result.returncode != 0: continue output = result.stdout.strip() + # Filter out admin-required errors + if "administrator access" in output.lower(): + continue # Parse "Computer Sleep: 10" or "Wake On Network Access: On" if ":" in output: value = output.split(":", 1)[1].strip() @@ -291,13 +294,31 @@ def _get_sleep_settings(self) -> dict[str, str | int | None]: except ValueError: settings[key] = value + # Fallback: extract sleep values from pmset if systemsetup failed + if not settings: + result = run_command(["pmset", "-g"]) + if result is not None and result.returncode == 0: + key_map = { + "sleep": "computer_sleep", + "displaysleep": "display_sleep", + "disksleep": "hard_disk_sleep", + "womp": "wake_on_network", + } + for line in result.stdout.splitlines(): + parts = line.strip().split() + if len(parts) >= 2 and parts[0] in key_map: + try: + settings[key_map[parts[0]]] = int(parts[1]) + except ValueError: + settings[key_map[parts[0]]] = parts[1] + return settings def _get_login_window(self) -> dict[str, Any]: """Read login window preferences.""" plist_path = Path("/Library/Preferences/com.apple.loginwindow.plist") data = read_plist_safe(plist_path) - if data is None: + if not isinstance(data, dict): return {} keys = [ "autoLoginUser", @@ -340,6 +361,25 @@ def _get_network_time(self) -> tuple[bool | None, str | None]: if ":" in output: ntp_server = output.split(":", 1)[1].strip() or None + # Fallback: check if timed service is running (admin-free) + if ntp_enabled is None: + result = run_command(["launchctl", "list", "com.apple.timed"]) + if result is not None: + ntp_enabled = result.returncode == 0 + + # Fallback: read NTP server from ntp.conf + if ntp_server is None: + ntp_conf = Path("/etc/ntp.conf") + if ntp_conf.is_file(): + try: + for line in ntp_conf.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("server "): + ntp_server = stripped.split(None, 1)[1].strip() + break + except OSError: + pass + return ntp_enabled, ntp_server def _get_printers(self) -> list[PrinterInfo]: @@ -428,8 +468,12 @@ def _detect_system_extensions(self) -> list[SystemExtension]: extensions: list[SystemExtension] = [] for raw_line in result.stdout.splitlines(): stripped = raw_line.strip() - if not stripped or stripped.startswith(("---", "*")): + if not stripped or stripped.startswith("---"): + continue + # Header line: "enabled\tactive\tteamID\tbundleID..." + if stripped.startswith("enabled") or stripped.endswith("extension(s)"): continue + # Data lines start with * (enabled/active markers) or contain bundle IDs parts = stripped.split() if len(parts) < 3: continue @@ -444,7 +488,7 @@ def _parse_extension_line(parts: list[str]) -> SystemExtension | None: identifier = None team_id = None version = None - state_parts: list[str] = [] + state_str: str | None = None for part in parts: if "." in part and not part.startswith("(") and not part.endswith(")"): if identifier is None and len(part.split(".")) >= 3: @@ -453,22 +497,22 @@ def _parse_extension_line(parts: list[str]) -> SystemExtension | None: team_id = part elif part.startswith("(") and part.endswith(")"): version = part.strip("()") - elif part in { - "enabled", - "disabled", - "activated_enabled", - "activated_disabled", - }: - state_parts.append(part) elif len(part) == 10 and part.isalnum() and team_id is None: team_id = part + + # Extract state from bracketed section: [activated enabled] + raw = " ".join(parts) + if "[" in raw and "]" in raw: + bracket_content = raw.split("[", 1)[1].split("]", 1)[0].strip() + state_str = bracket_content.replace(" ", "_") if bracket_content else None + if not identifier: return None return SystemExtension( identifier=identifier, team_id=team_id, version=version, - state="_".join(state_parts) if state_parts else None, + state=state_str, ) def _detect_icloud(self) -> ICloudState: diff --git a/src/mac2nix/scanners/version_managers.py b/src/mac2nix/scanners/version_managers.py index 1c3ff0b..3b0e3eb 100644 --- a/src/mac2nix/scanners/version_managers.py +++ b/src/mac2nix/scanners/version_managers.py @@ -225,18 +225,18 @@ def _parse_nvm_versions(nvm_dir: Path) -> list[ManagedRuntime]: def _detect_pyenv(self) -> VersionManagerInfo | None: has_binary = shutil.which("pyenv") is not None - pyenv_root = Path.home() / ".pyenv" - if not has_binary and not pyenv_root.is_dir(): + # Require binary — a leftover ~/.pyenv directory without the binary + # means pyenv is not actually installed/usable. + if not has_binary: return None version: str | None = None - if has_binary: - result = run_command(["pyenv", "--version"]) - if result is not None and result.returncode == 0: - # Output: "pyenv 2.3.36" - parts = result.stdout.strip().split() - version = parts[1] if len(parts) >= 2 else result.stdout.strip() + result = run_command(["pyenv", "--version"]) + if result is not None and result.returncode == 0: + # Output: "pyenv 2.3.36" + parts = result.stdout.strip().split() + version = parts[1] if len(parts) >= 2 else result.stdout.strip() runtimes = self._parse_pyenv_versions(has_binary) @@ -278,18 +278,18 @@ def _parse_pyenv_versions(has_binary: bool) -> list[ManagedRuntime]: def _detect_rbenv(self) -> VersionManagerInfo | None: has_binary = shutil.which("rbenv") is not None - rbenv_root = Path.home() / ".rbenv" - if not has_binary and not rbenv_root.is_dir(): + # Require binary — a leftover ~/.rbenv directory without the binary + # means rbenv is not actually installed/usable. + if not has_binary: return None version: str | None = None - if has_binary: - result = run_command(["rbenv", "--version"]) - if result is not None and result.returncode == 0: - # Output: "rbenv 1.2.0" - parts = result.stdout.strip().split() - version = parts[1] if len(parts) >= 2 else result.stdout.strip() + result = run_command(["rbenv", "--version"]) + if result is not None and result.returncode == 0: + # Output: "rbenv 1.2.0" + parts = result.stdout.strip().split() + version = parts[1] if len(parts) >= 2 else result.stdout.strip() runtimes = self._parse_rbenv_versions(has_binary) @@ -330,9 +330,10 @@ def _parse_rbenv_versions(has_binary: bool) -> list[ManagedRuntime]: def _detect_jenv(self) -> VersionManagerInfo | None: has_binary = shutil.which("jenv") is not None - jenv_root = Path.home() / ".jenv" - if not has_binary and not jenv_root.is_dir(): + # Require binary — a leftover ~/.jenv directory without the binary + # means jenv is not actually installed/usable. + if not has_binary: return None runtimes = self._parse_jenv_versions(has_binary) diff --git a/tests/scanners/test_system_scanner.py b/tests/scanners/test_system_scanner.py index 3d8be1e..131b076 100644 --- a/tests/scanners/test_system_scanner.py +++ b/tests/scanners/test_system_scanner.py @@ -665,8 +665,11 @@ def test_extensions_nonzero_exit(self, cmd_result) -> None: def test_extensions_parsed(self, cmd_result) -> None: ext_output = ( + "1 extension(s)\n" "--- com.apple.system_extension.driver_extension\n" - "enabled\tactive\tABCDEF1234\tcom.crowdstrike.falcon.Agent (6.50.16306)\tactivated_enabled\n" + "enabled\tactive\tteamID\tbundleID (version)\tname\t[state]\n" + "*\t*\tABCDEF1234\tcom.crowdstrike.falcon.Agent (6.50.16306)\t" + "CrowdStrike Falcon\t[activated enabled]\n" ) def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: @@ -695,8 +698,12 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces assert result == [] - def test_extensions_skips_star_lines(self, cmd_result) -> None: - ext_output = "* * some star line\n" + def test_extensions_skips_header_lines(self, cmd_result) -> None: + ext_output = ( + "0 extension(s)\n" + "--- com.apple.system_extension.driver_extension\n" + "enabled\tactive\tteamID\tbundleID (version)\tname\t[state]\n" + ) def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: if cmd == ["systemextensionsctl", "list"]: diff --git a/tests/scanners/test_version_managers.py b/tests/scanners/test_version_managers.py index 9409266..b6ec53f 100644 --- a/tests/scanners/test_version_managers.py +++ b/tests/scanners/test_version_managers.py @@ -372,7 +372,8 @@ def side_effect(cmd, **_kwargs): assert len(active) == 1 assert active[0].version == "3.12.1" - def test_present_via_dir_only(self, tmp_path: Path) -> None: + def test_not_present_with_dir_only(self, tmp_path: Path) -> None: + """A leftover ~/.pyenv directory without the binary means not installed.""" pyenv_dir = tmp_path / ".pyenv" pyenv_dir.mkdir() @@ -382,9 +383,7 @@ def test_present_via_dir_only(self, tmp_path: Path) -> None: ): result = VersionManagersScanner()._detect_pyenv() - assert result is not None - assert result.version is None - assert result.runtimes == [] + assert result is None def test_version_command_fails(self, cmd_result) -> None: def side_effect(cmd, **_kwargs): @@ -448,7 +447,8 @@ def side_effect(cmd, **_kwargs): assert active[0].version == "3.3.0" assert active[0].language == "ruby" - def test_present_via_dir_only(self, tmp_path: Path) -> None: + def test_not_present_with_dir_only(self, tmp_path: Path) -> None: + """A leftover ~/.rbenv directory without the binary means not installed.""" rbenv_dir = tmp_path / ".rbenv" rbenv_dir.mkdir() @@ -458,9 +458,7 @@ def test_present_via_dir_only(self, tmp_path: Path) -> None: ): result = VersionManagersScanner()._detect_rbenv() - assert result is not None - assert result.version is None - assert result.runtimes == [] + assert result is None def test_versions_command_fails(self, cmd_result) -> None: def side_effect(cmd, **_kwargs): @@ -517,7 +515,8 @@ def side_effect(cmd, **_kwargs): assert active[0].version == "21.0.1" assert active[0].language == "java" - def test_present_via_dir_only(self, tmp_path: Path) -> None: + def test_not_present_with_dir_only(self, tmp_path: Path) -> None: + """A leftover ~/.jenv directory without the binary means not installed.""" jenv_dir = tmp_path / ".jenv" jenv_dir.mkdir() @@ -527,8 +526,7 @@ def test_present_via_dir_only(self, tmp_path: Path) -> None: ): result = VersionManagersScanner()._detect_jenv() - assert result is not None - assert result.runtimes == [] + assert result is None def test_versions_command_fails(self) -> None: with ( From d27d9e461846f325d1624972c9e891fca37bd37e Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 12 Mar 2026 12:33:35 -0400 Subject: [PATCH 10/17] fix(scanners): 5 more bugs found on deeper live-system review - network: VPN regex now matches real scutil --nc list format with colons/dots in protocol field and optional service type between UUID and name - network: is_active uses ifconfig status: line instead of UP flag, correctly marks disconnected interfaces as inactive - system: NTP detection uses pgrep timed instead of launchctl user domain check (timed runs in system domain) - shell: sourced files now parsed for aliases/env vars (one level), previously only tracked as file paths without reading content - homebrew: cask versions populated from Caskroom directory structure, previously only queried formula versions via brew list --versions --- src/mac2nix/scanners/homebrew.py | 29 +++++++++++++++++++++++++- src/mac2nix/scanners/network.py | 25 ++++++++++++---------- src/mac2nix/scanners/shell.py | 23 ++++++++++++++++---- src/mac2nix/scanners/system_scanner.py | 4 ++-- tests/scanners/test_homebrew.py | 13 +++++++++--- tests/scanners/test_network.py | 2 ++ 6 files changed, 75 insertions(+), 21 deletions(-) diff --git a/src/mac2nix/scanners/homebrew.py b/src/mac2nix/scanners/homebrew.py index 9a2d007..f9c8db5 100644 --- a/src/mac2nix/scanners/homebrew.py +++ b/src/mac2nix/scanners/homebrew.py @@ -37,7 +37,10 @@ def scan(self) -> HomebrewState: # Enrich with versions from brew list versions = self._get_versions() formulae = [f.model_copy(update={"version": versions.get(f.name, f.version)}) for f in formulae] - casks = [c.model_copy(update={"version": versions.get(c.name, c.version)}) for c in casks] + + # Enrich cask versions from Caskroom directory + cask_versions = self._get_cask_versions() + casks = [c.model_copy(update={"version": cask_versions.get(c.name, c.version)}) for c in casks] # Mark pinned formulae pinned_names = self._get_pinned() @@ -116,6 +119,30 @@ def _get_versions(self) -> dict[str, str]: versions[parts[0]] = parts[-1] return versions + @staticmethod + def _get_cask_versions() -> dict[str, str]: + """Read cask versions from the Caskroom directory structure.""" + versions: dict[str, str] = {} + for caskroom in [Path("/opt/homebrew/Caskroom"), Path("/usr/local/Caskroom")]: + if not caskroom.is_dir(): + continue + try: + for cask_dir in caskroom.iterdir(): + if not cask_dir.is_dir(): + continue + # Each cask has version subdirectories; use the latest one + try: + version_dirs = sorted( + (d.name for d in cask_dir.iterdir() if d.is_dir() and d.name != ".metadata"), + ) + if version_dirs: + versions[cask_dir.name] = version_dirs[-1] + except PermissionError: + pass + except PermissionError: + pass + return versions + def _get_pinned(self) -> set[str]: """Get set of pinned formula names.""" result = run_command(["brew", "list", "--pinned"]) diff --git a/src/mac2nix/scanners/network.py b/src/mac2nix/scanners/network.py index 3342b27..38f855c 100644 --- a/src/mac2nix/scanners/network.py +++ b/src/mac2nix/scanners/network.py @@ -12,8 +12,6 @@ logger = logging.getLogger(__name__) -_FLAGS_PATTERN = re.compile(r"flags=\w+<([^>]*)>") - @register("network") class NetworkScanner(BaseScannerPlugin): @@ -99,13 +97,15 @@ def _parse_ifconfig(self) -> tuple[dict[str, str], dict[str, str], dict[str, boo for raw_line in result.stdout.splitlines(): if raw_line and not raw_line[0].isspace() and ":" in raw_line: current_iface = raw_line.split(":")[0] - # Parse flags for UP status - flags_match = _FLAGS_PATTERN.search(raw_line) - if flags_match: - flags = flags_match.group(1) - active_map[current_iface] = "UP" in flags.split(",") + # Default to False; will be updated by "status:" line + active_map[current_iface] = False elif current_iface: - if "inet " in raw_line: + stripped = raw_line.strip() + if stripped.startswith("status:"): + # "status: active" means link is up; "status: inactive" means no link + status_value = stripped.split(":", 1)[1].strip() + active_map[current_iface] = status_value == "active" + elif "inet " in raw_line: match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", raw_line) if match and match.group(1) != "127.0.0.1": ip_map[current_iface] = match.group(1) @@ -235,16 +235,19 @@ def _get_vpn_profiles(self) -> list[VpnProfile]: return [] profiles: list[VpnProfile] = [] - # Lines like: * (Connected) UUID "VPN Name" [IPSec] - vpn_pattern = re.compile(r'^\*\s+\((\w+)\)\s+\S+\s+"([^"]+)"\s+\[(\w+)\]') + # Lines like: + # * (Disconnected) UUID VPN (com.ubnt.wifiman) "Name" [VPN:com.ubnt.wifiman] + # * (Connected) UUID PPP --> DeviceName "Name" [PPP:Modem] + vpn_pattern = re.compile(r'^\*\s+\((\w+)\)\s+\S+\s+.*?"([^"]+)"\s+\[([^\]]+)\]') for line in result.stdout.splitlines(): match = vpn_pattern.match(line.strip()) if match: + protocol = match.group(3).split(":")[0] # "VPN:com.foo" → "VPN" profiles.append( VpnProfile( name=match.group(2), status=match.group(1), - protocol=match.group(3), + protocol=protocol, ) ) return profiles diff --git a/src/mac2nix/scanners/shell.py b/src/mac2nix/scanners/shell.py index 89ec647..c518ad8 100644 --- a/src/mac2nix/scanners/shell.py +++ b/src/mac2nix/scanners/shell.py @@ -211,18 +211,18 @@ def _check_source_posix(self, line: str, parsed: _ParsedShellData, home: Path, s match = _SOURCE_PATTERN.match(line) if not match: return - self._resolve_and_track_source(match.group(1).strip("'\""), parsed, home, seen_files) + self._resolve_and_track_source(match.group(1).strip("'\""), parsed, home, seen_files, shell_type="bash") def _check_source_fish(self, line: str, parsed: _ParsedShellData, home: Path, seen_files: set[Path]) -> None: match = _FISH_SOURCE_PATTERN.match(line) if not match: return - self._resolve_and_track_source(match.group(1).strip("'\""), parsed, home, seen_files) + self._resolve_and_track_source(match.group(1).strip("'\""), parsed, home, seen_files, shell_type="fish") def _resolve_and_track_source( - self, raw_path: str, parsed: _ParsedShellData, home: Path, seen_files: set[Path] + self, raw_path: str, parsed: _ParsedShellData, home: Path, seen_files: set[Path], shell_type: str = "fish" ) -> None: - """Resolve a sourced file path and add to sourced_files (one level only).""" + """Resolve a sourced file path, add to sourced_files, and parse it (one level only).""" # Expand ~ and $HOME resolved_str = raw_path.replace("$HOME", str(home)).replace("~", str(home)) try: @@ -238,6 +238,21 @@ def _resolve_and_track_source( seen_files.add(resolved) parsed.sourced_files.append(resolved) + # Parse the sourced file for aliases/env vars (one level — no recursive sourcing) + try: + content = resolved.read_text() + except (PermissionError, OSError): + return + + for raw_line in content.splitlines(): + stripped = raw_line.strip() + if not stripped or stripped.startswith("#"): + continue + if shell_type == "fish": + self._parse_fish_line(stripped, parsed) + else: + self._parse_posix_line(stripped, parsed) + def _parse_fish_line(self, line: str, parsed: _ParsedShellData) -> None: match = _FISH_ALIAS_PATTERN.match(line) if match: diff --git a/src/mac2nix/scanners/system_scanner.py b/src/mac2nix/scanners/system_scanner.py index 731fc34..4abbdac 100644 --- a/src/mac2nix/scanners/system_scanner.py +++ b/src/mac2nix/scanners/system_scanner.py @@ -361,9 +361,9 @@ def _get_network_time(self) -> tuple[bool | None, str | None]: if ":" in output: ntp_server = output.split(":", 1)[1].strip() or None - # Fallback: check if timed service is running (admin-free) + # Fallback: check if timed process is running (admin-free) if ntp_enabled is None: - result = run_command(["launchctl", "list", "com.apple.timed"]) + result = run_command(["pgrep", "-x", "timed"]) if result is not None: ntp_enabled = result.returncode == 0 diff --git a/tests/scanners/test_homebrew.py b/tests/scanners/test_homebrew.py index 30087dd..10c748a 100644 --- a/tests/scanners/test_homebrew.py +++ b/tests/scanners/test_homebrew.py @@ -95,9 +95,16 @@ def test_parses_mas_apps(self, cmd_result) -> None: assert result.mas_apps[0].app_id == 409183694 def test_version_enrichment(self, cmd_result) -> None: - with patch( - "mac2nix.scanners.homebrew.run_command", - side_effect=self._scan_side_effects(cmd_result), + with ( + patch( + "mac2nix.scanners.homebrew.run_command", + side_effect=self._scan_side_effects(cmd_result), + ), + patch.object( + HomebrewScanner, + "_get_cask_versions", + return_value={"firefox": "124.0", "iterm2": "3.5.0"}, + ), ): result = HomebrewScanner().scan() diff --git a/tests/scanners/test_network.py b/tests/scanners/test_network.py index f0acebc..43197c4 100644 --- a/tests/scanners/test_network.py +++ b/tests/scanners/test_network.py @@ -233,7 +233,9 @@ def test_interface_active_status(self, cmd_result) -> None: ifconfig_mixed = ( "en0: flags=8863 mtu 1500\n" "\tinet 192.168.1.42 netmask 0xffffff00\n" + "\tstatus: active\n" "en1: flags=8822 mtu 1500\n" + "\tstatus: inactive\n" ) responses = { ("networksetup", "-listallhardwareports"): cmd_result(_HARDWARE_PORTS), From d9303e9e36f478dedde7afb0f5cfd45624652e1c Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 12 Mar 2026 12:35:59 -0400 Subject: [PATCH 11/17] fix(scanners): iOS wrapper app detection, touch_id_sudo false positive - applications: check Wrapper/App.app/Info.plist for iOS apps that lack Contents/Info.plist (Apollo, Authy, WiFiman now have versions) - security: touch_id_sudo returns False (not None) when pam files were readable but pam_tid.so was not configured --- src/mac2nix/scanners/applications.py | 8 ++++++++ src/mac2nix/scanners/security.py | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/mac2nix/scanners/applications.py b/src/mac2nix/scanners/applications.py index 3b1ee38..f079348 100644 --- a/src/mac2nix/scanners/applications.py +++ b/src/mac2nix/scanners/applications.py @@ -80,6 +80,14 @@ def scan(self) -> ApplicationsResult: bundle_id: str | None = None version: str | None = None + # Also check iOS wrapper apps (Wrapper/App.app/Info.plist) + if not info_plist.exists(): + wrapper_dir = app_path / "Wrapper" + if wrapper_dir.is_dir(): + for inner in wrapper_dir.glob("*.app"): + info_plist = inner / "Info.plist" + break + if info_plist.exists(): data = read_plist_safe(info_plist) if isinstance(data, dict): diff --git a/src/mac2nix/scanners/security.py b/src/mac2nix/scanners/security.py index 6a007a7..15b8857 100644 --- a/src/mac2nix/scanners/security.py +++ b/src/mac2nix/scanners/security.py @@ -112,9 +112,11 @@ def _get_firewall_app_rules(self) -> list[FirewallAppRule]: def _check_touch_id_sudo(self) -> bool | None: """Check if Touch ID is configured for sudo.""" + checked_any = False for sudo_file in [Path("/etc/pam.d/sudo_local"), Path("/etc/pam.d/sudo")]: try: content = sudo_file.read_text() + checked_any = True for line in content.splitlines(): stripped = line.strip() if stripped.startswith("#"): @@ -123,7 +125,7 @@ def _check_touch_id_sudo(self) -> bool | None: return True except (PermissionError, OSError): continue - return None + return False if checked_any else None def _get_tcc_summary(self) -> dict[str, list[str]]: tcc_path = Path.home() / "Library" / "Application Support" / "com.apple.TCC" / "TCC.db" From e4ceb9f3b592d0bbb1cb80b09f8facce1e78bf6f Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 12 Mar 2026 13:08:23 -0400 Subject: [PATCH 12/17] refactor(scanners): removes TCC summary field TCC permissions require Full Disk Access to read and cannot be set via nix-darwin configuration. The field was always empty in practice and provided no actionable data for config generation. --- src/mac2nix/models/system.py | 1 - src/mac2nix/scanners/security.py | 22 ------------ tests/models/test_remaining.py | 8 ++--- tests/scanners/test_security.py | 62 -------------------------------- 4 files changed, 2 insertions(+), 91 deletions(-) diff --git a/src/mac2nix/models/system.py b/src/mac2nix/models/system.py index 5d6d663..7d1fd77 100644 --- a/src/mac2nix/models/system.py +++ b/src/mac2nix/models/system.py @@ -46,7 +46,6 @@ class SecurityState(BaseModel): sip_enabled: bool | None = None firewall_enabled: bool | None = None gatekeeper_enabled: bool | None = None - tcc_summary: dict[str, list[str]] = {} # service -> list of allowed apps firewall_stealth_mode: bool | None = None firewall_app_rules: list[FirewallAppRule] = [] firewall_block_all_incoming: bool | None = None diff --git a/src/mac2nix/scanners/security.py b/src/mac2nix/scanners/security.py index 15b8857..b14e00b 100644 --- a/src/mac2nix/scanners/security.py +++ b/src/mac2nix/scanners/security.py @@ -4,7 +4,6 @@ import logging import re -import sqlite3 from pathlib import Path from mac2nix.models.system import FirewallAppRule, SecurityState @@ -28,7 +27,6 @@ def scan(self) -> SecurityState: sip_enabled=self._check_sip(), gatekeeper_enabled=self._check_gatekeeper(), firewall_enabled=self._check_firewall(), - tcc_summary=self._get_tcc_summary(), firewall_stealth_mode=self._check_firewall_stealth(), firewall_app_rules=self._get_firewall_app_rules(), firewall_block_all_incoming=self._check_firewall_block_all(), @@ -127,26 +125,6 @@ def _check_touch_id_sudo(self) -> bool | None: continue return False if checked_any else None - def _get_tcc_summary(self) -> dict[str, list[str]]: - tcc_path = Path.home() / "Library" / "Application Support" / "com.apple.TCC" / "TCC.db" - if not tcc_path.exists(): - return {} - - try: - conn = sqlite3.connect(f"file:{tcc_path}?mode=ro&immutable=1", uri=True) - try: - cursor = conn.execute("SELECT service, client FROM access WHERE auth_value = 2") - summary: dict[str, list[str]] = {} - for service, client in cursor.fetchall(): - summary.setdefault(service, []).append(client) - return summary - finally: - conn.close() - except (sqlite3.OperationalError, sqlite3.DatabaseError) as exc: - # TCC.db is SIP-protected on most macOS versions — expected failure - logger.debug("Failed to read TCC database: %s", exc) - return {} - def _get_custom_certificates(self) -> list[str]: """Discover custom/corporate certificates in System keychain.""" result = run_command(["security", "find-certificate", "-a", "/Library/Keychains/System.keychain"]) diff --git a/tests/models/test_remaining.py b/tests/models/test_remaining.py index ea9711c..71d0b8a 100644 --- a/tests/models/test_remaining.py +++ b/tests/models/test_remaining.py @@ -125,20 +125,16 @@ def test_with_interfaces_and_dns(self) -> None: class TestSecurityState: - def test_with_tcc_summary(self) -> None: + def test_security_state_fields(self) -> None: state = SecurityState( filevault_enabled=True, sip_enabled=True, firewall_enabled=False, gatekeeper_enabled=True, - tcc_summary={ - "kTCCServiceAccessibility": ["iTerm2", "Hammerspoon"], - "kTCCServiceCamera": ["zoom.us"], - }, ) assert state.filevault_enabled is True assert state.firewall_enabled is False - assert len(state.tcc_summary["kTCCServiceAccessibility"]) == 2 + assert state.gatekeeper_enabled is True class TestSystemConfig: diff --git a/tests/scanners/test_security.py b/tests/scanners/test_security.py index c47e637..381872f 100644 --- a/tests/scanners/test_security.py +++ b/tests/scanners/test_security.py @@ -1,6 +1,5 @@ """Tests for security scanner.""" -import sqlite3 import subprocess from pathlib import Path from unittest.mock import patch @@ -103,47 +102,6 @@ def test_command_fails(self) -> None: assert result.sip_enabled is None assert result.gatekeeper_enabled is None - def test_tcc_inaccessible(self) -> None: - with ( - patch("mac2nix.scanners.security.run_command", return_value=None), - patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), - ): - result = SecurityScanner().scan() - - assert isinstance(result, SecurityState) - assert result.tcc_summary == {} - - def test_tcc_happy_path(self) -> None: - tcc_rows = [ - ("kTCCServiceAccessibility", "com.example.app1"), - ("kTCCServiceAccessibility", "com.example.app2"), - ("kTCCServiceCamera", "com.example.cam"), - ] - mock_cursor = type("MockCursor", (), {"fetchall": lambda _self: tcc_rows})() - mock_conn = type( - "MockConn", - (), - { - "execute": lambda _self, _query: mock_cursor, - "close": lambda _self: None, - }, - )() - - with ( - patch("mac2nix.scanners.security.run_command", return_value=None), - patch("mac2nix.scanners.security.Path.home", return_value=Path("/Users/testuser")), - patch("mac2nix.scanners.security.Path.exists", return_value=True), - patch("mac2nix.scanners.security.sqlite3.connect", return_value=mock_conn), - ): - result = SecurityScanner().scan() - - assert isinstance(result, SecurityState) - assert "kTCCServiceAccessibility" in result.tcc_summary - assert len(result.tcc_summary["kTCCServiceAccessibility"]) == 2 - assert "com.example.app1" in result.tcc_summary["kTCCServiceAccessibility"] - assert "kTCCServiceCamera" in result.tcc_summary - assert result.tcc_summary["kTCCServiceCamera"] == ["com.example.cam"] - def test_returns_security_state(self) -> None: with ( patch("mac2nix.scanners.security.run_command", return_value=None), @@ -343,23 +301,3 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces assert isinstance(result, SecurityState) assert result.custom_certificates == [] - - def test_tcc_database_corrupted(self, cmd_result) -> None: - def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: - if cmd[0] == "fdesetup": - return cmd_result("FileVault is Off.") - return None - - with ( - patch("mac2nix.scanners.security.run_command", side_effect=side_effect), - patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), - patch("mac2nix.scanners.security.Path.exists", return_value=True), - patch( - "mac2nix.scanners.security.sqlite3.connect", - side_effect=sqlite3.OperationalError("database is malformed"), - ), - ): - result = SecurityScanner().scan() - - assert isinstance(result, SecurityState) - assert result.tcc_summary == {} From a195dde7fa325f34d54adc5ea8ba8482c2616993 Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 12 Mar 2026 15:40:15 -0400 Subject: [PATCH 13/17] style(scanners): formats display.py --- src/mac2nix/scanners/display.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/mac2nix/scanners/display.py b/src/mac2nix/scanners/display.py index 43b9a4c..3316480 100644 --- a/src/mac2nix/scanners/display.py +++ b/src/mac2nix/scanners/display.py @@ -63,11 +63,7 @@ def _parse_monitor(self, display: dict[str, object]) -> Monitor: resolution_str = str(resolution) if resolution is not None else None display_type = str(display.get("spdisplays_display_type", "")) pixel_res = str(display.get("spdisplays_pixelresolution", "")) - retina = ( - "retina" in pixel_res.lower() - or "retina" in display_type.lower() - or "Retina" in (resolution_str or "") - ) + retina = "retina" in pixel_res.lower() or "retina" in display_type.lower() or "Retina" in (resolution_str or "") arrangement = None if display.get("spdisplays_main") == "spdisplays_yes": From 3bb12d26e2651bbe63c0c715cbcb606911469523 Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 12 Mar 2026 15:45:33 -0400 Subject: [PATCH 14/17] fix(ci): prek files regex double-escaped, hooks never matched .py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TOML single-quoted strings are literal — '\\.py$' produced the regex \\.py$ (literal backslash + any char + py) instead of \.py$ (dot + py). The Python lint and format hooks have been silently skipping since the project was created. --- prek.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/prek.toml b/prek.toml index 724e9a1..fb04e4e 100644 --- a/prek.toml +++ b/prek.toml @@ -63,7 +63,7 @@ hooks = [ name = "Python lint (ruff check)", entry = "uv run ruff check src/ tests/", language = "system", - files = '\\.py$', + files = '\.py$', pass_filenames = false, priority = 0 }, @@ -72,7 +72,7 @@ hooks = [ name = "Python format check (ruff format)", entry = "uv run ruff format --check src/ tests/", language = "system", - files = '\\.py$', + files = '\.py$', pass_filenames = false, priority = 0 } From 512a8135a2209b7cf48a5dc68207edb8a2bc0c21 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sat, 14 Mar 2026 11:36:58 -0400 Subject: [PATCH 15/17] feat(cli): adds scan command with orchestrator --- pyproject.toml | 1 + src/mac2nix/cli.py | 87 +++- src/mac2nix/orchestrator.py | 219 ++++++++++ src/mac2nix/scanners/_utils.py | 47 +++ src/mac2nix/scanners/audio.py | 31 +- src/mac2nix/scanners/cron.py | 15 +- src/mac2nix/scanners/display.py | 32 +- src/mac2nix/scanners/launch_agents.py | 40 +- src/mac2nix/scanners/system_scanner.py | 21 +- tests/scanners/test_prefetch_injection.py | 488 ++++++++++++++++++++++ tests/test_cli.py | 268 ++++++++++++ tests/test_orchestrator.py | 442 ++++++++++++++++++++ uv.lock | 36 ++ 13 files changed, 1684 insertions(+), 43 deletions(-) create mode 100644 src/mac2nix/orchestrator.py create mode 100644 tests/scanners/test_prefetch_injection.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_orchestrator.py diff --git a/pyproject.toml b/pyproject.toml index 728a161..51b5ce6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "pydantic>=2.0", "jinja2>=3.1", "pyyaml>=6.0", + "rich>=13.0", ] [project.scripts] diff --git a/src/mac2nix/cli.py b/src/mac2nix/cli.py index 2b02c67..7276f22 100644 --- a/src/mac2nix/cli.py +++ b/src/mac2nix/cli.py @@ -1,6 +1,17 @@ """mac2nix CLI.""" +from __future__ import annotations + +import asyncio +import time +from pathlib import Path + import click +from rich.console import Console +from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn + +from mac2nix.orchestrator import run_scan +from mac2nix.scanners import get_all_scanners @click.group() @@ -10,9 +21,81 @@ def main() -> None: @main.command() -def scan() -> None: +@click.option( + "--output", + "-o", + type=click.Path(path_type=Path), + default=None, + help="Write JSON output to FILE instead of stdout.", + metavar="FILE", +) +@click.option( + "--scanner", + "-s", + "selected_scanners", + multiple=True, + help="Run only this scanner (repeatable). Defaults to all scanners.", + metavar="NAME", +) +def scan(output: Path | None, selected_scanners: tuple[str, ...]) -> None: """Scan the current macOS system state.""" - click.echo("scan: not yet implemented") + all_names = list(get_all_scanners().keys()) + scanners: list[str] | None = list(selected_scanners) if selected_scanners else None + + # Validate any explicitly requested scanner names + if scanners is not None: + unknown = [s for s in scanners if s not in all_names] + if unknown: + 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) + + completed: int = 0 + 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") + + try: + state = asyncio.run(run_scan(scanners=scanners, progress_callback=progress_callback)) + except RuntimeError as e: + raise click.ClickException(str(e)) from e + + elapsed = time.monotonic() - start + scanner_count = completed + + json_output = state.to_json() + + 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, + ) + else: + click.echo( + f"Scanned {scanner_count} scanner(s) in {elapsed:.1f}s", + err=True, + ) + click.echo(json_output) @main.command() diff --git a/src/mac2nix/orchestrator.py b/src/mac2nix/orchestrator.py new file mode 100644 index 0000000..85f4d1b --- /dev/null +++ b/src/mac2nix/orchestrator.py @@ -0,0 +1,219 @@ +"""Async scan orchestrator — dispatches all scanners concurrently.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import platform +import shutil +import socket +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from pydantic import BaseModel + +from mac2nix.models.system_state import SystemState +from mac2nix.scanners import get_all_scanners +from mac2nix.scanners._utils import read_launchd_plists, run_command +from mac2nix.scanners.audio import AudioScanner +from mac2nix.scanners.cron import CronScanner +from mac2nix.scanners.display import DisplayScanner +from mac2nix.scanners.launch_agents import LaunchAgentsScanner +from mac2nix.scanners.system_scanner import SystemScanner + +logger = logging.getLogger(__name__) + +# Scanners that share pre-fetched data — excluded from generic dispatch. +_PREFETCH_SCANNERS = frozenset({"display", "audio", "system", "launch_agents", "cron"}) + + +def _get_system_metadata() -> tuple[str, str, str]: + """Return (hostname, macos_version, architecture).""" + hostname = socket.gethostname() + architecture = platform.machine() # arm64 or x86_64 + + # Try sw_vers for macOS version string + result = run_command(["sw_vers", "-productVersion"]) + if result is not None and result.returncode == 0: + macos_version = result.stdout.strip() + else: + macos_version = platform.mac_ver()[0] or "unknown" + + return hostname, macos_version, architecture + + +def _fetch_system_profiler_batch() -> dict[str, Any]: + """Run a single batched system_profiler call for display, audio, and hardware data. + + Returns the parsed JSON dict with ``SPDisplaysDataType``, + ``SPAudioDataType``, and ``SPHardwareDataType`` as top-level keys. + Returns an empty dict on failure. + """ + if shutil.which("system_profiler") is None: + logger.warning("system_profiler not found — display/audio/hardware data unavailable") + return {} + + result = run_command( + ["system_profiler", "SPDisplaysDataType", "SPAudioDataType", "SPHardwareDataType", "-json"], + timeout=20, + ) + if result is None or result.returncode != 0: + logger.warning("Batched system_profiler call failed") + return {} + + try: + return json.loads(result.stdout) # type: ignore[no-any-return] + except (json.JSONDecodeError, ValueError): + logger.warning("Failed to parse batched system_profiler output") + return {} + + +async def _run_scanner_async( + scanner_name: str, + scanner_cls: type, + kwargs: dict[str, Any], + progress_callback: Callable[[str], None] | None, +) -> tuple[str, BaseModel | None]: + """Dispatch a single scanner in a thread and return (name, result).""" + 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 + finally: + if progress_callback is not None: + progress_callback(scanner_name) + + +async def run_scan( + scanners: list[str] | None = None, + progress_callback: Callable[[str], None] | 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. + + Returns: + Populated :class:`~mac2nix.models.system_state.SystemState`. + """ + hostname, macos_version, architecture = _get_system_metadata() + + all_registered = get_all_scanners() + if scanners is not None: + selected = {name: cls for name, cls in all_registered.items() if name in scanners} + else: + selected = dict(all_registered) + + need_sp = "display" in selected or "audio" in selected or "system" in selected + need_launchd = "launch_agents" in selected or "cron" in selected + + # --- Dispatch independent scanners immediately --- + # These 14 scanners need no pre-fetched data; start them now. + tasks: list[asyncio.Task[tuple[str, BaseModel | None]]] = [] + for name, cls in selected.items(): + if name in _PREFETCH_SCANNERS: + continue + tasks.append( + asyncio.create_task( + _run_scanner_async(name, cls, {}, progress_callback), + name=f"scanner-{name}", + ) + ) + + # --- Run pre-fetches concurrently while independent scanners are running --- + batched_sp: dict[str, Any] + launchd_plists: list[tuple[Path, str, dict[str, Any]]] | None + batched_sp, launchd_plists = await asyncio.gather( + asyncio.to_thread(_fetch_system_profiler_batch) if need_sp else asyncio.sleep(0, result={}), + asyncio.to_thread(read_launchd_plists) if need_launchd else asyncio.sleep(0, result=None), + ) + + # --- Dispatch prefetch-dependent scanners --- + if "display" in selected: + tasks.append( + asyncio.create_task( + _run_scanner_async( + "display", + DisplayScanner, + {"prefetched_data": batched_sp}, + progress_callback, + ), + name="scanner-display", + ) + ) + if "audio" in selected: + tasks.append( + asyncio.create_task( + _run_scanner_async( + "audio", + AudioScanner, + {"prefetched_data": batched_sp}, + progress_callback, + ), + name="scanner-audio", + ) + ) + if "system" in selected: + tasks.append( + asyncio.create_task( + _run_scanner_async( + "system", + SystemScanner, + {"prefetched_data": batched_sp}, + progress_callback, + ), + name="scanner-system", + ) + ) + if "launch_agents" in selected: + tasks.append( + asyncio.create_task( + _run_scanner_async( + "launch_agents", + LaunchAgentsScanner, + {"launchd_plists": launchd_plists}, + progress_callback, + ), + name="scanner-launch_agents", + ) + ) + if "cron" in selected: + tasks.append( + asyncio.create_task( + _run_scanner_async( + "cron", + CronScanner, + {"launchd_plists": launchd_plists}, + progress_callback, + ), + name="scanner-cron", + ) + ) + + results = await asyncio.gather(*tasks, return_exceptions=False) + + # --- Assemble SystemState --- + domain_results: dict[str, BaseModel | None] = {} + for scanner_name, result in results: + domain_results[scanner_name] = result + + return SystemState( + hostname=hostname, + macos_version=macos_version, + architecture=architecture, + **{k: v for k, v in domain_results.items() if v is not None}, # type: ignore[arg-type] + ) diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index 2816656..72f7a94 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -15,6 +15,53 @@ logger = logging.getLogger(__name__) +WALK_SKIP_DIRS = frozenset( + { + # Caches & transient data + "Caches", + "Cache", + "cache", + ".cache", + "GPUCache", + "ShaderCache", + "Code Cache", + "CachedData", + "Service Worker", + "blob_storage", + "IndexedDB", + "GrShaderCache", + "component_crx_cache", + # Logs + "Logs", + "logs", + "log", + # VCS + ".git", + ".svn", + ".hg", + # Build artifacts & dependency trees + "node_modules", + "__pycache__", + ".tox", + ".nox", + "DerivedData", + "Build", + ".build", + "build", + "target", + "dist", + ".next", + ".nuxt", + # Temp + "tmp", + "temp", + ".tmp", + # Trash + ".Trash", + ".Trashes", + } +) + LAUNCHD_DIRS: list[tuple[Path, str]] = [ (Path.home() / "Library" / "LaunchAgents", "user"), (Path("/Library/LaunchAgents"), "system"), diff --git a/src/mac2nix/scanners/audio.py b/src/mac2nix/scanners/audio.py index 729ea00..8cab0af 100644 --- a/src/mac2nix/scanners/audio.py +++ b/src/mac2nix/scanners/audio.py @@ -5,6 +5,7 @@ import json import logging import shutil +from typing import Any from mac2nix.models.hardware import AudioConfig, AudioDevice from mac2nix.scanners._utils import run_command @@ -29,6 +30,17 @@ def _parse_float(value: str) -> float | None: @register("audio") class AudioScanner(BaseScannerPlugin): + def __init__(self, prefetched_data: dict[str, Any] | None = None) -> None: + """Initialise the audio scanner. + + Args: + prefetched_data: Pre-parsed JSON dict from a batched system_profiler call. + When provided, the scanner skips its own system_profiler invocation. + Must contain the ``SPAudioDataType`` key. Defaults to ``None`` + (the scanner fetches data itself). + """ + self._prefetched_data = prefetched_data + @property def name(self) -> str: return "audio" @@ -51,17 +63,24 @@ def scan(self) -> AudioConfig: output_muted=output_muted, ) - def _get_audio_devices( - self, - ) -> tuple[list[AudioDevice], list[AudioDevice], str | None, str | None]: + def _load_audio_data(self) -> dict[str, Any] | None: + """Return parsed SPAudio JSON, from prefetch or a fresh subprocess call.""" + if self._prefetched_data is not None: + return self._prefetched_data result = run_command(["system_profiler", "SPAudioDataType", "-json"], timeout=15) if result is None or result.returncode != 0: - return [], [], None, None - + return None try: - data = json.loads(result.stdout) + return json.loads(result.stdout) # type: ignore[no-any-return] except (json.JSONDecodeError, ValueError): logger.warning("Failed to parse system_profiler audio output") + return None + + def _get_audio_devices( + self, + ) -> tuple[list[AudioDevice], list[AudioDevice], str | None, str | None]: + data = self._load_audio_data() + if data is None: return [], [], None, None input_devices: list[AudioDevice] = [] diff --git a/src/mac2nix/scanners/cron.py b/src/mac2nix/scanners/cron.py index 1aaf8c7..7677b7d 100644 --- a/src/mac2nix/scanners/cron.py +++ b/src/mac2nix/scanners/cron.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from pathlib import Path +from typing import Any from mac2nix.models.services import CronEntry, LaunchdScheduledJob, ScheduledTasks from mac2nix.scanners._utils import read_launchd_plists, run_command @@ -13,6 +15,16 @@ @register("cron") class CronScanner(BaseScannerPlugin): + def __init__(self, launchd_plists: list[tuple[Path, str, dict[str, Any]]] | None = None) -> None: + """Initialise the cron scanner. + + Args: + launchd_plists: Pre-computed launchd plist tuples as returned by + ``read_launchd_plists()``. When provided, the scanner skips its own + disk read. Defaults to ``None`` (the scanner reads plists itself). + """ + self._launchd_plists = launchd_plists + @property def name(self) -> str: return "cron" @@ -68,8 +80,9 @@ def _get_cron_entries(self) -> tuple[list[CronEntry], dict[str, str]]: def _get_launchd_scheduled(self) -> list[LaunchdScheduledJob]: """Find launchd plists with scheduling keys.""" + plists = self._launchd_plists if self._launchd_plists is not None else read_launchd_plists() jobs: list[LaunchdScheduledJob] = [] - for _plist_path, _source_key, data in read_launchd_plists(): + for _plist_path, _source_key, data in plists: label = data.get("Label") if not label: continue diff --git a/src/mac2nix/scanners/display.py b/src/mac2nix/scanners/display.py index 3316480..a93d1f3 100644 --- a/src/mac2nix/scanners/display.py +++ b/src/mac2nix/scanners/display.py @@ -18,6 +18,17 @@ @register("display") class DisplayScanner(BaseScannerPlugin): + def __init__(self, prefetched_data: dict[str, Any] | None = None) -> None: + """Initialise the display scanner. + + Args: + prefetched_data: Pre-parsed JSON dict from a batched system_profiler call. + When provided, the scanner skips its own system_profiler invocation. + Must contain the ``SPDisplaysDataType`` key. Defaults to ``None`` + (the scanner fetches data itself). + """ + self._prefetched_data = prefetched_data + @property def name(self) -> str: return "display" @@ -26,15 +37,18 @@ def is_available(self) -> bool: return shutil.which("system_profiler") is not None def scan(self) -> DisplayConfig: - result = run_command(["system_profiler", "SPDisplaysDataType", "-json"], timeout=15) - if result is None or result.returncode != 0: - return DisplayConfig() - - try: - data = json.loads(result.stdout) - except (json.JSONDecodeError, ValueError): - logger.warning("Failed to parse system_profiler display output") - return DisplayConfig() + if self._prefetched_data is not None: + data = self._prefetched_data + else: + result = run_command(["system_profiler", "SPDisplaysDataType", "-json"], timeout=15) + if result is None or result.returncode != 0: + return DisplayConfig() + + try: + data = json.loads(result.stdout) + except (json.JSONDecodeError, ValueError): + logger.warning("Failed to parse system_profiler display output") + return DisplayConfig() monitors: list[Monitor] = [] gpu_list = data.get("SPDisplaysDataType", []) diff --git a/src/mac2nix/scanners/launch_agents.py b/src/mac2nix/scanners/launch_agents.py index f13e0a3..2fca092 100644 --- a/src/mac2nix/scanners/launch_agents.py +++ b/src/mac2nix/scanners/launch_agents.py @@ -2,7 +2,6 @@ from __future__ import annotations -import copy import logging import os import re @@ -28,6 +27,16 @@ @register("launch_agents") class LaunchAgentsScanner(BaseScannerPlugin): + def __init__(self, launchd_plists: list[tuple[Path, str, dict[str, Any]]] | None = None) -> None: + """Initialise the launch agents scanner. + + Args: + launchd_plists: Pre-computed launchd plist tuples as returned by + ``read_launchd_plists()``. When provided, the scanner skips its own + disk read. Defaults to ``None`` (the scanner reads plists itself). + """ + self._launchd_plists = launchd_plists + @property def name(self) -> str: return "launch_agents" @@ -35,8 +44,9 @@ def name(self) -> str: def scan(self) -> LaunchAgentsResult: entries: list[LaunchAgentEntry] = [] + plists = self._launchd_plists if self._launchd_plists is not None else read_launchd_plists() # Scan plist directories using shared reader - for plist_path, source_key, data in read_launchd_plists(): + for plist_path, source_key, data in plists: source = _SOURCE_MAP[source_key] entry = self._parse_agent_data(plist_path, source, data) if entry is not None: @@ -63,10 +73,16 @@ def _parse_agent_data( program_arguments = data.get("ProgramArguments", []) run_at_load = data.get("RunAtLoad", False) - # Deep copy data for raw_plist to avoid mutating the shared cache - raw_plist = copy.deepcopy(data) - # Redact sensitive environment variables in raw_plist - self._redact_sensitive_env(raw_plist) + # Shallow copy + replace env vars to avoid mutating the shared prefetch data + raw_plist = dict(data) + env_raw = data.get("EnvironmentVariables") + if isinstance(env_raw, dict): + redacted = { + k: "***REDACTED***" if any(p in k.upper() for p in _SENSITIVE_ENV_PATTERNS) else v + for k, v in env_raw.items() + } + if redacted != env_raw: + raw_plist["EnvironmentVariables"] = redacted # Extract filtered environment variables env_vars = data.get("EnvironmentVariables") @@ -103,23 +119,13 @@ def _parse_agent_data( group_name=data.get("GroupName"), ) - @staticmethod - def _redact_sensitive_env(plist: dict[str, Any]) -> None: - """Redact sensitive keys from EnvironmentVariables in the plist dict.""" - env_vars = plist.get("EnvironmentVariables") - if not isinstance(env_vars, dict): - return - for key in list(env_vars.keys()): - if any(p in key.upper() for p in _SENSITIVE_ENV_PATTERNS): - env_vars[key] = "***REDACTED***" - def _get_login_items(self) -> list[LaunchAgentEntry]: """Parse login items from sfltool dumpbtm text output. Filters to the current user's UID section and extracts items with type "login item". """ - result = run_command(["sfltool", "dumpbtm"]) + result = run_command(["sfltool", "dumpbtm"], timeout=15) if result is None or result.returncode != 0: return [] diff --git a/src/mac2nix/scanners/system_scanner.py b/src/mac2nix/scanners/system_scanner.py index 4abbdac..d3e0dc5 100644 --- a/src/mac2nix/scanners/system_scanner.py +++ b/src/mac2nix/scanners/system_scanner.py @@ -26,6 +26,9 @@ @register("system") class SystemScanner(BaseScannerPlugin): + def __init__(self, prefetched_data: dict[str, Any] | None = None) -> None: + self._prefetched_data = prefetched_data + @property def name(self) -> str: return "system" @@ -177,14 +180,16 @@ def _get_hardware_info( self, ) -> tuple[str | None, str | None, str | None, str | None]: """Parse system_profiler SPHardwareDataType for hardware info.""" - result = run_command(["system_profiler", "SPHardwareDataType", "-json"], timeout=15) - if result is None or result.returncode != 0: - return None, None, None, None - - try: - data = json.loads(result.stdout) - except (json.JSONDecodeError, ValueError): - return None, None, None, None + if self._prefetched_data is not None: + data = self._prefetched_data + else: + result = run_command(["system_profiler", "SPHardwareDataType", "-json"], timeout=15) + if result is None or result.returncode != 0: + return None, None, None, None + try: + data = json.loads(result.stdout) + except (json.JSONDecodeError, ValueError): + return None, None, None, None hw_list = data.get("SPHardwareDataType", []) if not hw_list: diff --git a/tests/scanners/test_prefetch_injection.py b/tests/scanners/test_prefetch_injection.py new file mode 100644 index 0000000..7605fce --- /dev/null +++ b/tests/scanners/test_prefetch_injection.py @@ -0,0 +1,488 @@ +"""Tests for prefetch data injection into display, audio, launch_agents, and cron scanners. + +These tests verify that when prefetched_data is provided, scanners use it +instead of making their own subprocess/IO calls, and that the results are +identical to self-fetched data. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from mac2nix.models.hardware import AudioConfig, DisplayConfig +from mac2nix.models.services import LaunchAgentSource, LaunchAgentsResult, ScheduledTasks +from mac2nix.scanners.audio import AudioScanner +from mac2nix.scanners.cron import CronScanner +from mac2nix.scanners.display import DisplayScanner +from mac2nix.scanners.launch_agents import LaunchAgentsScanner + +# --------------------------------------------------------------------------- +# Shared test data +# --------------------------------------------------------------------------- + +_SP_DISPLAYS_DATA = [ + { + "_name": "Apple M3 Pro", + "spdisplays_ndrvs": [ + { + "_name": "Built-in Retina Display", + "_spdisplays_resolution": "2880 x 1864 Retina", + "spdisplays_main": "spdisplays_yes", + "spdisplays_display_type": "spdisplays_retina", + } + ], + } +] + +_SP_AUDIO_DATA = [ + { + "_name": "MacBook Pro Speakers", + "_items": [ + { + "_name": "MacBook Pro Speakers", + "coreaudio_device_uid": "BuiltInSpeaker", + "coreaudio_device_output": "yes", + "coreaudio_default_audio_output_device": "yes", + }, + { + "_name": "MacBook Pro Microphone", + "coreaudio_device_uid": "BuiltInMic", + "coreaudio_device_input": "yes", + }, + ], + } +] + +_LAUNCHD_PLISTS = [ + ( + Path("/Users/test/Library/LaunchAgents/com.test.prefetch.plist"), + "user", + { + "Label": "com.test.prefetch", + "Program": "/usr/bin/test", + "RunAtLoad": True, + }, + ) +] + +_SCHEDULED_PLISTS = [ + ( + Path("/Users/test/Library/LaunchAgents/com.test.scheduled.plist"), + "user", + { + "Label": "com.test.scheduled", + "StartCalendarInterval": {"Hour": 6, "Minute": 0}, + }, + ) +] + + +# --------------------------------------------------------------------------- +# DisplayScanner prefetch injection +# --------------------------------------------------------------------------- + + +class TestDisplayScannerPrefetch: + def test_prefetch_bypasses_run_command(self) -> None: + """When prefetched_data is provided, DisplayScanner must not call run_command for system_profiler.""" + sp_data = {"SPDisplaysDataType": _SP_DISPLAYS_DATA} + scanner = DisplayScanner(prefetched_data=sp_data) + + with ( + patch("mac2nix.scanners.display.run_command") as mock_cmd, + patch("mac2nix.scanners.display.read_plist_safe", return_value=None), + ): + result = scanner.scan() + + # run_command should NOT have been called with system_profiler SPDisplaysDataType + for call in mock_cmd.call_args_list: + args = call[0][0] if call[0] else [] + assert "SPDisplaysDataType" not in args, ( + "DisplayScanner called system_profiler despite having prefetched data" + ) + + assert isinstance(result, DisplayConfig) + assert len(result.monitors) == 1 + assert result.monitors[0].name == "Built-in Retina Display" + + def test_prefetch_produces_same_result_as_self_fetch(self, cmd_result: Any) -> None: + """Prefetched data should produce the same monitors as self-fetched data.""" + sp_data = {"SPDisplaysDataType": _SP_DISPLAYS_DATA} + + # Self-fetched path + with ( + patch( + "mac2nix.scanners.display.run_command", + return_value=cmd_result(json.dumps(sp_data)), + ), + patch("mac2nix.scanners.display.read_plist_safe", return_value=None), + ): + self_fetched = DisplayScanner().scan() + + # Prefetched path + with patch("mac2nix.scanners.display.read_plist_safe", return_value=None): + prefetched = DisplayScanner(prefetched_data=sp_data).scan() + + assert len(self_fetched.monitors) == len(prefetched.monitors) + for sf_mon, pf_mon in zip(self_fetched.monitors, prefetched.monitors, strict=True): + assert sf_mon.name == pf_mon.name + assert sf_mon.resolution == pf_mon.resolution + assert sf_mon.retina == pf_mon.retina + + def test_none_prefetch_falls_back_to_run_command(self, cmd_result: Any) -> None: + """When prefetched_data is None, DisplayScanner fetches data itself (backward compat).""" + sp_data = {"SPDisplaysDataType": _SP_DISPLAYS_DATA} + scanner = DisplayScanner(prefetched_data=None) + + with ( + patch( + "mac2nix.scanners.display.run_command", + return_value=cmd_result(json.dumps(sp_data)), + ), + patch("mac2nix.scanners.display.read_plist_safe", return_value=None), + ): + result = scanner.scan() + + assert isinstance(result, DisplayConfig) + assert len(result.monitors) == 1 + + def test_no_prefetch_arg_falls_back_to_run_command(self, cmd_result: Any) -> None: + """Default (no prefetched_data kwarg) should preserve existing behavior.""" + sp_data = {"SPDisplaysDataType": _SP_DISPLAYS_DATA} + scanner = DisplayScanner() # no prefetched_data + + with ( + patch( + "mac2nix.scanners.display.run_command", + return_value=cmd_result(json.dumps(sp_data)), + ), + patch("mac2nix.scanners.display.read_plist_safe", return_value=None), + ): + result = scanner.scan() + + assert isinstance(result, DisplayConfig) + + def test_empty_prefetch_produces_empty_monitors(self) -> None: + """Prefetched data with empty SPDisplaysDataType should yield no monitors.""" + sp_data = {"SPDisplaysDataType": []} + scanner = DisplayScanner(prefetched_data=sp_data) + + with ( + patch("mac2nix.scanners.display.read_plist_safe", return_value=None), + patch("mac2nix.scanners.display.run_command", return_value=None), + ): + result = scanner.scan() + + assert isinstance(result, DisplayConfig) + assert result.monitors == [] + + +# --------------------------------------------------------------------------- +# AudioScanner prefetch injection +# --------------------------------------------------------------------------- + + +class TestAudioScannerPrefetch: + def test_prefetch_bypasses_system_profiler_call(self) -> None: + """When prefetched_data is provided, AudioScanner must not call system_profiler.""" + sp_data = {"SPAudioDataType": _SP_AUDIO_DATA} + scanner = AudioScanner(prefetched_data=sp_data) + + with patch("mac2nix.scanners.audio.run_command", return_value=None) as mock_cmd: + result = scanner.scan() + + # run_command should NOT have been called with SPAudioDataType + for call in mock_cmd.call_args_list: + args = call[0][0] if call[0] else [] + assert "SPAudioDataType" not in args, "AudioScanner called system_profiler despite having prefetched data" + + assert isinstance(result, AudioConfig) + assert len(result.output_devices) >= 1 + + def test_prefetch_produces_same_result_as_self_fetch(self, cmd_result: Any) -> None: + """Prefetched data should produce the same devices as self-fetched data.""" + sp_data = {"SPAudioDataType": _SP_AUDIO_DATA} + + # Self-fetched path + with patch( + "mac2nix.scanners.audio.run_command", + return_value=cmd_result(json.dumps(sp_data)), + ): + self_fetched = AudioScanner().scan() + + # Prefetched path + with patch("mac2nix.scanners.audio.run_command", return_value=None): + prefetched = AudioScanner(prefetched_data=sp_data).scan() + + assert len(self_fetched.output_devices) == len(prefetched.output_devices) + assert self_fetched.default_output == prefetched.default_output + + def test_none_prefetch_falls_back_to_run_command(self, cmd_result: Any) -> None: + """When prefetched_data is None, AudioScanner fetches data itself (backward compat).""" + sp_data = {"SPAudioDataType": _SP_AUDIO_DATA} + scanner = AudioScanner(prefetched_data=None) + + with patch( + "mac2nix.scanners.audio.run_command", + return_value=cmd_result(json.dumps(sp_data)), + ): + result = scanner.scan() + + assert isinstance(result, AudioConfig) + assert len(result.output_devices) >= 1 + + def test_no_prefetch_arg_falls_back_to_run_command(self) -> None: + """Default (no prefetched_data kwarg) should preserve existing behavior.""" + scanner = AudioScanner() + + with patch("mac2nix.scanners.audio.run_command", return_value=None): + result = scanner.scan() + + assert isinstance(result, AudioConfig) + + def test_empty_prefetch_produces_empty_devices(self) -> None: + """Empty SPAudioDataType in prefetch should yield no devices.""" + sp_data = {"SPAudioDataType": []} + scanner = AudioScanner(prefetched_data=sp_data) + + with patch("mac2nix.scanners.audio.run_command", return_value=None): + result = scanner.scan() + + assert isinstance(result, AudioConfig) + assert result.output_devices == [] + assert result.input_devices == [] + + def test_volume_settings_still_fetched_with_prefetch(self, cmd_result: Any) -> None: + """Volume settings (osascript) should still be fetched even with prefetched device data.""" + sp_data = {"SPAudioDataType": _SP_AUDIO_DATA} + scanner = AudioScanner(prefetched_data=sp_data) + + def _side_effect(cmd: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[str] | None: + if "osascript" in cmd: + return cmd_result("output volume:50, input volume:75, alert volume:100, output muted:false") + return None + + with patch("mac2nix.scanners.audio.run_command", side_effect=_side_effect): + result = scanner.scan() + + assert isinstance(result, AudioConfig) + assert result.output_volume == 50 + assert result.input_volume == 75 + + +# --------------------------------------------------------------------------- +# LaunchAgentsScanner prefetch injection +# --------------------------------------------------------------------------- + + +class TestLaunchAgentsScannerPrefetch: + def test_prefetch_bypasses_read_launchd_plists(self) -> None: + """When launchd_plists is provided, LaunchAgentsScanner must not call read_launchd_plists.""" + scanner = LaunchAgentsScanner(launchd_plists=_LAUNCHD_PLISTS) + + with ( + patch("mac2nix.scanners.launch_agents.read_launchd_plists") as mock_read, + patch("mac2nix.scanners.launch_agents.run_command", return_value=None), + ): + result = scanner.scan() + + mock_read.assert_not_called() + assert isinstance(result, LaunchAgentsResult) + assert len(result.entries) == 1 + assert result.entries[0].label == "com.test.prefetch" + + def test_prefetch_produces_same_result_as_self_fetch(self) -> None: + """Pre-computed launchd plists should produce same entries as self-fetched.""" + # Self-fetched path + with ( + patch( + "mac2nix.scanners.launch_agents.read_launchd_plists", + return_value=_LAUNCHD_PLISTS, + ), + patch("mac2nix.scanners.launch_agents.run_command", return_value=None), + ): + self_fetched = LaunchAgentsScanner().scan() + + # Prefetched path + with patch("mac2nix.scanners.launch_agents.run_command", return_value=None): + prefetched = LaunchAgentsScanner(launchd_plists=_LAUNCHD_PLISTS).scan() + + assert len(self_fetched.entries) == len(prefetched.entries) + assert self_fetched.entries[0].label == prefetched.entries[0].label + + def test_none_prefetch_falls_back_to_read_launchd_plists(self) -> None: + """When launchd_plists is None, scanner reads plists itself (backward compat).""" + scanner = LaunchAgentsScanner(launchd_plists=None) + + with ( + patch( + "mac2nix.scanners.launch_agents.read_launchd_plists", + return_value=_LAUNCHD_PLISTS, + ) as mock_read, + patch("mac2nix.scanners.launch_agents.run_command", return_value=None), + ): + result = scanner.scan() + + mock_read.assert_called_once() + assert isinstance(result, LaunchAgentsResult) + + def test_no_prefetch_arg_falls_back_to_read_launchd_plists(self) -> None: + """Default (no launchd_plists kwarg) should preserve existing behavior.""" + with ( + patch( + "mac2nix.scanners.launch_agents.read_launchd_plists", + return_value=[], + ) as mock_read, + patch("mac2nix.scanners.launch_agents.run_command", return_value=None), + ): + result = LaunchAgentsScanner().scan() + + mock_read.assert_called_once() + assert isinstance(result, LaunchAgentsResult) + + def test_empty_prefetch_produces_no_plist_entries(self) -> None: + """Empty launchd_plists should yield no plist entries (login items may still appear).""" + with patch("mac2nix.scanners.launch_agents.run_command", return_value=None): + result = LaunchAgentsScanner(launchd_plists=[]).scan() + + assert isinstance(result, LaunchAgentsResult) + # No plist-sourced entries + plist_entries = [e for e in result.entries if e.source != LaunchAgentSource.LOGIN_ITEM] + assert plist_entries == [] + + +# --------------------------------------------------------------------------- +# CronScanner prefetch injection +# --------------------------------------------------------------------------- + + +class TestCronScannerPrefetch: + def test_prefetch_bypasses_read_launchd_plists(self) -> None: + """When launchd_plists is provided, CronScanner must not call read_launchd_plists.""" + scanner = CronScanner(launchd_plists=_SCHEDULED_PLISTS) + + with ( + patch("mac2nix.scanners.cron.read_launchd_plists") as mock_read, + patch("mac2nix.scanners.cron.run_command", return_value=None), + ): + result = scanner.scan() + + mock_read.assert_not_called() + assert isinstance(result, ScheduledTasks) + assert len(result.launchd_scheduled) == 1 + assert result.launchd_scheduled[0].label == "com.test.scheduled" + + def test_prefetch_produces_same_result_as_self_fetch(self) -> None: + """Pre-computed launchd plists should produce same scheduled tasks as self-fetched.""" + # Self-fetched path + with ( + patch("mac2nix.scanners.cron.run_command", return_value=None), + patch( + "mac2nix.scanners.cron.read_launchd_plists", + return_value=_SCHEDULED_PLISTS, + ), + ): + self_fetched = CronScanner().scan() + + # Prefetched path + with patch("mac2nix.scanners.cron.run_command", return_value=None): + prefetched = CronScanner(launchd_plists=_SCHEDULED_PLISTS).scan() + + assert len(self_fetched.launchd_scheduled) == len(prefetched.launchd_scheduled) + assert self_fetched.launchd_scheduled[0].label == prefetched.launchd_scheduled[0].label + assert self_fetched.launchd_scheduled[0].trigger_type == prefetched.launchd_scheduled[0].trigger_type + + def test_none_prefetch_falls_back_to_read_launchd_plists(self) -> None: + """When launchd_plists is None, CronScanner reads plists itself (backward compat).""" + scanner = CronScanner(launchd_plists=None) + + with ( + patch("mac2nix.scanners.cron.run_command", return_value=None), + patch( + "mac2nix.scanners.cron.read_launchd_plists", + return_value=[], + ) as mock_read, + ): + result = scanner.scan() + + mock_read.assert_called_once() + assert isinstance(result, ScheduledTasks) + + def test_no_prefetch_arg_falls_back_to_read_launchd_plists(self) -> None: + """Default (no launchd_plists kwarg) should preserve existing behavior.""" + with ( + patch("mac2nix.scanners.cron.run_command", return_value=None), + patch( + "mac2nix.scanners.cron.read_launchd_plists", + return_value=[], + ) as mock_read, + ): + result = CronScanner().scan() + + mock_read.assert_called_once() + assert isinstance(result, ScheduledTasks) + + def test_empty_prefetch_produces_no_launchd_scheduled(self) -> None: + """Empty launchd_plists should yield no launchd_scheduled entries.""" + with patch("mac2nix.scanners.cron.run_command", return_value=None): + result = CronScanner(launchd_plists=[]).scan() + + assert isinstance(result, ScheduledTasks) + assert result.launchd_scheduled == [] + + def test_cron_entries_still_fetched_with_prefetch(self, cmd_result: Any) -> None: + """Crontab entries (from run_command crontab -l) should still be fetched with prefetch.""" + crontab = "0 5 * * * /usr/bin/backup\n" + + with patch("mac2nix.scanners.cron.run_command", return_value=cmd_result(crontab)): + result = CronScanner(launchd_plists=[]).scan() + + assert isinstance(result, ScheduledTasks) + assert len(result.cron_entries) == 1 + assert result.cron_entries[0].command == "/usr/bin/backup" + + +# --------------------------------------------------------------------------- +# Backward compatibility: existing tests still pass +# --------------------------------------------------------------------------- + + +class TestPrefetchBackwardCompatibility: + """Verify that instantiating scanners without prefetched_data still works identically.""" + + def test_display_scanner_no_args(self) -> None: + scanner = DisplayScanner() + with ( + patch("mac2nix.scanners.display.run_command", return_value=None), + patch("mac2nix.scanners.display.read_plist_safe", return_value=None), + ): + result = scanner.scan() + assert isinstance(result, DisplayConfig) + + def test_audio_scanner_no_args(self) -> None: + scanner = AudioScanner() + with patch("mac2nix.scanners.audio.run_command", return_value=None): + result = scanner.scan() + assert isinstance(result, AudioConfig) + + def test_launch_agents_scanner_no_args(self) -> None: + scanner = LaunchAgentsScanner() + with ( + patch("mac2nix.scanners.launch_agents.read_launchd_plists", return_value=[]), + patch("mac2nix.scanners.launch_agents.run_command", return_value=None), + ): + result = scanner.scan() + assert isinstance(result, LaunchAgentsResult) + + def test_cron_scanner_no_args(self) -> None: + scanner = CronScanner() + with ( + patch("mac2nix.scanners.cron.run_command", return_value=None), + patch("mac2nix.scanners.cron.read_launchd_plists", return_value=[]), + ): + result = scanner.scan() + assert isinstance(result, ScheduledTasks) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..955250d --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,268 @@ +"""Tests for the mac2nix scan CLI command.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from mac2nix.cli import main +from mac2nix.models.system_state import SystemState + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_state(**kwargs: Any) -> SystemState: + defaults: dict[str, Any] = { + "hostname": "test-mac", + "macos_version": "15.3.0", + "architecture": "arm64", + } + defaults.update(kwargs) + return SystemState(**defaults) + + +def _extract_json(output: str) -> str: + """Extract the first JSON object from mixed CLI output (stdout+stderr mixed by CliRunner).""" + start = output.find("{") + if start == -1: + return output + return output[start:] + + +# --------------------------------------------------------------------------- +# CLI command registration +# --------------------------------------------------------------------------- + + +class TestCliCommandRegistration: + def test_scan_command_is_registered(self) -> None: + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + + assert result.exit_code == 0 + assert "scan" in result.output + + def test_scan_help_shows_output_option(self) -> None: + runner = CliRunner() + result = runner.invoke(main, ["scan", "--help"]) + + assert result.exit_code == 0 + assert "--output" in result.output or "-o" in result.output + + def test_scan_help_shows_scanner_option(self) -> None: + runner = CliRunner() + result = runner.invoke(main, ["scan", "--help"]) + + assert result.exit_code == 0 + assert "--scanner" in result.output or "-s" in result.output + + +# --------------------------------------------------------------------------- +# Basic invocation +# --------------------------------------------------------------------------- + + +class TestScanCommandBasic: + def test_scan_exits_zero(self) -> None: + runner = CliRunner() + state = _make_state() + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = state + result = runner.invoke(main, ["scan"]) + + assert result.exit_code == 0 + + def test_scan_outputs_valid_json(self) -> None: + runner = CliRunner() + state = _make_state() + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = state + result = runner.invoke(main, ["scan"]) + + assert result.exit_code == 0 + try: + # CliRunner mixes stderr+stdout; extract the JSON portion + parsed = json.loads(_extract_json(result.output)) + except json.JSONDecodeError: + pytest.fail(f"scan output does not contain valid JSON: {result.output!r}") + + assert parsed["hostname"] == "test-mac" + assert parsed["macos_version"] == "15.3.0" + assert parsed["architecture"] == "arm64" + + def test_scan_json_round_trips(self) -> None: + runner = CliRunner() + state = _make_state() + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = state + result = runner.invoke(main, ["scan"]) + + assert result.exit_code == 0 + recovered = SystemState.from_json(_extract_json(result.output)) + assert recovered.hostname == state.hostname + assert recovered.macos_version == state.macos_version + assert recovered.architecture == state.architecture + + +# --------------------------------------------------------------------------- +# --output / -o option +# --------------------------------------------------------------------------- + + +class TestScanOutputOption: + def test_output_writes_to_file(self, tmp_path: Path) -> None: + runner = CliRunner() + state = _make_state() + output_file = tmp_path / "scan.json" + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = state + result = runner.invoke(main, ["scan", "--output", str(output_file)]) + + assert result.exit_code == 0 + assert output_file.exists() + parsed = json.loads(output_file.read_text()) + assert parsed["hostname"] == "test-mac" + + def test_short_flag_o_works(self, tmp_path: Path) -> None: + runner = CliRunner() + state = _make_state() + output_file = tmp_path / "scan-short.json" + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = state + result = runner.invoke(main, ["scan", "-o", str(output_file)]) + + assert result.exit_code == 0 + assert output_file.exists() + + def test_output_to_file_stdout_is_not_json_blob(self, tmp_path: Path) -> None: + """When --output is given, stdout should not be the full JSON blob.""" + runner = CliRunner() + state = _make_state() + output_file = tmp_path / "scan.json" + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = state + result = runner.invoke(main, ["scan", "--output", str(output_file)]) + + assert result.exit_code == 0 + # stdout should be empty when output goes to a file (summary goes to stderr) + assert "scan_timestamp" not in result.output + + def test_output_creates_parent_dirs(self, tmp_path: Path) -> None: + runner = CliRunner() + state = _make_state() + output_file = tmp_path / "nested" / "dir" / "scan.json" + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = state + result = runner.invoke(main, ["scan", "--output", str(output_file)]) + + assert result.exit_code == 0 + assert output_file.exists() + + +# --------------------------------------------------------------------------- +# --scanner / -s option +# --------------------------------------------------------------------------- + + +class TestScanScannerOption: + def test_scanner_short_flag_accepted(self) -> None: + runner = CliRunner() + state = _make_state() + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = state + result = runner.invoke(main, ["scan", "-s", "display"]) + + assert result.exit_code == 0 + + def test_scanner_repeatable(self) -> None: + runner = CliRunner() + state = _make_state() + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = state + result = runner.invoke(main, ["scan", "--scanner", "display", "--scanner", "audio"]) + + assert result.exit_code == 0 + + def test_unknown_scanner_exits_nonzero(self) -> None: + runner = CliRunner() + + result = runner.invoke(main, ["scan", "--scanner", "bogus_scanner_xyz_nonexistent"]) + + assert result.exit_code != 0 + + def test_no_scanner_option_exits_zero(self) -> None: + runner = CliRunner() + state = _make_state() + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = state + result = runner.invoke(main, ["scan"]) + + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# Progress on stderr +# --------------------------------------------------------------------------- + + +class TestScanProgressOutput: + def test_json_portion_is_parseable(self) -> None: + """When no --output flag, the JSON portion of output must be parseable.""" + runner = CliRunner() + state = _make_state() + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = state + result = runner.invoke(main, ["scan"]) + + assert result.exit_code == 0 + try: + json.loads(_extract_json(result.output)) + except json.JSONDecodeError: + pytest.fail(f"output does not contain valid JSON: {result.output!r}") + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestScanErrorHandling: + def test_orchestrator_exception_exits_nonzero(self) -> None: + runner = CliRunner() + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.side_effect = RuntimeError("orchestrator failed") + result = runner.invoke(main, ["scan"]) + + assert result.exit_code != 0 + + def test_summary_shown_after_writing_to_file(self, tmp_path: Path) -> None: + """After a successful scan to file, the output file must exist.""" + runner = CliRunner() + state = _make_state() + output_file = tmp_path / "scan.json" + + with patch("mac2nix.cli.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = state + result = runner.invoke(main, ["scan", "--output", str(output_file)]) + + assert result.exit_code == 0 + assert output_file.exists() diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py new file mode 100644 index 0000000..84135d3 --- /dev/null +++ b/tests/test_orchestrator.py @@ -0,0 +1,442 @@ +"""Tests for the async scan orchestrator.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +from pydantic import BaseModel + +from mac2nix.models.hardware import AudioConfig, DisplayConfig +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 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_minimal_scanner(name: str, result: Any, *, available: bool = True) -> type: + """Create a minimal mock scanner class that returns a fixed result.""" + + class _MockScanner: + def __init__(self, **_kwargs: object) -> None: + pass + + @property + def name(self) -> str: + return name + + def is_available(self) -> bool: + return available + + def scan(self) -> Any: + return result + + return _MockScanner + + +# --------------------------------------------------------------------------- +# _get_system_metadata +# --------------------------------------------------------------------------- + + +class TestGetSystemMetadata: + def test_returns_three_strings(self) -> None: + with patch("mac2nix.orchestrator.run_command") as mock_cmd: + mock_cmd.return_value = MagicMock(returncode=0, stdout="14.3.1\n") + hostname, macos_version, architecture = _get_system_metadata() + + assert isinstance(hostname, str) + assert isinstance(macos_version, str) + assert isinstance(architecture, str) + + def test_sw_vers_fallback(self) -> None: + with ( + patch("mac2nix.orchestrator.run_command", return_value=None), + patch("mac2nix.orchestrator.platform.mac_ver", return_value=("13.0", (), "")), + ): + _, macos_version, _ = _get_system_metadata() + + assert macos_version == "13.0" + + def test_sw_vers_fallback_unknown(self) -> None: + with ( + patch("mac2nix.orchestrator.run_command", return_value=None), + patch("mac2nix.orchestrator.platform.mac_ver", return_value=("", (), "")), + ): + _, macos_version, _ = _get_system_metadata() + + assert macos_version == "unknown" + + def test_hostname_is_string(self) -> None: + with patch("mac2nix.orchestrator.run_command") as mock_cmd: + mock_cmd.return_value = MagicMock(returncode=0, stdout="14.0\n") + hostname, _, _ = _get_system_metadata() + assert len(hostname) > 0 + + +# --------------------------------------------------------------------------- +# _fetch_system_profiler_batch +# --------------------------------------------------------------------------- + + +class TestFetchSystemProfilerBatch: + def test_returns_parsed_dict(self) -> None: + payload = '{"SPDisplaysDataType": [], "SPAudioDataType": []}' + with ( + patch("mac2nix.orchestrator.shutil.which", return_value="/usr/sbin/system_profiler"), + patch("mac2nix.orchestrator.run_command") as mock_cmd, + ): + mock_cmd.return_value = MagicMock(returncode=0, stdout=payload) + result = _fetch_system_profiler_batch() + + assert "SPDisplaysDataType" in result + assert "SPAudioDataType" in result + + def test_returns_empty_when_not_found(self) -> None: + with patch("mac2nix.orchestrator.shutil.which", return_value=None): + result = _fetch_system_profiler_batch() + + assert result == {} + + def test_returns_empty_on_command_failure(self) -> None: + with ( + patch("mac2nix.orchestrator.shutil.which", return_value="/usr/sbin/system_profiler"), + patch("mac2nix.orchestrator.run_command", return_value=None), + ): + result = _fetch_system_profiler_batch() + + assert result == {} + + def test_returns_empty_on_nonzero_returncode(self) -> None: + with ( + patch("mac2nix.orchestrator.shutil.which", return_value="/usr/sbin/system_profiler"), + patch("mac2nix.orchestrator.run_command") as mock_cmd, + ): + mock_cmd.return_value = MagicMock(returncode=1, stdout="") + result = _fetch_system_profiler_batch() + + assert result == {} + + def test_returns_empty_on_invalid_json(self) -> None: + with ( + patch("mac2nix.orchestrator.shutil.which", return_value="/usr/sbin/system_profiler"), + patch("mac2nix.orchestrator.run_command") as mock_cmd, + ): + mock_cmd.return_value = MagicMock(returncode=0, stdout="{not valid json!!!") + result = _fetch_system_profiler_batch() + + assert result == {} + + +# --------------------------------------------------------------------------- +# run_scan +# --------------------------------------------------------------------------- + + +class _FakeResult(BaseModel): + """Trivial Pydantic model used as a scanner result placeholder in tests.""" + + +class TestRunScan: + def _make_pydantic_result(self) -> _FakeResult: + return _FakeResult() + + def _make_registry(self, names: list[str]) -> dict[str, type]: + """Create a registry of scanners that report as unavailable (result = None).""" + return {name: _make_minimal_scanner(name, _FakeResult(), available=False) for name in names} + + def test_returns_system_state(self) -> None: + # Use a scanner name that doesn't match any SystemState field — extra fields are ignored + registry = self._make_registry(["_fake_scanner_a"]) + + 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=[]), + ): + state = asyncio.run(run_scan()) + + assert isinstance(state, SystemState) + assert state.hostname == "host" + assert state.macos_version == "14.0" + assert state.architecture == "arm64" + + def test_unavailable_scanner_produces_none(self) -> None: + registry = {"shell": _make_minimal_scanner("shell", _FakeResult(), available=False)} + + 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=[]), + ): + state = asyncio.run(run_scan()) + + assert isinstance(state, SystemState) + assert state.shell is None + + def test_scanner_exception_does_not_crash_orchestrator(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} + + 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=[]), + ): + state = asyncio.run(run_scan()) + + assert isinstance(state, SystemState) + # Crash scanner produces None — shell field should still be None + assert state.shell is None + assert state.audio is None + + def test_progress_callback_called_for_each_scanner(self) -> None: + registry = self._make_registry(["_fake_a", "_fake_b"]) + called: list[str] = [] + + 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=[]), + ): + asyncio.run(run_scan(progress_callback=called.append)) + + assert sorted(called) == sorted(["_fake_a", "_fake_b"]) + + def test_progress_callback_called_for_unavailable(self) -> None: + """Unavailable scanners should still trigger the progress callback.""" + registry = {"_fake": _make_minimal_scanner("_fake", _FakeResult(), available=False)} + called: list[str] = [] + + 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=[]), + ): + asyncio.run(run_scan(progress_callback=called.append)) + + assert "_fake" in called + + def test_display_receives_prefetched_data(self) -> None: + """Display scanner should be instantiated with prefetched_data from the batch call.""" + batched = {"SPDisplaysDataType": [], "SPAudioDataType": []} + init_kwargs: list[dict[str, Any]] = [] + + class _TrackingDisplay: + def __init__(self, **kwargs: Any) -> None: + init_kwargs.append(kwargs) + + @property + def name(self) -> str: + return "display" + + def is_available(self) -> bool: + return True + + def scan(self) -> DisplayConfig: + return DisplayConfig() + + with ( + patch("mac2nix.orchestrator.get_all_scanners", return_value={"display": _TrackingDisplay}), + patch("mac2nix.orchestrator.DisplayScanner", _TrackingDisplay), + patch("mac2nix.orchestrator._get_system_metadata", return_value=("host", "14.0", "arm64")), + patch("mac2nix.orchestrator._fetch_system_profiler_batch", return_value=batched), + patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), + ): + asyncio.run(run_scan(scanners=["display"])) + + assert init_kwargs, "Display scanner was not instantiated" + assert init_kwargs[0].get("prefetched_data") == batched + + def test_audio_receives_prefetched_data(self) -> None: + """Audio scanner should be instantiated with prefetched_data from the batch call.""" + batched = {"SPDisplaysDataType": [], "SPAudioDataType": []} + init_kwargs: list[dict[str, Any]] = [] + + class _TrackingAudio: + def __init__(self, **kwargs: Any) -> None: + init_kwargs.append(kwargs) + + @property + def name(self) -> str: + return "audio" + + def is_available(self) -> bool: + return True + + def scan(self) -> AudioConfig: + return AudioConfig() + + with ( + patch("mac2nix.orchestrator.get_all_scanners", return_value={"audio": _TrackingAudio}), + patch("mac2nix.orchestrator.AudioScanner", _TrackingAudio), + patch("mac2nix.orchestrator._get_system_metadata", return_value=("host", "14.0", "arm64")), + patch("mac2nix.orchestrator._fetch_system_profiler_batch", return_value=batched), + patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), + ): + asyncio.run(run_scan(scanners=["audio"])) + + assert init_kwargs, "Audio scanner was not instantiated" + assert init_kwargs[0].get("prefetched_data") == batched + + def test_launch_agents_and_cron_share_plists(self) -> None: + """Both launch_agents and cron should receive the same pre-read plist list.""" + plist_data: list[tuple[Path, str, dict[str, Any]]] = [] + la_kwargs: list[dict[str, Any]] = [] + cron_kwargs: list[dict[str, Any]] = [] + + class _TrackingLA: + def __init__(self, **kwargs: Any) -> None: + la_kwargs.append(kwargs) + + @property + def name(self) -> str: + return "launch_agents" + + def is_available(self) -> bool: + return True + + def scan(self) -> LaunchAgentsResult: + return LaunchAgentsResult() + + class _TrackingCron: + def __init__(self, **kwargs: Any) -> None: + cron_kwargs.append(kwargs) + + @property + def name(self) -> str: + return "cron" + + def is_available(self) -> bool: + return True + + def scan(self) -> ScheduledTasks: + return ScheduledTasks() + + with ( + patch( + "mac2nix.orchestrator.get_all_scanners", + return_value={"launch_agents": _TrackingLA, "cron": _TrackingCron}, + ), + patch("mac2nix.orchestrator.LaunchAgentsScanner", _TrackingLA), + patch("mac2nix.orchestrator.CronScanner", _TrackingCron), + 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=plist_data), + ): + asyncio.run(run_scan(scanners=["launch_agents", "cron"])) + + assert la_kwargs[0].get("launchd_plists") is plist_data + assert cron_kwargs[0].get("launchd_plists") is plist_data + + def test_empty_scan_produces_valid_system_state(self) -> None: + with ( + patch("mac2nix.orchestrator.get_all_scanners", return_value={}), + patch("mac2nix.orchestrator._get_system_metadata", return_value=("myhost", "15.0", "x86_64")), + patch("mac2nix.orchestrator._fetch_system_profiler_batch", return_value={}), + patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), + ): + state = asyncio.run(run_scan()) + + assert state.hostname == "myhost" + assert state.display is None + assert state.audio is None + + def test_launchd_not_read_when_not_selected(self) -> None: + """read_launchd_plists should NOT be called when launch_agents and cron not selected.""" + registry = {"shell": _make_minimal_scanner("shell", _FakeResult(), available=False)} + + 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") as mock_read, + ): + asyncio.run(run_scan(scanners=["shell"])) + + mock_read.assert_not_called() + + def test_system_profiler_not_called_when_not_selected(self) -> None: + """Batched system_profiler should NOT be called when display+audio not selected.""" + registry = {"shell": _make_minimal_scanner("shell", _FakeResult(), available=False)} + + 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") as mock_sp, + patch("mac2nix.orchestrator.read_launchd_plists", return_value=[]), + ): + asyncio.run(run_scan(scanners=["shell"])) + + mock_sp.assert_not_called() + + def test_scanner_filter_excludes_unselected(self) -> None: + """Scanners not in the filter list should not run.""" + called: list[str] = [] + + class _TrackingScanner: + def __init__(self, **_kwargs: object) -> None: + pass + + @property + def name(self) -> str: + return "_fake_selected" + + def is_available(self) -> bool: + return True + + def scan(self) -> _FakeResult: + called.append("_fake_selected") + return _FakeResult() + + class _UnselectedScanner: + def __init__(self, **_kwargs: object) -> None: + pass + + @property + def name(self) -> str: + return "_fake_other" + + def is_available(self) -> bool: + return True + + def scan(self) -> _FakeResult: + called.append("_fake_other") + return _FakeResult() + + registry: dict[str, type] = {"_fake_selected": _TrackingScanner, "_fake_other": _UnselectedScanner} + + 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=[]), + ): + asyncio.run(run_scan(scanners=["_fake_selected"])) + + assert "_fake_selected" in called + assert "_fake_other" not in called diff --git a/uv.lock b/uv.lock index 0cce97d..31415e1 100644 --- a/uv.lock +++ b/uv.lock @@ -107,6 +107,7 @@ dependencies = [ { name = "jinja2" }, { name = "pydantic" }, { name = "pyyaml" }, + { name = "rich" }, ] [package.dev-dependencies] @@ -124,6 +125,7 @@ requires-dist = [ { name = "jinja2", specifier = ">=3.1" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", specifier = ">=13.0" }, ] [package.metadata.requires-dev] @@ -135,6 +137,18 @@ dev = [ { name = "ruff" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -187,6 +201,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -407,6 +430,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + [[package]] name = "ruff" version = "0.15.5" From 40deff7f6b0fc4b0e6789b075afaa2b8a8f425c0 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sat, 14 Mar 2026 11:37:34 -0400 Subject: [PATCH 16/17] refactor(scanners): merges library_audit + app_config into library --- src/mac2nix/models/__init__.py | 6 +- src/mac2nix/models/files.py | 9 +- src/mac2nix/models/system_state.py | 5 +- src/mac2nix/scanners/__init__.py | 3 +- src/mac2nix/scanners/app_config.py | 152 --- .../{library_audit.py => library_scanner.py} | 253 +++- tests/models/test_dotfile.py | 39 +- tests/models/test_remaining.py | 40 +- tests/models/test_system_state.py | 14 +- tests/scanners/test_app_config.py | 324 ----- tests/scanners/test_library_audit.py | 512 -------- tests/scanners/test_library_scanner.py | 1138 +++++++++++++++++ 12 files changed, 1372 insertions(+), 1123 deletions(-) delete mode 100644 src/mac2nix/scanners/app_config.py rename src/mac2nix/scanners/{library_audit.py => library_scanner.py} (66%) delete mode 100644 tests/scanners/test_app_config.py delete mode 100644 tests/scanners/test_library_audit.py create mode 100644 tests/scanners/test_library_scanner.py diff --git a/src/mac2nix/models/__init__.py b/src/mac2nix/models/__init__.py index 79e0ef6..e5f6a05 100644 --- a/src/mac2nix/models/__init__.py +++ b/src/mac2nix/models/__init__.py @@ -14,7 +14,6 @@ ) from mac2nix.models.files import ( AppConfigEntry, - AppConfigResult, BundleEntry, ConfigFileType, DotfileEntry, @@ -25,9 +24,9 @@ FontSource, FontsResult, KeyBindingEntry, - LibraryAuditResult, LibraryDirEntry, LibraryFileEntry, + LibraryResult, WorkflowEntry, ) from mac2nix.models.hardware import ( @@ -93,7 +92,6 @@ __all__ = [ "AppConfigEntry", - "AppConfigResult", "AppSource", "ApplicationsResult", "AudioConfig", @@ -131,9 +129,9 @@ "LaunchAgentSource", "LaunchAgentsResult", "LaunchdScheduledJob", - "LibraryAuditResult", "LibraryDirEntry", "LibraryFileEntry", + "LibraryResult", "MacPortsPackage", "MacPortsState", "ManagedRuntime", diff --git a/src/mac2nix/models/files.py b/src/mac2nix/models/files.py index e1a022a..7bb9068 100644 --- a/src/mac2nix/models/files.py +++ b/src/mac2nix/models/files.py @@ -1,4 +1,4 @@ -"""Dotfile, app config, font, and library audit models.""" +"""Dotfile, app config, font, and library models.""" from __future__ import annotations @@ -56,10 +56,6 @@ class AppConfigEntry(BaseModel): modified_time: datetime | None = None -class AppConfigResult(BaseModel): - entries: list[AppConfigEntry] - - class FontSource(StrEnum): USER = "user" # ~/Library/Fonts SYSTEM = "system" # /Library/Fonts @@ -121,7 +117,8 @@ class KeyBindingEntry(BaseModel): action: str | dict[str, Any] -class LibraryAuditResult(BaseModel): +class LibraryResult(BaseModel): + app_configs: list[AppConfigEntry] = [] bundles: list[BundleEntry] = [] directories: list[LibraryDirEntry] = [] uncovered_files: list[LibraryFileEntry] = [] diff --git a/src/mac2nix/models/system_state.py b/src/mac2nix/models/system_state.py index 9fa46d6..b1505df 100644 --- a/src/mac2nix/models/system_state.py +++ b/src/mac2nix/models/system_state.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from mac2nix.models.application import ApplicationsResult, HomebrewState -from mac2nix.models.files import AppConfigResult, DotfilesResult, FontsResult, LibraryAuditResult +from mac2nix.models.files import DotfilesResult, FontsResult, LibraryResult from mac2nix.models.hardware import AudioConfig, DisplayConfig from mac2nix.models.package_managers import ( ContainersResult, @@ -37,7 +37,6 @@ class SystemState(BaseModel): applications: ApplicationsResult | None = None homebrew: HomebrewState | None = None dotfiles: DotfilesResult | None = None - app_config: AppConfigResult | None = None fonts: FontsResult | None = None launch_agents: LaunchAgentsResult | None = None shell: ShellConfig | None = None @@ -47,7 +46,7 @@ class SystemState(BaseModel): display: DisplayConfig | None = None audio: AudioConfig | None = None cron: ScheduledTasks | None = None - library_audit: LibraryAuditResult | None = None + library: LibraryResult | None = None nix_state: NixState | None = None version_managers: VersionManagersResult | None = None package_managers: PackageManagersResult | None = None diff --git a/src/mac2nix/scanners/__init__.py b/src/mac2nix/scanners/__init__.py index 8d99231..6713930 100644 --- a/src/mac2nix/scanners/__init__.py +++ b/src/mac2nix/scanners/__init__.py @@ -1,7 +1,6 @@ """Scanner plugins for macOS system state discovery.""" from mac2nix.scanners import ( # noqa: F401 - app_config, applications, audio, containers, @@ -11,7 +10,7 @@ fonts, homebrew, launch_agents, - library_audit, + library_scanner, network, nix_state, package_managers_scanner, diff --git a/src/mac2nix/scanners/app_config.py b/src/mac2nix/scanners/app_config.py deleted file mode 100644 index aead506..0000000 --- a/src/mac2nix/scanners/app_config.py +++ /dev/null @@ -1,152 +0,0 @@ -"""App config scanner — discovers application configuration files.""" - -from __future__ import annotations - -import logging -import os -from datetime import UTC, datetime -from pathlib import Path - -from mac2nix.models.files import AppConfigEntry, AppConfigResult, ConfigFileType -from mac2nix.scanners._utils import hash_file -from mac2nix.scanners.base import BaseScannerPlugin, register - -logger = logging.getLogger(__name__) - -_EXTENSION_MAP: dict[str, ConfigFileType] = { - ".json": ConfigFileType.JSON, - ".plist": ConfigFileType.PLIST, - ".toml": ConfigFileType.TOML, - ".yaml": ConfigFileType.YAML, - ".yml": ConfigFileType.YAML, - ".xml": ConfigFileType.XML, - ".conf": ConfigFileType.CONF, - ".cfg": ConfigFileType.CONF, - ".ini": ConfigFileType.CONF, - ".sqlite": ConfigFileType.DATABASE, - ".db": ConfigFileType.DATABASE, - ".sqlite3": ConfigFileType.DATABASE, -} - -_SKIP_DIRS = frozenset( - { - "Caches", - "Cache", - "Logs", - "logs", - "tmp", - "temp", - "__pycache__", - "node_modules", - ".git", - ".svn", - ".hg", - "DerivedData", - "Build", - ".build", - "IndexedDB", - "GPUCache", - "ShaderCache", - "Service Worker", - "Code Cache", - "CachedData", - "blob_storage", - } -) - -_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB -_MAX_FILES_PER_APP = 500 - - -@register("app_config") -class AppConfigScanner(BaseScannerPlugin): - @property - def name(self) -> str: - return "app_config" - - def scan(self) -> AppConfigResult: - home = Path.home() - entries: list[AppConfigEntry] = [] - - scan_dirs = [ - home / "Library" / "Application Support", - home / "Library" / "Group Containers", - ] - - # Add Containers app support dirs - containers_dir = home / "Library" / "Containers" - if containers_dir.is_dir(): - try: - for container in sorted(containers_dir.iterdir()): - app_support = container / "Data" / "Library" / "Application Support" - if app_support.is_dir() and os.access(app_support, os.R_OK): - scan_dirs.append(app_support) - except PermissionError: - logger.warning("Permission denied reading: %s", containers_dir) - - for base_dir in scan_dirs: - if not base_dir.is_dir(): - continue - try: - app_dirs = sorted(base_dir.iterdir()) - except PermissionError: - logger.warning("Permission denied reading: %s", base_dir) - continue - for app_dir in app_dirs: - if not app_dir.is_dir(): - continue - if not os.access(app_dir, os.R_OK): - logger.debug("Skipping TCC-protected directory: %s", app_dir) - continue - self._scan_app_dir(app_dir, entries) - - return AppConfigResult(entries=entries) - - def _scan_app_dir(self, app_dir: Path, entries: list[AppConfigEntry]) -> None: - app_name = app_dir.name - file_count = 0 - - try: - for dirpath, dirnames, filenames in os.walk(app_dir, followlinks=False): - # Prune skipped directories in-place - dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] - - for filename in filenames: - if file_count >= _MAX_FILES_PER_APP: - logger.warning( - "Reached %d file cap for app directory: %s", - _MAX_FILES_PER_APP, - app_dir, - ) - return - - filepath = Path(dirpath) / filename - try: - stat = filepath.stat() - except OSError: - continue - - # Skip files over 10MB - if stat.st_size > _MAX_FILE_SIZE: - continue - - ext = filepath.suffix.lower() - file_type = _EXTENSION_MAP.get(ext, ConfigFileType.UNKNOWN) - scannable = file_type != ConfigFileType.DATABASE - - content_hash = hash_file(filepath) if scannable else None - modified_time = datetime.fromtimestamp(stat.st_mtime, tz=UTC) - - entries.append( - AppConfigEntry( - app_name=app_name, - path=filepath, - file_type=file_type, - content_hash=content_hash, - scannable=scannable, - modified_time=modified_time, - ) - ) - file_count += 1 - except PermissionError: - logger.warning("Permission denied reading app config dir: %s", app_dir) diff --git a/src/mac2nix/scanners/library_audit.py b/src/mac2nix/scanners/library_scanner.py similarity index 66% rename from src/mac2nix/scanners/library_audit.py rename to src/mac2nix/scanners/library_scanner.py index 3dcc47d..9370a90 100644 --- a/src/mac2nix/scanners/library_audit.py +++ b/src/mac2nix/scanners/library_scanner.py @@ -1,7 +1,8 @@ -"""Library audit scanner — discovers uncovered ~/Library and /Library content.""" +"""Library scanner — discovers ~/Library content, app configs, and system bundles.""" from __future__ import annotations +import contextlib import logging import os import sqlite3 @@ -10,25 +11,49 @@ from typing import Any from mac2nix.models.files import ( + AppConfigEntry, BundleEntry, + ConfigFileType, KeyBindingEntry, - LibraryAuditResult, LibraryDirEntry, LibraryFileEntry, + LibraryResult, WorkflowEntry, ) -from mac2nix.scanners._utils import hash_file, read_plist_safe, run_command +from mac2nix.scanners._utils import WALK_SKIP_DIRS, hash_file, read_plist_safe, run_command from mac2nix.scanners.base import BaseScannerPlugin, register logger = logging.getLogger(__name__) +# --- App config constants --- + +_EXTENSION_MAP: dict[str, ConfigFileType] = { + ".json": ConfigFileType.JSON, + ".plist": ConfigFileType.PLIST, + ".toml": ConfigFileType.TOML, + ".yaml": ConfigFileType.YAML, + ".yml": ConfigFileType.YAML, + ".xml": ConfigFileType.XML, + ".conf": ConfigFileType.CONF, + ".cfg": ConfigFileType.CONF, + ".ini": ConfigFileType.CONF, + ".sqlite": ConfigFileType.DATABASE, + ".db": ConfigFileType.DATABASE, + ".sqlite3": ConfigFileType.DATABASE, +} + +_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB +_MAX_FILES_PER_APP = 500 + +# --- Library audit constants --- + _COVERED_DIRS: dict[str, str] = { "Preferences": "preferences", - "Application Support": "app_config", + "Application Support": "library", "Fonts": "fonts", "LaunchAgents": "launch_agents", - "Containers": "preferences+app_config", - "Group Containers": "app_config", + "Containers": "preferences+library", + "Group Containers": "library", "FontCollections": "fonts", "SyncedPreferences": "preferences", } @@ -52,7 +77,8 @@ _SENSITIVE_KEY_PATTERNS = {"_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH"} -_MAX_FILES_PER_DIR = 200 +_MAX_FILES_PER_DIR = 1000 +_MAX_SCRIPTS = 50 _SYSTEM_SCAN_PATTERNS: dict[str, str] = { "Extensions": "*.kext", @@ -87,32 +113,37 @@ def _redact_sensitive_keys(data: dict[str, Any]) -> None: _redact_sensitive_keys(item) -@register("library_audit") -class LibraryAuditScanner(BaseScannerPlugin): +@register("library") +class LibraryScanner(BaseScannerPlugin): @property def name(self) -> str: - return "library_audit" + return "library" + + def scan(self) -> LibraryResult: + home = Path.home() + home_lib = home / "Library" - def scan(self) -> LibraryAuditResult: - home_lib = Path.home() / "Library" + # --- Library directory audit --- directories = self._audit_directories(home_lib) uncovered_files: list[LibraryFileEntry] = [] workflows: list[WorkflowEntry] = [] + bundles: list[BundleEntry] = [] key_bindings = self._scan_key_bindings(home_lib) spelling_words, spelling_dicts = self._scan_spelling(home_lib) text_replacements = self._scan_text_replacements(home_lib) input_methods = self._scan_bundles_in_dir(home_lib / "Input Methods") - keyboard_layouts = self._scan_file_hashes(home_lib / "Keyboard Layouts", ".keylayout") - color_profiles = self._scan_file_hashes(home_lib / "ColorSync" / "Profiles", ".icc", ".icm") - compositions = self._scan_file_hashes(home_lib / "Compositions", ".qtz") + keyboard_layouts = self._list_files_by_extension(home_lib / "Keyboard Layouts", ".keylayout") + color_profiles = self._list_files_by_extension(home_lib / "ColorSync" / "Profiles", ".icc", ".icm") + compositions = self._list_files_by_extension(home_lib / "Compositions", ".qtz") scripts = self._scan_scripts(home_lib) - # Capture uncovered files and workflows from uncovered directories + # Capture uncovered files, workflows, and bundles from uncovered directories for d in directories: if d.covered_by_scanner is None and d.name not in _TRANSIENT_DIRS: - files, wf = self._capture_uncovered_dir(d.path) + files, wf, bdl = self._capture_uncovered_dir(d.path) uncovered_files.extend(files) workflows.extend(wf) + bundles.extend(bdl) # Scan workflows from known Workflows/Services dirs for wf_dir_name in ["Workflows", "Services"]: @@ -120,9 +151,15 @@ def scan(self) -> LibraryAuditResult: if wf_dir.is_dir(): workflows.extend(self._scan_workflows(wf_dir)) + # --- App config scanning --- + entries = self._scan_app_configs(home_lib) + + # --- system library bundles --- system_bundles = self._scan_system_library() - return LibraryAuditResult( + return LibraryResult( + app_configs=entries, + bundles=bundles, directories=directories, uncovered_files=uncovered_files, workflows=workflows, @@ -138,6 +175,93 @@ def scan(self) -> LibraryAuditResult: system_bundles=system_bundles, ) + # --- App config scanning --- + + def _scan_app_configs(self, home_lib: Path) -> list[AppConfigEntry]: + """Walk Application Support, Group Containers, and Containers for app configs.""" + entries: list[AppConfigEntry] = [] + + scan_dirs = [ + home_lib / "Application Support", + home_lib / "Group Containers", + ] + + # Add Containers app support dirs + containers_dir = home_lib / "Containers" + if containers_dir.is_dir(): + try: + for container in sorted(containers_dir.iterdir()): + app_support = container / "Data" / "Library" / "Application Support" + if app_support.is_dir() and os.access(app_support, os.R_OK): + scan_dirs.append(app_support) + except PermissionError: + logger.warning("Permission denied reading: %s", containers_dir) + + for base_dir in scan_dirs: + if not base_dir.is_dir(): + continue + try: + app_dirs = sorted(base_dir.iterdir()) + except PermissionError: + logger.warning("Permission denied reading: %s", base_dir) + continue + for app_dir in app_dirs: + if not app_dir.is_dir(): + continue + if not os.access(app_dir, os.R_OK): + logger.debug("Skipping TCC-protected directory: %s", app_dir) + continue + self._scan_app_dir(app_dir, entries) + + return entries + + def _scan_app_dir(self, app_dir: Path, entries: list[AppConfigEntry]) -> None: + app_name = app_dir.name + file_count = 0 + + try: + for dirpath, dirnames, filenames in os.walk(app_dir, followlinks=False): + # Prune skipped directories in-place + dirnames[:] = [d for d in dirnames if d not in WALK_SKIP_DIRS] + + for filename in filenames: + if file_count >= _MAX_FILES_PER_APP: + logger.info("Reached %d file cap for app: %s", _MAX_FILES_PER_APP, app_name) + return + + filepath = Path(dirpath) / filename + try: + stat = filepath.stat() + except OSError: + continue + + # Skip files over 10MB + if stat.st_size > _MAX_FILE_SIZE: + continue + + ext = filepath.suffix.lower() + file_type = _EXTENSION_MAP.get(ext, ConfigFileType.UNKNOWN) + scannable = file_type != ConfigFileType.DATABASE + + content_hash = hash_file(filepath) if scannable else None + modified_time = datetime.fromtimestamp(stat.st_mtime, tz=UTC) + + entries.append( + AppConfigEntry( + app_name=app_name, + path=filepath, + file_type=file_type, + content_hash=content_hash, + scannable=scannable, + modified_time=modified_time, + ) + ) + file_count += 1 + except PermissionError: + logger.warning("Permission denied reading app config dir: %s", app_dir) + + # --- Library audit scanning --- + def _audit_directories(self, lib_path: Path) -> list[LibraryDirEntry]: """Walk top-level ~/Library directories and collect metadata.""" if not lib_path.is_dir(): @@ -174,6 +298,8 @@ def _dir_stats(path: Path) -> tuple[int | None, int | None, datetime | None]: total_size = 0 newest = 0.0 for entry in path.iterdir(): + if entry.is_symlink(): + continue try: st = entry.stat() file_count += 1 @@ -186,44 +312,44 @@ def _dir_stats(path: Path) -> tuple[int | None, int | None, datetime | None]: except PermissionError: return None, None, None - def _capture_uncovered_dir(self, dir_path: Path) -> tuple[list[LibraryFileEntry], list[WorkflowEntry]]: - """Capture files from an uncovered directory (capped).""" + def _capture_uncovered_dir( + self, dir_path: Path + ) -> tuple[list[LibraryFileEntry], list[WorkflowEntry], list[BundleEntry]]: + """Capture files, workflows, and bundles from an uncovered directory.""" files: list[LibraryFileEntry] = [] workflows: list[WorkflowEntry] = [] + bundles: list[BundleEntry] = [] count = 0 try: for dirpath, dirnames, filenames in os.walk(dir_path, followlinks=False): for filename in filenames: if count >= _MAX_FILES_PER_DIR: - logger.warning( - "Reached %d file cap for directory: %s", - _MAX_FILES_PER_DIR, - dir_path, - ) - return files, workflows + logger.info("Reached %d file cap for directory: %s", _MAX_FILES_PER_DIR, dir_path) + return files, workflows, bundles filepath = Path(dirpath) / filename + count += 1 entry = self._classify_file(filepath) if entry is not None: files.append(entry) - count += 1 - # Check dirnames for workflow bundles (they're directories, not files) - # and prune them + transient/cache subdirectories in a single pass - _skip = {"Caches", "Cache", "Logs", "tmp", "__pycache__"} + # Check dirnames for workflow/bundle directories + # and prune known non-config directories in a single pass kept: list[str] = [] for dirname in dirnames: + sub_path = Path(dirpath) / dirname if dirname.endswith(".workflow"): - wf_path = Path(dirpath) / dirname - wf = self._parse_workflow(wf_path) + wf = self._parse_workflow(sub_path) if wf is not None: workflows.append(wf) - elif dirname not in _skip: + elif any(dirname.endswith(ext) for ext in _BUNDLE_EXTENSIONS): + bundles.append(self._parse_bundle(sub_path)) + elif dirname not in WALK_SKIP_DIRS: kept.append(dirname) dirnames[:] = kept except PermissionError: logger.warning("Permission denied walking: %s", dir_path) - return files, workflows + return files, workflows, bundles def _classify_file(self, filepath: Path) -> LibraryFileEntry | None: """Classify and capture a file from an uncovered directory.""" @@ -235,26 +361,29 @@ def _classify_file(self, filepath: Path) -> LibraryFileEntry | None: size = stat.st_size suffix = filepath.suffix.lower() file_type = suffix.lstrip(".") if suffix else "unknown" - content_hash = hash_file(filepath) plist_content: dict[str, Any] | None = None text_content: str | None = None - strategy = "hash_only" + content_hash: str | None = None + # Only do expensive IO on known config file types if suffix == ".plist": raw_plist = read_plist_safe(filepath) if isinstance(raw_plist, dict): plist_content = raw_plist _redact_sensitive_keys(plist_content) - strategy = "plist_capture" - elif suffix in {".txt", ".md", ".cfg", ".conf", ".ini", ".yaml", ".yml", ".json", ".xml"}: + content_hash = hash_file(filepath) + strategy = "plist_capture" if plist_content else "hash_only" + elif suffix in {".txt", ".md", ".cfg", ".conf", ".ini", ".yaml", ".yml", ".json", ".xml", ".toml"}: + content_hash = hash_file(filepath) if size < 65536: - try: + with contextlib.suppress(OSError): text_content = filepath.read_text(errors="replace") - strategy = "text_capture" - except OSError: - pass + strategy = "text_capture" if text_content else "hash_only" elif suffix in _BUNDLE_EXTENSIONS: strategy = "bundle" + else: + # Non-config file: record path + size only, no IO + strategy = "metadata_only" return LibraryFileEntry( path=filepath, @@ -355,6 +484,7 @@ def _parse_workflow(wf_path: Path) -> WorkflowEntry | None: if doc_plist.is_file(): raw = read_plist_safe(doc_plist) if isinstance(raw, dict): + _redact_sensitive_keys(raw) definition = raw return WorkflowEntry( @@ -371,33 +501,15 @@ def _scan_bundles_in_dir(self, dir_path: Path) -> list[BundleEntry]: bundles: list[BundleEntry] = [] try: for item in sorted(dir_path.iterdir()): - if not item.is_dir(): + if item.is_symlink() or not item.is_dir(): continue - info_plist = item / "Contents" / "Info.plist" - if not info_plist.is_file(): - info_plist = item / "Info.plist" - bundle_id: str | None = None - version: str | None = None - if info_plist.is_file(): - data = read_plist_safe(info_plist) - if isinstance(data, dict): - bundle_id = data.get("CFBundleIdentifier") - version = data.get("CFBundleShortVersionString") - bundles.append( - BundleEntry( - name=item.name, - path=item, - bundle_id=bundle_id, - version=version, - bundle_type=item.suffix.lstrip(".") if item.suffix else None, - ) - ) + bundles.append(self._parse_bundle(item)) except PermissionError: logger.debug("Permission denied reading: %s", dir_path) return bundles @staticmethod - def _scan_file_hashes(dir_path: Path, *extensions: str) -> list[str]: + def _list_files_by_extension(dir_path: Path, *extensions: str) -> list[str]: """Scan files in a directory and return their names.""" if not dir_path.is_dir(): return [] @@ -419,6 +531,9 @@ def _scan_scripts(self, lib_path: Path) -> list[str]: scripts: list[str] = [] try: for f in sorted(scripts_dir.iterdir()): + if len(scripts) >= _MAX_SCRIPTS: + logger.info("Reached %d script cap for: %s", _MAX_SCRIPTS, scripts_dir) + break if f.is_file(): if f.suffix == ".scpt": # Try to decompile AppleScript @@ -449,9 +564,7 @@ def _scan_system_library(self) -> list[BundleEntry]: try: for item in sorted(scan_dir.glob(pattern)): if item.is_dir(): - bundle = self._parse_system_bundle(item) - if bundle is not None: - bundles.append(bundle) + bundles.append(self._parse_bundle(item)) except PermissionError: logger.debug("Permission denied reading: %s", scan_dir) @@ -475,16 +588,14 @@ def _scan_audio_plugins(self, audio_plugins: Path) -> list[BundleEntry]: if subdir.is_dir(): for item in sorted(subdir.iterdir()): if item.is_dir() and item.suffix in _BUNDLE_EXTENSIONS: - bundle = self._parse_system_bundle(item) - if bundle is not None: - bundles.append(bundle) + bundles.append(self._parse_bundle(item)) except PermissionError: pass return bundles @staticmethod - def _parse_system_bundle(item: Path) -> BundleEntry | None: - """Parse a system-level bundle.""" + def _parse_bundle(item: Path) -> BundleEntry: + """Parse a bundle directory, reading Info.plist for metadata.""" info_plist = item / "Contents" / "Info.plist" if not info_plist.is_file(): info_plist = item / "Info.plist" diff --git a/tests/models/test_dotfile.py b/tests/models/test_dotfile.py index a861dd0..997ba5b 100644 --- a/tests/models/test_dotfile.py +++ b/tests/models/test_dotfile.py @@ -1,10 +1,9 @@ -"""Tests for dotfile, app_config, and font models.""" +"""Tests for dotfile, app config entry, and font models.""" from pathlib import Path from mac2nix.models.files import ( AppConfigEntry, - AppConfigResult, ConfigFileType, DotfileEntry, DotfileManager, @@ -92,42 +91,6 @@ def test_defaults(self): assert entry.scannable is True -class TestAppConfigResult: - def test_with_entries(self): - entries = [ - AppConfigEntry( - app_name="VSCode", - path=Path("~/Library/Application Support/Code/settings.json"), - file_type=ConfigFileType.JSON, - ), - AppConfigEntry( - app_name="Safari", - path=Path("~/Library/Safari/History.db"), - file_type=ConfigFileType.DATABASE, - scannable=False, - ), - ] - result = AppConfigResult(entries=entries) - assert len(result.entries) == 2 - - def test_json_roundtrip(self): - original = AppConfigResult( - entries=[ - AppConfigEntry( - app_name="iTerm2", - app_bundle_id="com.googlecode.iterm2", - path=Path("~/Library/Preferences/com.googlecode.iterm2.plist"), - file_type=ConfigFileType.PLIST, - content_hash="def456", - ), - ], - ) - json_str = original.model_dump_json() - restored = AppConfigResult.model_validate_json(json_str) - assert restored.entries[0].app_name == "iTerm2" - assert restored.entries[0].file_type == ConfigFileType.PLIST - - class TestFontEntry: def test_user_source(self): entry = FontEntry( diff --git a/tests/models/test_remaining.py b/tests/models/test_remaining.py index 71d0b8a..8892657 100644 --- a/tests/models/test_remaining.py +++ b/tests/models/test_remaining.py @@ -7,12 +7,14 @@ from mac2nix.models.application import BinarySource, BrewService, PathBinary from mac2nix.models.files import ( + AppConfigEntry, BundleEntry, + ConfigFileType, DotfileEntry, DotfileManager, FontCollection, - LibraryAuditResult, LibraryFileEntry, + LibraryResult, WorkflowEntry, ) from mac2nix.models.hardware import AudioConfig, AudioDevice, DisplayConfig, Monitor, NightShiftConfig @@ -371,9 +373,10 @@ def test_defaults(self) -> None: assert job.trigger_type == "calendar" -class TestLibraryAuditResult: +class TestLibraryResult: def test_all_defaults_empty(self) -> None: - result = LibraryAuditResult() + result = LibraryResult() + assert result.app_configs == [] assert result.bundles == [] assert result.directories == [] assert result.uncovered_files == [] @@ -390,16 +393,45 @@ def test_all_defaults_empty(self) -> None: assert result.system_bundles == [] def test_with_populated_fields(self) -> None: - result = LibraryAuditResult( + result = LibraryResult( + app_configs=[ + AppConfigEntry( + app_name="iTerm2", + path=Path("~/Library/Application Support/iTerm2/settings.json"), + file_type=ConfigFileType.JSON, + ), + ], bundles=[BundleEntry(name="Test.bundle", path=Path("/Library/Bundles/Test.bundle"))], spelling_words=["nix", "darwin"], keyboard_layouts=["US", "Dvorak"], text_replacements=[{"shortcut": "omw", "phrase": "On my way!"}], ) + assert len(result.app_configs) == 1 + assert result.app_configs[0].app_name == "iTerm2" assert len(result.bundles) == 1 assert result.spelling_words == ["nix", "darwin"] assert len(result.text_replacements) == 1 + def test_json_roundtrip_with_app_configs(self) -> None: + original = LibraryResult( + app_configs=[ + AppConfigEntry( + app_name="iTerm2", + app_bundle_id="com.googlecode.iterm2", + path=Path("~/Library/Application Support/iTerm2/settings.json"), + file_type=ConfigFileType.JSON, + content_hash="abc123", + ), + ], + spelling_words=["nix"], + ) + json_str = original.model_dump_json() + restored = LibraryResult.model_validate_json(json_str) + assert len(restored.app_configs) == 1 + assert restored.app_configs[0].app_name == "iTerm2" + assert restored.app_configs[0].file_type == ConfigFileType.JSON + assert restored.spelling_words == ["nix"] + class TestBundleEntry: def test_construction(self) -> None: diff --git a/tests/models/test_system_state.py b/tests/models/test_system_state.py index 7e9443d..d1f0be9 100644 --- a/tests/models/test_system_state.py +++ b/tests/models/test_system_state.py @@ -7,7 +7,7 @@ from mac2nix.models import ( BrewFormula, HomebrewState, - LibraryAuditResult, + LibraryResult, PreferencesDomain, PreferencesResult, SystemState, @@ -104,23 +104,23 @@ def test_with_domain_data(self): assert restored.homebrew is not None assert len(restored.homebrew.formulae) == 2 - def test_library_audit_field(self): + def test_library_field(self): state = SystemState( hostname="test-mac", macos_version="15.3", architecture="arm64", - library_audit=LibraryAuditResult( + library=LibraryResult( spelling_words=["nix", "darwin"], keyboard_layouts=["US"], ), ) - assert state.library_audit is not None - assert state.library_audit.spelling_words == ["nix", "darwin"] + assert state.library is not None + assert state.library.spelling_words == ["nix", "darwin"] - def test_library_audit_default_none(self): + def test_library_default_none(self): state = SystemState( hostname="test-mac", macos_version="15.3", architecture="arm64", ) - assert state.library_audit is None + assert state.library is None diff --git a/tests/scanners/test_app_config.py b/tests/scanners/test_app_config.py deleted file mode 100644 index 21206c9..0000000 --- a/tests/scanners/test_app_config.py +++ /dev/null @@ -1,324 +0,0 @@ -"""Tests for app config scanner.""" - -from pathlib import Path -from unittest.mock import patch - -from mac2nix.models.files import AppConfigResult, ConfigFileType -from mac2nix.scanners.app_config import AppConfigScanner - - -def _setup_app_support(tmp_path: Path) -> Path: - app_support = tmp_path / "Library" / "Application Support" - app_support.mkdir(parents=True) - return app_support - - -class TestAppConfigScanner: - def test_name_property(self) -> None: - assert AppConfigScanner().name == "app_config" - - def test_json_config(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "MyApp" - app_dir.mkdir() - (app_dir / "settings.json").write_text('{"key": "value"}') - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert len(result.entries) == 1 - assert result.entries[0].file_type == ConfigFileType.JSON - assert result.entries[0].app_name == "MyApp" - assert result.entries[0].scannable is True - - def test_plist_config(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "SomeApp" - app_dir.mkdir() - (app_dir / "config.plist").write_text("") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert result.entries[0].file_type == ConfigFileType.PLIST - assert result.entries[0].scannable is True - - def test_database_not_scannable(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "DBApp" - app_dir.mkdir() - (app_dir / "data.sqlite").write_bytes(b"SQLite format 3\x00") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert result.entries[0].file_type == ConfigFileType.DATABASE - assert result.entries[0].scannable is False - - def test_unknown_extension(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "OtherApp" - app_dir.mkdir() - (app_dir / "data.xyz").write_text("unknown format") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert result.entries[0].file_type == ConfigFileType.UNKNOWN - - def test_conf_extension(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "ConfApp" - app_dir.mkdir() - (app_dir / "app.conf").write_text("[section]\nkey=value") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert result.entries[0].file_type == ConfigFileType.CONF - - def test_content_hash_computed(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "HashApp" - app_dir.mkdir() - (app_dir / "config.json").write_text('{"a": 1}') - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert result.entries[0].content_hash is not None - assert len(result.entries[0].content_hash) == 16 - - def test_empty_app_support(self, tmp_path: Path) -> None: - _setup_app_support(tmp_path) - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert result.entries == [] - - def test_database_hash_skipped(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "DBApp" - app_dir.mkdir() - (app_dir / "data.db").write_bytes(b"SQLite format 3\x00" + b"\x00" * 100) - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert len(result.entries) == 1 - assert result.entries[0].file_type == ConfigFileType.DATABASE - assert result.entries[0].scannable is False - assert result.entries[0].content_hash is None - - def test_group_containers(self, tmp_path: Path) -> None: - group_containers = tmp_path / "Library" / "Group Containers" - group_containers.mkdir(parents=True) - app_dir = group_containers / "group.com.example.app" - app_dir.mkdir() - (app_dir / "settings.json").write_text('{"key": "value"}') - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert len(result.entries) == 1 - assert result.entries[0].app_name == "group.com.example.app" - assert result.entries[0].file_type == ConfigFileType.JSON - - def test_yaml_extension(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "YamlApp" - app_dir.mkdir() - (app_dir / "config.yaml").write_text("key: value") - (app_dir / "settings.yml").write_text("other: true") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert len(result.entries) == 2 - assert all(e.file_type == ConfigFileType.YAML for e in result.entries) - - def test_xml_extension(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "XmlApp" - app_dir.mkdir() - (app_dir / "config.xml").write_text("") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert len(result.entries) == 1 - assert result.entries[0].file_type == ConfigFileType.XML - - def test_returns_app_config_result(self, tmp_path: Path) -> None: - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - - def test_containers_app_support(self, tmp_path: Path) -> None: - _setup_app_support(tmp_path) - container = tmp_path / "Library" / "Containers" / "com.test.app" / "Data" / "Library" / "Application Support" - container.mkdir(parents=True) - app_dir = container / "TestApp" - app_dir.mkdir() - (app_dir / "config.json").write_text('{"key": "value"}') - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert len(result.entries) == 1 - assert result.entries[0].app_name == "TestApp" - - def test_skip_dirs_pruned(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "MyApp" - app_dir.mkdir() - (app_dir / "settings.json").write_text("{}") - cache_dir = app_dir / "Caches" - cache_dir.mkdir() - (cache_dir / "cached.json").write_text("{}") - git_dir = app_dir / ".git" - git_dir.mkdir() - (git_dir / "config").write_text("[core]") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - paths = {str(e.path) for e in result.entries} - assert any("settings.json" in p for p in paths) - assert not any("Caches" in p for p in paths) - assert not any(".git" in p for p in paths) - - def test_large_file_skipped(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "BigApp" - app_dir.mkdir() - (app_dir / "small.json").write_text("{}") - big_file = app_dir / "huge.json" - # Write just over 10MB - big_file.write_bytes(b"x" * (10 * 1024 * 1024 + 1)) - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert len(result.entries) == 1 - assert result.entries[0].path.name == "small.json" - - def test_max_files_per_app_cap(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "ManyFilesApp" - app_dir.mkdir() - # Create 501 files to hit the cap (500) - for i in range(501): - (app_dir / f"file{i:04d}.json").write_text("{}") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - app_entries = [e for e in result.entries if e.app_name == "ManyFilesApp"] - assert len(app_entries) == 500 - - def test_toml_extension(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "TomlApp" - app_dir.mkdir() - (app_dir / "config.toml").write_text("[section]\nkey = 'value'") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert result.entries[0].file_type == ConfigFileType.TOML - - def test_ini_extension(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "IniApp" - app_dir.mkdir() - (app_dir / "config.ini").write_text("[section]\nkey=value") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert result.entries[0].file_type == ConfigFileType.CONF - - def test_cfg_extension(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "CfgApp" - app_dir.mkdir() - (app_dir / "app.cfg").write_text("key=value") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert result.entries[0].file_type == ConfigFileType.CONF - - def test_sqlite3_extension(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "Sqlite3App" - app_dir.mkdir() - (app_dir / "data.sqlite3").write_bytes(b"SQLite format 3\x00") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert result.entries[0].file_type == ConfigFileType.DATABASE - assert result.entries[0].scannable is False - - def test_nested_config_files(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "Chrome" - profile = app_dir / "Default" - profile.mkdir(parents=True) - (profile / "Preferences").write_text('{"key": "value"}') - (app_dir / "Local State").write_text('{"other": true}') - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert len(result.entries) == 2 - - def test_modified_time_set(self, tmp_path: Path) -> None: - app_support = _setup_app_support(tmp_path) - app_dir = app_support / "TimeApp" - app_dir.mkdir() - (app_dir / "config.json").write_text("{}") - - with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) - assert result.entries[0].modified_time is not None - - def test_permission_denied_containers(self, tmp_path: Path) -> None: - _setup_app_support(tmp_path) - containers = tmp_path / "Library" / "Containers" - containers.mkdir(parents=True) - - with ( - patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path), - patch("pathlib.Path.iterdir", side_effect=PermissionError("denied")), - ): - # Should not crash — gracefully handles permission error - result = AppConfigScanner().scan() - - assert isinstance(result, AppConfigResult) diff --git a/tests/scanners/test_library_audit.py b/tests/scanners/test_library_audit.py deleted file mode 100644 index 168b467..0000000 --- a/tests/scanners/test_library_audit.py +++ /dev/null @@ -1,512 +0,0 @@ -"""Tests for library audit scanner.""" - -import sqlite3 -from pathlib import Path -from unittest.mock import MagicMock, patch - -from mac2nix.models.files import LibraryAuditResult -from mac2nix.scanners.library_audit import ( - _COVERED_DIRS, - _TRANSIENT_DIRS, - LibraryAuditScanner, - _redact_sensitive_keys, -) - - -class TestLibraryAuditScanner: - def test_name_property(self) -> None: - assert LibraryAuditScanner().name == "library_audit" - - def test_returns_library_audit_result(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - lib.mkdir() - with ( - patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), - patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), - ): - result = LibraryAuditScanner().scan() - - assert isinstance(result, LibraryAuditResult) - - def test_audit_directories_covered(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - lib.mkdir() - prefs = lib / "Preferences" - prefs.mkdir() - (prefs / "com.apple.finder.plist").write_bytes(b"data") - - with ( - patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), - patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), - ): - result = LibraryAuditScanner().scan() - - pref_dir = next(d for d in result.directories if d.name == "Preferences") - assert pref_dir.covered_by_scanner == "preferences" - assert pref_dir.has_user_content is False - - def test_audit_directories_uncovered(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - lib.mkdir() - custom = lib / "CustomDir" - custom.mkdir() - (custom / "file.txt").write_text("hello") - - with ( - patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), - patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), - ): - result = LibraryAuditScanner().scan() - - custom_dir = next(d for d in result.directories if d.name == "CustomDir") - assert custom_dir.covered_by_scanner is None - assert custom_dir.has_user_content is True - - def test_audit_directories_transient_not_user_content(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - lib.mkdir() - caches = lib / "Caches" - caches.mkdir() - (caches / "something.cache").write_bytes(b"data") - - with ( - patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), - patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), - ): - result = LibraryAuditScanner().scan() - - cache_dir = next(d for d in result.directories if d.name == "Caches") - assert cache_dir.covered_by_scanner is None - assert cache_dir.has_user_content is False - - def test_dir_stats(self, tmp_path: Path) -> None: - (tmp_path / "file1.txt").write_text("hello") - (tmp_path / "file2.txt").write_text("world!") - - file_count, total_size, newest_mod = LibraryAuditScanner._dir_stats(tmp_path) - - assert file_count == 2 - assert total_size is not None - assert total_size > 0 - assert newest_mod is not None - - def test_dir_stats_permission_denied(self, tmp_path: Path) -> None: - protected = tmp_path / "protected" - protected.mkdir() - - with patch.object(Path, "iterdir", side_effect=PermissionError("denied")): - file_count, total_size, newest_mod = LibraryAuditScanner._dir_stats(protected) - - assert file_count is None - assert total_size is None - assert newest_mod is None - - def test_classify_file_plist(self, tmp_path: Path) -> None: - plist_file = tmp_path / "test.plist" - plist_file.write_bytes(b"data") - - with ( - patch("mac2nix.scanners.library_audit.read_plist_safe", return_value={"key": "value"}), - patch("mac2nix.scanners.library_audit.hash_file", return_value="abc123"), - ): - entry = LibraryAuditScanner()._classify_file(plist_file) - - assert entry is not None - assert entry.file_type == "plist" - assert entry.migration_strategy == "plist_capture" - assert entry.plist_content == {"key": "value"} - - def test_classify_file_text(self, tmp_path: Path) -> None: - txt_file = tmp_path / "readme.txt" - txt_file.write_text("some text content") - - with patch("mac2nix.scanners.library_audit.hash_file", return_value="def456"): - entry = LibraryAuditScanner()._classify_file(txt_file) - - assert entry is not None - assert entry.file_type == "txt" - assert entry.migration_strategy == "text_capture" - assert entry.text_content == "some text content" - - def test_classify_file_text_too_large(self, tmp_path: Path) -> None: - large_file = tmp_path / "big.txt" - large_file.write_text("x" * 70000) - - with patch("mac2nix.scanners.library_audit.hash_file", return_value="abc"): - entry = LibraryAuditScanner()._classify_file(large_file) - - assert entry is not None - assert entry.migration_strategy == "hash_only" - assert entry.text_content is None - - def test_classify_file_bundle_extension(self, tmp_path: Path) -> None: - bundle = tmp_path / "plugin.component" - bundle.write_bytes(b"data") - - with patch("mac2nix.scanners.library_audit.hash_file", return_value="hash"): - entry = LibraryAuditScanner()._classify_file(bundle) - - assert entry is not None - assert entry.migration_strategy == "bundle" - - def test_classify_file_unknown(self, tmp_path: Path) -> None: - binary_file = tmp_path / "data.bin" - binary_file.write_bytes(b"\x00\x01\x02") - - with patch("mac2nix.scanners.library_audit.hash_file", return_value="hash"): - entry = LibraryAuditScanner()._classify_file(binary_file) - - assert entry is not None - assert entry.migration_strategy == "hash_only" - - def test_key_bindings(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - kb_dir = lib / "KeyBindings" - kb_dir.mkdir(parents=True) - kb_file = kb_dir / "DefaultKeyBinding.dict" - kb_file.write_bytes(b"dummy") - - with patch( - "mac2nix.scanners.library_audit.read_plist_safe", - return_value={"^w": "deleteWordBackward:", "~f": "moveWordForward:"}, - ): - result = LibraryAuditScanner()._scan_key_bindings(lib) - - assert len(result) == 2 - keys = {e.key for e in result} - assert "^w" in keys - assert "~f" in keys - - def test_key_bindings_no_file(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - lib.mkdir() - result = LibraryAuditScanner()._scan_key_bindings(lib) - assert result == [] - - def test_spelling_words(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - spelling = lib / "Spelling" - spelling.mkdir(parents=True) - local_dict = spelling / "LocalDictionary" - local_dict.write_text("nix\ndarwin\nhomebrew\n") - (spelling / "en_US").write_text("") - - words, dicts = LibraryAuditScanner()._scan_spelling(lib) - - assert words == ["nix", "darwin", "homebrew"] - assert "en_US" in dicts - - def test_spelling_no_dir(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - lib.mkdir() - words, dicts = LibraryAuditScanner()._scan_spelling(lib) - assert words == [] - assert dicts == [] - - def test_scan_workflows(self, tmp_path: Path) -> None: - wf_dir = tmp_path / "Services" - wf = wf_dir / "MyService.workflow" - contents = wf / "Contents" - contents.mkdir(parents=True) - info = contents / "Info.plist" - info.write_bytes(b"dummy") - - with patch( - "mac2nix.scanners.library_audit.read_plist_safe", - return_value={"CFBundleIdentifier": "com.example.myservice"}, - ): - result = LibraryAuditScanner()._scan_workflows(wf_dir) - - assert len(result) == 1 - assert result[0].name == "MyService" - assert result[0].identifier == "com.example.myservice" - - def test_scan_workflows_no_dir(self, tmp_path: Path) -> None: - result = LibraryAuditScanner()._scan_workflows(tmp_path / "nonexistent") - assert result == [] - - def test_scan_bundles_in_dir(self, tmp_path: Path) -> None: - im_dir = tmp_path / "Input Methods" - bundle = im_dir / "MyInput.app" - contents = bundle / "Contents" - contents.mkdir(parents=True) - info = contents / "Info.plist" - info.write_bytes(b"dummy") - - with patch( - "mac2nix.scanners.library_audit.read_plist_safe", - return_value={ - "CFBundleIdentifier": "com.example.input", - "CFBundleShortVersionString": "1.0", - }, - ): - result = LibraryAuditScanner()._scan_bundles_in_dir(im_dir) - - assert len(result) == 1 - assert result[0].bundle_id == "com.example.input" - assert result[0].version == "1.0" - - def test_scan_bundles_no_dir(self) -> None: - result = LibraryAuditScanner()._scan_bundles_in_dir(Path("/nonexistent")) - assert result == [] - - def test_scan_file_hashes(self, tmp_path: Path) -> None: - (tmp_path / "layout1.keylayout").write_text("xml") - (tmp_path / "layout2.keylayout").write_text("xml") - (tmp_path / "other.txt").write_text("ignored") - - result = LibraryAuditScanner._scan_file_hashes(tmp_path, ".keylayout") - - assert len(result) == 2 - assert "layout1.keylayout" in result - assert "layout2.keylayout" in result - - def test_scan_file_hashes_no_dir(self) -> None: - result = LibraryAuditScanner._scan_file_hashes(Path("/nonexistent"), ".icc") - assert result == [] - - def test_scan_scripts_with_applescript(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - scripts = lib / "Scripts" - scripts.mkdir(parents=True) - scpt = scripts / "hello.scpt" - scpt.write_bytes(b"compiled") - sh = scripts / "cleanup.sh" - sh.write_text("#!/bin/bash\necho cleanup") - - with patch( - "mac2nix.scanners.library_audit.run_command", - return_value=MagicMock(returncode=0, stdout='display dialog "Hello"'), - ): - result = LibraryAuditScanner()._scan_scripts(lib) - - assert len(result) == 2 - script_names = [s.split(":")[0] if ":" in s else s for s in result] - assert "cleanup.sh" in script_names - assert "hello.scpt" in script_names - - def test_scan_scripts_applescript_decompile_fails(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - scripts = lib / "Scripts" - scripts.mkdir(parents=True) - scpt = scripts / "broken.scpt" - scpt.write_bytes(b"compiled") - - with patch("mac2nix.scanners.library_audit.run_command", return_value=None): - result = LibraryAuditScanner()._scan_scripts(lib) - - assert result == ["broken.scpt"] - - def test_scan_scripts_no_dir(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - lib.mkdir() - result = LibraryAuditScanner()._scan_scripts(lib) - assert result == [] - - def test_text_replacements(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - ks_dir = lib / "KeyboardServices" - ks_dir.mkdir(parents=True) - db_path = ks_dir / "TextReplacements.db" - db_path.write_bytes(b"dummy") - - mock_rows = [("omw", "On my way!"), ("addr", "123 Main St")] - mock_cursor = type("MockCursor", (), {"fetchall": lambda _self: mock_rows})() - mock_conn = type( - "MockConn", - (), - { - "execute": lambda _self, _query: mock_cursor, - "close": lambda _self: None, - }, - )() - - with patch("mac2nix.scanners.library_audit.sqlite3.connect", return_value=mock_conn): - result = LibraryAuditScanner()._scan_text_replacements(lib) - - assert len(result) == 2 - assert result[0] == {"shortcut": "omw", "phrase": "On my way!"} - - def test_text_replacements_no_db(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - lib.mkdir() - result = LibraryAuditScanner()._scan_text_replacements(lib) - assert result == [] - - def test_capture_uncovered_dir_capped(self, tmp_path: Path) -> None: - for i in range(210): - (tmp_path / f"file{i:03d}.txt").write_text(f"content {i}") - - with ( - patch("mac2nix.scanners.library_audit.hash_file", return_value="hash"), - patch("mac2nix.scanners.library_audit.read_plist_safe", return_value=None), - ): - files, _workflows = LibraryAuditScanner()._capture_uncovered_dir(tmp_path) - - assert len(files) <= 200 - - def test_uncovered_files_collected(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - lib.mkdir() - custom = lib / "CustomStuff" - custom.mkdir() - (custom / "config.json").write_text('{"key": "value"}') - - with ( - patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), - patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), - patch("mac2nix.scanners.library_audit.hash_file", return_value="hash"), - patch("mac2nix.scanners.library_audit.read_plist_safe", return_value=None), - ): - result = LibraryAuditScanner().scan() - - assert len(result.uncovered_files) >= 1 - json_file = next(f for f in result.uncovered_files if "config.json" in str(f.path)) - assert json_file.file_type == "json" - - def test_workflows_from_services_dir(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - lib.mkdir() - services = lib / "Services" - wf = services / "Convert.workflow" - contents = wf / "Contents" - contents.mkdir(parents=True) - (contents / "Info.plist").write_bytes(b"dummy") - - with ( - patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), - patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), - patch( - "mac2nix.scanners.library_audit.read_plist_safe", - return_value={"CFBundleIdentifier": "com.example.convert"}, - ), - ): - result = LibraryAuditScanner().scan() - - assert any(w.name == "Convert" for w in result.workflows) - - def test_empty_library(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - lib.mkdir() - - with ( - patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), - patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), - ): - result = LibraryAuditScanner().scan() - - assert isinstance(result, LibraryAuditResult) - assert result.directories == [] - assert result.uncovered_files == [] - - def test_no_library_dir(self, tmp_path: Path) -> None: - with ( - patch("mac2nix.scanners.library_audit.Path.home", return_value=tmp_path), - patch.object(LibraryAuditScanner, "_scan_system_library", return_value=[]), - ): - result = LibraryAuditScanner().scan() - - assert isinstance(result, LibraryAuditResult) - assert result.directories == [] - - -class TestRedactSensitiveKeys: - def test_redacts_api_key(self) -> None: - data = {"API_KEY": "secret123", "name": "test"} - _redact_sensitive_keys(data) - - redacted = "***REDACTED***" - assert data["API_KEY"] == redacted - assert data["name"] == "test" - - def test_redacts_nested_dict(self) -> None: - data = {"config": {"DB_PASSWORD": "secret", "host": "localhost"}} - _redact_sensitive_keys(data) - - redacted = "***REDACTED***" - assert data["config"]["DB_PASSWORD"] == redacted - assert data["config"]["host"] == "localhost" - - def test_redacts_in_list(self) -> None: - data = {"items": [{"ACCESS_TOKEN": "token123"}, {"normal": "value"}]} - _redact_sensitive_keys(data) - - redacted = "***REDACTED***" - assert data["items"][0]["ACCESS_TOKEN"] == redacted - assert data["items"][1]["normal"] == "value" - - def test_case_insensitive_match(self) -> None: - data = {"my_auth_header": "Bearer xyz"} - _redact_sensitive_keys(data) - - redacted = "***REDACTED***" - assert data["my_auth_header"] == redacted - - def test_no_sensitive_keys(self) -> None: - data = {"name": "test", "count": 42} - _redact_sensitive_keys(data) - assert data == {"name": "test", "count": 42} - - -class TestCoveredDirsMapping: - def test_known_covered_dirs(self) -> None: - assert _COVERED_DIRS["Preferences"] == "preferences" - assert _COVERED_DIRS["Application Support"] == "app_config" - assert _COVERED_DIRS["LaunchAgents"] == "launch_agents" - assert _COVERED_DIRS["Fonts"] == "fonts" - - def test_transient_dirs(self) -> None: - assert "Caches" in _TRANSIENT_DIRS - assert "Logs" in _TRANSIENT_DIRS - assert "Saved Application State" in _TRANSIENT_DIRS - - -class TestScanAudioPlugins: - def test_finds_component_bundles(self, tmp_path: Path) -> None: - components = tmp_path / "Components" - components.mkdir() - plugin = components / "MyPlugin.component" - plugin.mkdir() - info = plugin / "Contents" / "Info.plist" - info.parent.mkdir() - info.write_bytes(b"dummy") - - with patch( - "mac2nix.scanners.library_audit.read_plist_safe", - return_value={"CFBundleIdentifier": "com.test.plugin", "CFBundleShortVersionString": "1.0"}, - ): - result = LibraryAuditScanner()._scan_audio_plugins(tmp_path) - - assert len(result) == 1 - assert result[0].name == "MyPlugin.component" - assert result[0].bundle_id == "com.test.plugin" - - def test_skips_non_bundle_dirs(self, tmp_path: Path) -> None: - components = tmp_path / "Components" - components.mkdir() - regular_dir = components / "NotABundle" - regular_dir.mkdir() - - result = LibraryAuditScanner()._scan_audio_plugins(tmp_path) - assert result == [] - - def test_empty_audio_dir(self, tmp_path: Path) -> None: - result = LibraryAuditScanner()._scan_audio_plugins(tmp_path / "nonexistent") - assert result == [] - - -class TestTextReplacementsCorrupted: - def test_corrupted_db_returns_empty(self, tmp_path: Path) -> None: - lib = tmp_path / "Library" - ks_dir = lib / "KeyboardServices" - ks_dir.mkdir(parents=True) - db_path = ks_dir / "TextReplacements.db" - db_path.write_bytes(b"not a sqlite database") - - with patch( - "mac2nix.scanners.library_audit.sqlite3.connect", - side_effect=sqlite3.OperationalError("not a database"), - ): - result = LibraryAuditScanner()._scan_text_replacements(lib) - - assert result == [] diff --git a/tests/scanners/test_library_scanner.py b/tests/scanners/test_library_scanner.py new file mode 100644 index 0000000..844be6e --- /dev/null +++ b/tests/scanners/test_library_scanner.py @@ -0,0 +1,1138 @@ +"""Tests for library scanner.""" + +import sqlite3 +from pathlib import Path +from unittest.mock import MagicMock, patch + +from mac2nix.models.files import ConfigFileType, LibraryResult +from mac2nix.scanners.library_scanner import ( + _COVERED_DIRS, + _TRANSIENT_DIRS, + LibraryScanner, + _redact_sensitive_keys, +) + + +def _setup_app_support(tmp_path: Path) -> Path: + app_support = tmp_path / "Library" / "Application Support" + app_support.mkdir(parents=True) + return app_support + + +class TestLibraryScanner: + def test_name_property(self) -> None: + assert LibraryScanner().name == "library" + + # --- App config tests (via scan()) --- + + def test_json_config(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "MyApp" + app_dir.mkdir() + (app_dir / "settings.json").write_text('{"key": "value"}') + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert len(result.app_configs) == 1 + assert result.app_configs[0].file_type == ConfigFileType.JSON + assert result.app_configs[0].app_name == "MyApp" + assert result.app_configs[0].scannable is True + + def test_plist_config(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "SomeApp" + app_dir.mkdir() + (app_dir / "config.plist").write_text("") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.app_configs[0].file_type == ConfigFileType.PLIST + assert result.app_configs[0].scannable is True + + def test_database_not_scannable(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "DBApp" + app_dir.mkdir() + (app_dir / "data.sqlite").write_bytes(b"SQLite format 3\x00") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.app_configs[0].file_type == ConfigFileType.DATABASE + assert result.app_configs[0].scannable is False + + def test_unknown_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "OtherApp" + app_dir.mkdir() + (app_dir / "data.xyz").write_text("unknown format") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.app_configs[0].file_type == ConfigFileType.UNKNOWN + + def test_conf_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "ConfApp" + app_dir.mkdir() + (app_dir / "app.conf").write_text("[section]\nkey=value") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.app_configs[0].file_type == ConfigFileType.CONF + + def test_content_hash_computed(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "HashApp" + app_dir.mkdir() + (app_dir / "config.json").write_text('{"a": 1}') + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.app_configs[0].content_hash is not None + assert len(result.app_configs[0].content_hash) == 16 + + def test_empty_app_support(self, tmp_path: Path) -> None: + _setup_app_support(tmp_path) + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.app_configs == [] + + def test_database_hash_skipped(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "DBApp" + app_dir.mkdir() + (app_dir / "data.db").write_bytes(b"SQLite format 3\x00" + b"\x00" * 100) + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert len(result.app_configs) == 1 + assert result.app_configs[0].file_type == ConfigFileType.DATABASE + assert result.app_configs[0].scannable is False + assert result.app_configs[0].content_hash is None + + def test_group_containers(self, tmp_path: Path) -> None: + group_containers = tmp_path / "Library" / "Group Containers" + group_containers.mkdir(parents=True) + app_dir = group_containers / "group.com.example.app" + app_dir.mkdir() + (app_dir / "settings.json").write_text('{"key": "value"}') + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert len(result.app_configs) == 1 + assert result.app_configs[0].app_name == "group.com.example.app" + assert result.app_configs[0].file_type == ConfigFileType.JSON + + def test_yaml_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "YamlApp" + app_dir.mkdir() + (app_dir / "config.yaml").write_text("key: value") + (app_dir / "settings.yml").write_text("other: true") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert len(result.app_configs) == 2 + assert all(e.file_type == ConfigFileType.YAML for e in result.app_configs) + + def test_xml_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "XmlApp" + app_dir.mkdir() + (app_dir / "config.xml").write_text("") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert len(result.app_configs) == 1 + assert result.app_configs[0].file_type == ConfigFileType.XML + + def test_returns_library_result(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + + def test_containers_app_support(self, tmp_path: Path) -> None: + _setup_app_support(tmp_path) + container = tmp_path / "Library" / "Containers" / "com.test.app" / "Data" / "Library" / "Application Support" + container.mkdir(parents=True) + app_dir = container / "TestApp" + app_dir.mkdir() + (app_dir / "config.json").write_text('{"key": "value"}') + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert len(result.app_configs) == 1 + assert result.app_configs[0].app_name == "TestApp" + + def test_skip_dirs_pruned(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "MyApp" + app_dir.mkdir() + (app_dir / "settings.json").write_text("{}") + cache_dir = app_dir / "Caches" + cache_dir.mkdir() + (cache_dir / "cached.json").write_text("{}") + git_dir = app_dir / ".git" + git_dir.mkdir() + (git_dir / "config").write_text("[core]") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + paths = {str(e.path) for e in result.app_configs} + assert any("settings.json" in p for p in paths) + assert not any("Caches" in p for p in paths) + assert not any(".git" in p for p in paths) + + def test_large_file_skipped(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "BigApp" + app_dir.mkdir() + (app_dir / "small.json").write_text("{}") + big_file = app_dir / "huge.json" + # Write just over 10MB + big_file.write_bytes(b"x" * (10 * 1024 * 1024 + 1)) + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert len(result.app_configs) == 1 + assert result.app_configs[0].path.name == "small.json" + + def test_max_files_per_app_cap(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "ManyFilesApp" + app_dir.mkdir() + for i in range(501): + (app_dir / f"file{i:04d}.json").write_text("{}") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + app_entries = [e for e in result.app_configs if e.app_name == "ManyFilesApp"] + assert len(app_entries) == 500 + + def test_skips_non_config_dirs(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "TestApp" + app_dir.mkdir() + (app_dir / "config.json").write_text("{}") + for skip_name in ["node_modules", ".git", "Caches"]: + skip_dir = app_dir / skip_name + skip_dir.mkdir() + (skip_dir / "junk.json").write_text("{}") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + app_entries = [e for e in result.app_configs if e.app_name == "TestApp"] + paths = {str(e.path) for e in app_entries} + assert any("config.json" in p for p in paths) + assert not any("junk.json" in p for p in paths) + + def test_toml_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "TomlApp" + app_dir.mkdir() + (app_dir / "config.toml").write_text("[section]\nkey = 'value'") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.app_configs[0].file_type == ConfigFileType.TOML + + def test_ini_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "IniApp" + app_dir.mkdir() + (app_dir / "config.ini").write_text("[section]\nkey=value") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.app_configs[0].file_type == ConfigFileType.CONF + + def test_cfg_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "CfgApp" + app_dir.mkdir() + (app_dir / "app.cfg").write_text("key=value") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.app_configs[0].file_type == ConfigFileType.CONF + + def test_sqlite3_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "Sqlite3App" + app_dir.mkdir() + (app_dir / "data.sqlite3").write_bytes(b"SQLite format 3\x00") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.app_configs[0].file_type == ConfigFileType.DATABASE + assert result.app_configs[0].scannable is False + + def test_nested_config_files(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "Chrome" + profile = app_dir / "Default" + profile.mkdir(parents=True) + (profile / "Preferences").write_text('{"key": "value"}') + (app_dir / "Local State").write_text('{"other": true}') + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert len(result.app_configs) == 2 + + def test_modified_time_set(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "TimeApp" + app_dir.mkdir() + (app_dir / "config.json").write_text("{}") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.app_configs[0].modified_time is not None + + def test_permission_denied_containers(self, tmp_path: Path) -> None: + _setup_app_support(tmp_path) + containers = tmp_path / "Library" / "Containers" + containers.mkdir(parents=True) + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch("pathlib.Path.iterdir", side_effect=PermissionError("denied")), + ): + # Should not crash — gracefully handles permission error + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + + # --- Library audit tests --- + + def test_audit_directories_covered(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + prefs = lib / "Preferences" + prefs.mkdir() + (prefs / "com.apple.finder.plist").write_bytes(b"data") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + pref_dir = next(d for d in result.directories if d.name == "Preferences") + assert pref_dir.covered_by_scanner == "preferences" + assert pref_dir.has_user_content is False + + def test_audit_directories_uncovered(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + custom = lib / "CustomDir" + custom.mkdir() + (custom / "file.txt").write_text("hello") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + custom_dir = next(d for d in result.directories if d.name == "CustomDir") + assert custom_dir.covered_by_scanner is None + assert custom_dir.has_user_content is True + + def test_audit_directories_transient_not_user_content(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + caches = lib / "Caches" + caches.mkdir() + (caches / "something.cache").write_bytes(b"data") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + cache_dir = next(d for d in result.directories if d.name == "Caches") + assert cache_dir.covered_by_scanner is None + assert cache_dir.has_user_content is False + + def test_dir_stats(self, tmp_path: Path) -> None: + (tmp_path / "file1.txt").write_text("hello") + (tmp_path / "file2.txt").write_text("world!") + + file_count, total_size, newest_mod = LibraryScanner._dir_stats(tmp_path) + + assert file_count == 2 + assert total_size is not None + assert total_size > 0 + assert newest_mod is not None + + def test_dir_stats_permission_denied(self, tmp_path: Path) -> None: + protected = tmp_path / "protected" + protected.mkdir() + + with patch.object(Path, "iterdir", side_effect=PermissionError("denied")): + file_count, total_size, newest_mod = LibraryScanner._dir_stats(protected) + + assert file_count is None + assert total_size is None + assert newest_mod is None + + def test_classify_file_plist(self, tmp_path: Path) -> None: + plist_file = tmp_path / "test.plist" + plist_file.write_bytes(b"data") + + with ( + patch("mac2nix.scanners.library_scanner.read_plist_safe", return_value={"key": "value"}), + patch("mac2nix.scanners.library_scanner.hash_file", return_value="abc123"), + ): + entry = LibraryScanner()._classify_file(plist_file) + + assert entry is not None + assert entry.file_type == "plist" + assert entry.migration_strategy == "plist_capture" + assert entry.plist_content == {"key": "value"} + + def test_classify_file_text(self, tmp_path: Path) -> None: + txt_file = tmp_path / "readme.txt" + txt_file.write_text("some text content") + + with patch("mac2nix.scanners.library_scanner.hash_file", return_value="def456"): + entry = LibraryScanner()._classify_file(txt_file) + + assert entry is not None + assert entry.file_type == "txt" + assert entry.migration_strategy == "text_capture" + assert entry.text_content == "some text content" + + def test_classify_file_text_too_large(self, tmp_path: Path) -> None: + large_file = tmp_path / "big.txt" + large_file.write_text("x" * 70000) + + with patch("mac2nix.scanners.library_scanner.hash_file", return_value="abc"): + entry = LibraryScanner()._classify_file(large_file) + + assert entry is not None + assert entry.migration_strategy == "hash_only" + assert entry.text_content is None + + def test_classify_file_bundle_extension(self, tmp_path: Path) -> None: + bundle = tmp_path / "plugin.component" + bundle.write_bytes(b"data") + + with patch("mac2nix.scanners.library_scanner.hash_file", return_value="hash"): + entry = LibraryScanner()._classify_file(bundle) + + assert entry is not None + assert entry.migration_strategy == "bundle" + + def test_classify_file_unknown(self, tmp_path: Path) -> None: + binary_file = tmp_path / "data.bin" + binary_file.write_bytes(b"\x00\x01\x02") + + entry = LibraryScanner()._classify_file(binary_file) + + assert entry is not None + assert entry.migration_strategy == "metadata_only" + assert entry.content_hash is None + + def test_key_bindings(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + kb_dir = lib / "KeyBindings" + kb_dir.mkdir(parents=True) + kb_file = kb_dir / "DefaultKeyBinding.dict" + kb_file.write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_scanner.read_plist_safe", + return_value={"^w": "deleteWordBackward:", "~f": "moveWordForward:"}, + ): + result = LibraryScanner()._scan_key_bindings(lib) + + assert len(result) == 2 + keys = {e.key for e in result} + assert "^w" in keys + assert "~f" in keys + + def test_key_bindings_no_file(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + result = LibraryScanner()._scan_key_bindings(lib) + assert result == [] + + def test_spelling_words(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + spelling = lib / "Spelling" + spelling.mkdir(parents=True) + local_dict = spelling / "LocalDictionary" + local_dict.write_text("nix\ndarwin\nhomebrew\n") + (spelling / "en_US").write_text("") + + words, dicts = LibraryScanner()._scan_spelling(lib) + + assert words == ["nix", "darwin", "homebrew"] + assert "en_US" in dicts + + def test_spelling_no_dir(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + words, dicts = LibraryScanner()._scan_spelling(lib) + assert words == [] + assert dicts == [] + + def test_scan_workflows(self, tmp_path: Path) -> None: + wf_dir = tmp_path / "Services" + wf = wf_dir / "MyService.workflow" + contents = wf / "Contents" + contents.mkdir(parents=True) + info = contents / "Info.plist" + info.write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_scanner.read_plist_safe", + return_value={"CFBundleIdentifier": "com.example.myservice"}, + ): + result = LibraryScanner()._scan_workflows(wf_dir) + + assert len(result) == 1 + assert result[0].name == "MyService" + assert result[0].identifier == "com.example.myservice" + + def test_scan_workflows_no_dir(self, tmp_path: Path) -> None: + result = LibraryScanner()._scan_workflows(tmp_path / "nonexistent") + assert result == [] + + def test_scan_bundles_in_dir(self, tmp_path: Path) -> None: + im_dir = tmp_path / "Input Methods" + bundle = im_dir / "MyInput.app" + contents = bundle / "Contents" + contents.mkdir(parents=True) + info = contents / "Info.plist" + info.write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_scanner.read_plist_safe", + return_value={ + "CFBundleIdentifier": "com.example.input", + "CFBundleShortVersionString": "1.0", + }, + ): + result = LibraryScanner()._scan_bundles_in_dir(im_dir) + + assert len(result) == 1 + assert result[0].bundle_id == "com.example.input" + assert result[0].version == "1.0" + + def test_scan_bundles_no_dir(self) -> None: + result = LibraryScanner()._scan_bundles_in_dir(Path("/nonexistent")) + assert result == [] + + def test_list_files_by_extension(self, tmp_path: Path) -> None: + (tmp_path / "layout1.keylayout").write_text("xml") + (tmp_path / "layout2.keylayout").write_text("xml") + (tmp_path / "other.txt").write_text("ignored") + + result = LibraryScanner._list_files_by_extension(tmp_path, ".keylayout") + + assert len(result) == 2 + assert "layout1.keylayout" in result + assert "layout2.keylayout" in result + + def test_list_files_by_extension_no_dir(self) -> None: + result = LibraryScanner._list_files_by_extension(Path("/nonexistent"), ".icc") + assert result == [] + + def test_scan_scripts_with_applescript(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + scripts = lib / "Scripts" + scripts.mkdir(parents=True) + scpt = scripts / "hello.scpt" + scpt.write_bytes(b"compiled") + sh = scripts / "cleanup.sh" + sh.write_text("#!/bin/bash\necho cleanup") + + with patch( + "mac2nix.scanners.library_scanner.run_command", + return_value=MagicMock(returncode=0, stdout='display dialog "Hello"'), + ): + result = LibraryScanner()._scan_scripts(lib) + + assert len(result) == 2 + script_names = [s.split(":")[0] if ":" in s else s for s in result] + assert "cleanup.sh" in script_names + assert "hello.scpt" in script_names + + def test_scan_scripts_applescript_decompile_fails(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + scripts = lib / "Scripts" + scripts.mkdir(parents=True) + scpt = scripts / "broken.scpt" + scpt.write_bytes(b"compiled") + + with patch("mac2nix.scanners.library_scanner.run_command", return_value=None): + result = LibraryScanner()._scan_scripts(lib) + + assert result == ["broken.scpt"] + + def test_scan_scripts_no_dir(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + result = LibraryScanner()._scan_scripts(lib) + assert result == [] + + def test_text_replacements(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + ks_dir = lib / "KeyboardServices" + ks_dir.mkdir(parents=True) + db_path = ks_dir / "TextReplacements.db" + db_path.write_bytes(b"dummy") + + mock_rows = [("omw", "On my way!"), ("addr", "123 Main St")] + mock_cursor = type("MockCursor", (), {"fetchall": lambda _self: mock_rows})() + mock_conn = type( + "MockConn", + (), + { + "execute": lambda _self, _query: mock_cursor, + "close": lambda _self: None, + }, + )() + + with patch("mac2nix.scanners.library_scanner.sqlite3.connect", return_value=mock_conn): + result = LibraryScanner()._scan_text_replacements(lib) + + assert len(result) == 2 + assert result[0] == {"shortcut": "omw", "phrase": "On my way!"} + + def test_text_replacements_no_db(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + result = LibraryScanner()._scan_text_replacements(lib) + assert result == [] + + def test_capture_uncovered_dir_walks_below_cap(self, tmp_path: Path) -> None: + for i in range(210): + (tmp_path / f"file{i:03d}.txt").write_text(f"content {i}") + + with ( + patch("mac2nix.scanners.library_scanner.hash_file", return_value="hash"), + patch("mac2nix.scanners.library_scanner.read_plist_safe", return_value=None), + ): + files, _workflows, _bundles = LibraryScanner()._capture_uncovered_dir(tmp_path) + + assert len(files) == 210 # all files captured (below 1000 cap) + + def test_capture_uncovered_dir_skips_non_config_dirs(self, tmp_path: Path) -> None: + config_dir = tmp_path / "real_config" + config_dir.mkdir() + (config_dir / "settings.json").write_text("{}") + + for skip_name in ["node_modules", ".git", "Caches", "DerivedData"]: + skip_dir = tmp_path / skip_name + skip_dir.mkdir() + (skip_dir / "junk.txt").write_text("should be skipped") + + with ( + patch("mac2nix.scanners.library_scanner.hash_file", return_value="hash"), + patch("mac2nix.scanners.library_scanner.read_plist_safe", return_value=None), + ): + files, _workflows, _bundles = LibraryScanner()._capture_uncovered_dir(tmp_path) + + paths = {str(f.path) for f in files} + assert any("settings.json" in p for p in paths) + assert not any("junk.txt" in p for p in paths) + + def test_uncovered_files_collected(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + custom = lib / "CustomStuff" + custom.mkdir() + (custom / "config.json").write_text('{"key": "value"}') + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + patch("mac2nix.scanners.library_scanner.hash_file", return_value="hash"), + patch("mac2nix.scanners.library_scanner.read_plist_safe", return_value=None), + ): + result = LibraryScanner().scan() + + assert len(result.uncovered_files) >= 1 + json_file = next(f for f in result.uncovered_files if "config.json" in str(f.path)) + assert json_file.file_type == "json" + + def test_workflows_from_services_dir(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + services = lib / "Services" + wf = services / "Convert.workflow" + contents = wf / "Contents" + contents.mkdir(parents=True) + (contents / "Info.plist").write_bytes(b"dummy") + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + patch( + "mac2nix.scanners.library_scanner.read_plist_safe", + return_value={"CFBundleIdentifier": "com.example.convert"}, + ), + ): + result = LibraryScanner().scan() + + assert any(w.name == "Convert" for w in result.workflows) + + def test_empty_library(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + lib.mkdir() + + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.directories == [] + assert result.uncovered_files == [] + + def test_no_library_dir(self, tmp_path: Path) -> None: + with ( + patch("mac2nix.scanners.library_scanner.Path.home", return_value=tmp_path), + patch.object(LibraryScanner, "_scan_system_library", return_value=[]), + ): + result = LibraryScanner().scan() + + assert isinstance(result, LibraryResult) + assert result.directories == [] + + +class TestRedactSensitiveKeys: + def test_redacts_api_key(self) -> None: + data = {"API_KEY": "secret123", "name": "test"} + _redact_sensitive_keys(data) + + redacted = "***REDACTED***" + assert data["API_KEY"] == redacted + assert data["name"] == "test" + + def test_redacts_nested_dict(self) -> None: + data = {"config": {"DB_PASSWORD": "secret", "host": "localhost"}} + _redact_sensitive_keys(data) + + redacted = "***REDACTED***" + assert data["config"]["DB_PASSWORD"] == redacted + assert data["config"]["host"] == "localhost" + + def test_redacts_in_list(self) -> None: + data = {"items": [{"ACCESS_TOKEN": "token123"}, {"normal": "value"}]} + _redact_sensitive_keys(data) + + redacted = "***REDACTED***" + assert data["items"][0]["ACCESS_TOKEN"] == redacted + assert data["items"][1]["normal"] == "value" + + def test_case_insensitive_match(self) -> None: + data = {"my_auth_header": "Bearer xyz"} + _redact_sensitive_keys(data) + + redacted = "***REDACTED***" + assert data["my_auth_header"] == redacted + + def test_no_sensitive_keys(self) -> None: + data = {"name": "test", "count": 42} + _redact_sensitive_keys(data) + assert data == {"name": "test", "count": 42} + + +class TestCoveredDirsMapping: + def test_known_covered_dirs(self) -> None: + assert _COVERED_DIRS["Preferences"] == "preferences" + assert _COVERED_DIRS["Application Support"] == "library" + assert _COVERED_DIRS["LaunchAgents"] == "launch_agents" + assert _COVERED_DIRS["Fonts"] == "fonts" + + def test_transient_dirs(self) -> None: + assert "Caches" in _TRANSIENT_DIRS + assert "Logs" in _TRANSIENT_DIRS + assert "Saved Application State" in _TRANSIENT_DIRS + + +class TestScanAudioPlugins: + def test_finds_component_bundles(self, tmp_path: Path) -> None: + components = tmp_path / "Components" + components.mkdir() + plugin = components / "MyPlugin.component" + plugin.mkdir() + info = plugin / "Contents" / "Info.plist" + info.parent.mkdir() + info.write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_scanner.read_plist_safe", + return_value={"CFBundleIdentifier": "com.test.plugin", "CFBundleShortVersionString": "1.0"}, + ): + result = LibraryScanner()._scan_audio_plugins(tmp_path) + + assert len(result) == 1 + assert result[0].name == "MyPlugin.component" + assert result[0].bundle_id == "com.test.plugin" + + def test_skips_non_bundle_dirs(self, tmp_path: Path) -> None: + components = tmp_path / "Components" + components.mkdir() + regular_dir = components / "NotABundle" + regular_dir.mkdir() + + result = LibraryScanner()._scan_audio_plugins(tmp_path) + assert result == [] + + def test_empty_audio_dir(self, tmp_path: Path) -> None: + result = LibraryScanner()._scan_audio_plugins(tmp_path / "nonexistent") + assert result == [] + + +class TestTextReplacementsCorrupted: + def test_corrupted_db_returns_empty(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + ks_dir = lib / "KeyboardServices" + ks_dir.mkdir(parents=True) + db_path = ks_dir / "TextReplacements.db" + db_path.write_bytes(b"not a sqlite database") + + with patch( + "mac2nix.scanners.library_scanner.sqlite3.connect", + side_effect=sqlite3.OperationalError("not a database"), + ): + result = LibraryScanner()._scan_text_replacements(lib) + + assert result == [] + + def test_null_rows_filtered(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + ks_dir = lib / "KeyboardServices" + ks_dir.mkdir(parents=True) + db_path = ks_dir / "TextReplacements.db" + db_path.write_bytes(b"dummy") + + mock_rows = [("omw", "On my way!"), (None, "no shortcut"), ("notext", None), (None, None)] + mock_cursor = type("MockCursor", (), {"fetchall": lambda _self: mock_rows})() + mock_conn = type( + "MockConn", + (), + { + "execute": lambda _self, _query: mock_cursor, + "close": lambda _self: None, + }, + )() + + with patch("mac2nix.scanners.library_scanner.sqlite3.connect", return_value=mock_conn): + result = LibraryScanner()._scan_text_replacements(lib) + + assert len(result) == 1 + assert result[0] == {"shortcut": "omw", "phrase": "On my way!"} + + +class TestCaptureUncoveredDirEdgeCases: + def test_file_cap_enforced(self, tmp_path: Path) -> None: + for i in range(1050): + (tmp_path / f"file{i:04d}.txt").write_text(f"content {i}") + + with ( + patch("mac2nix.scanners.library_scanner.hash_file", return_value="hash"), + patch("mac2nix.scanners.library_scanner.read_plist_safe", return_value=None), + ): + files, _workflows, _bundles = LibraryScanner()._capture_uncovered_dir(tmp_path) + + # Count includes all files encountered, but total should be capped + assert len(files) <= 1000 + + def test_workflow_bundles_discovered(self, tmp_path: Path) -> None: + wf = tmp_path / "MyAction.workflow" + contents = wf / "Contents" + contents.mkdir(parents=True) + (contents / "Info.plist").write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_scanner.read_plist_safe", + return_value={"CFBundleIdentifier": "com.example.action"}, + ): + _files, workflows, _bundles = LibraryScanner()._capture_uncovered_dir(tmp_path) + + assert len(workflows) == 1 + assert workflows[0].name == "MyAction" + assert workflows[0].identifier == "com.example.action" + + def test_bundle_dirs_discovered(self, tmp_path: Path) -> None: + plugin = tmp_path / "MyPlugin.component" + plugin_contents = plugin / "Contents" + plugin_contents.mkdir(parents=True) + (plugin_contents / "Info.plist").write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_scanner.read_plist_safe", + return_value={"CFBundleIdentifier": "com.example.plugin", "CFBundleShortVersionString": "2.0"}, + ): + _files, _workflows, bundles = LibraryScanner()._capture_uncovered_dir(tmp_path) + + assert len(bundles) == 1 + assert bundles[0].name == "MyPlugin.component" + assert bundles[0].bundle_id == "com.example.plugin" + assert bundles[0].bundle_type == "component" + + +class TestClassifyFileEdgeCases: + def test_plist_returns_non_dict(self, tmp_path: Path) -> None: + plist_file = tmp_path / "list.plist" + plist_file.write_bytes(b"data") + + with ( + patch("mac2nix.scanners.library_scanner.read_plist_safe", return_value=["item1", "item2"]), + patch("mac2nix.scanners.library_scanner.hash_file", return_value="abc123"), + ): + entry = LibraryScanner()._classify_file(plist_file) + + assert entry is not None + assert entry.migration_strategy == "hash_only" + assert entry.plist_content is None + assert entry.content_hash == "abc123" + + +class TestKeyBindingsEdgeCases: + def test_non_string_dict_actions_filtered(self, tmp_path: Path) -> None: + lib = tmp_path / "Library" + kb_dir = lib / "KeyBindings" + kb_dir.mkdir(parents=True) + (kb_dir / "DefaultKeyBinding.dict").write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_scanner.read_plist_safe", + return_value={ + "^w": "deleteWordBackward:", + "^x": 42, + "^y": ["a", "b"], + "^z": {"moveRight:": "moveWordRight:"}, + }, + ): + result = LibraryScanner()._scan_key_bindings(lib) + + assert len(result) == 2 + keys = {e.key for e in result} + assert "^w" in keys + assert "^z" in keys + assert "^x" not in keys + assert "^y" not in keys + + +class TestScanSystemLibrary: + def test_discovers_kext_bundles(self, tmp_path: Path) -> None: + extensions = tmp_path / "Extensions" + kext = extensions / "MyDriver.kext" + kext_contents = kext / "Contents" + kext_contents.mkdir(parents=True) + (kext_contents / "Info.plist").write_bytes(b"dummy") + + with ( + patch("mac2nix.scanners.library_scanner.Path", wraps=Path) as mock_path, + patch( + "mac2nix.scanners.library_scanner.read_plist_safe", + return_value={"CFBundleIdentifier": "com.example.driver", "CFBundleShortVersionString": "1.0"}, + ), + ): + mock_path.side_effect = lambda p: tmp_path if p == "/Library" else Path(p) + scanner = LibraryScanner() + # Directly call with our tmp_path standing in for /Library + result = scanner._scan_bundles_in_dir(extensions) + + # At minimum, verify the bundle parsing works + # (full _scan_system_library integration is hard to mock cleanly) + assert len(result) == 1 + assert result[0].bundle_id == "com.example.driver" + + def test_discovers_audio_plugins(self, tmp_path: Path) -> None: + components = tmp_path / "Components" + components.mkdir() + vst = components / "Synth.vst" + vst_contents = vst / "Contents" + vst_contents.mkdir(parents=True) + (vst_contents / "Info.plist").write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_scanner.read_plist_safe", + return_value={"CFBundleIdentifier": "com.example.synth"}, + ): + result = LibraryScanner()._scan_audio_plugins(tmp_path) + + assert len(result) == 1 + assert result[0].bundle_id == "com.example.synth" + assert result[0].bundle_type == "vst" + + def test_parse_bundle_fallback_info_plist(self, tmp_path: Path) -> None: + bundle = tmp_path / "MyBundle.plugin" + bundle.mkdir() + # Info.plist at root level (no Contents/ dir) + (bundle / "Info.plist").write_bytes(b"dummy") + + with patch( + "mac2nix.scanners.library_scanner.read_plist_safe", + return_value={"CFBundleIdentifier": "com.example.root"}, + ): + result = LibraryScanner._parse_bundle(bundle) + + assert result.bundle_id == "com.example.root" + + def test_parse_bundle_no_info_plist(self, tmp_path: Path) -> None: + bundle = tmp_path / "Empty.plugin" + bundle.mkdir() + + result = LibraryScanner._parse_bundle(bundle) + + assert result.name == "Empty.plugin" + assert result.bundle_id is None + assert result.version is None + assert result.bundle_type == "plugin" + + def test_parse_bundle_no_suffix(self, tmp_path: Path) -> None: + bundle = tmp_path / "WeirdBundle" + bundle.mkdir() + + result = LibraryScanner._parse_bundle(bundle) + + assert result.name == "WeirdBundle" + assert result.bundle_type is None + + +class TestSymlinkSafety: + def test_dir_stats_skips_symlinks(self, tmp_path: Path) -> None: + (tmp_path / "real_file.txt").write_text("hello") + (tmp_path / "link").symlink_to(tmp_path / "real_file.txt") + + file_count, _total_size, _ = LibraryScanner._dir_stats(tmp_path) + + # Symlink should be skipped — only real_file counted + assert file_count == 1 + + def test_scan_bundles_in_dir_skips_symlinks(self, tmp_path: Path) -> None: + real_bundle = tmp_path / "Real.app" + real_bundle.mkdir() + (tmp_path / "Linked.app").symlink_to(real_bundle) + + result = LibraryScanner()._scan_bundles_in_dir(tmp_path) + + names = [b.name for b in result] + assert "Real.app" in names + assert "Linked.app" not in names From ccd2d5685efab162ba2e2c4add21cc9f4d6d04a8 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sat, 14 Mar 2026 13:45:19 -0400 Subject: [PATCH 17/17] =?UTF-8?q?fix(scanners):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20PII=20redaction,=20callback=20safety,=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove hardware_serial capture (PII with no migration value) - Wrap progress callback in try/except (broken terminal can't crash scan) - deepcopy raw_plist to protect shared prefetch data from mutation - Expand nix_state _PRUNE_DIRS to skip macOS non-project directories - Rename convert_datetimes → sanitize_plist_values (handles bytes, UIDs too) --- src/mac2nix/orchestrator.py | 7 ++++++- src/mac2nix/scanners/_utils.py | 8 ++++---- src/mac2nix/scanners/launch_agents.py | 5 +++-- src/mac2nix/scanners/nix_state.py | 20 +++++++++++++++++++- src/mac2nix/scanners/preferences.py | 4 ++-- src/mac2nix/scanners/system_scanner.py | 4 ++-- tests/scanners/test_system_scanner.py | 2 +- tests/scanners/test_utils.py | 14 +++++++------- 8 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/mac2nix/orchestrator.py b/src/mac2nix/orchestrator.py index 85f4d1b..46fb31b 100644 --- a/src/mac2nix/orchestrator.py +++ b/src/mac2nix/orchestrator.py @@ -90,8 +90,13 @@ async def _run_scanner_async( logger.exception("Scanner '%s' raised an exception", scanner_name) return scanner_name, None finally: + # 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: - progress_callback(scanner_name) + try: + progress_callback(scanner_name) + except Exception: + logger.debug("Progress callback failed for '%s'", scanner_name) async def run_scan( diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index 72f7a94..d0a162f 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -69,7 +69,7 @@ ] -def convert_datetimes(obj: Any) -> Any: +def sanitize_plist_values(obj: Any) -> Any: """Recursively convert non-JSON-safe plist values. plistlib returns datetime objects (for NSDate), bytes objects (for NSData), @@ -82,9 +82,9 @@ def convert_datetimes(obj: Any) -> Any: if isinstance(obj, plistlib.UID): return int(obj) if isinstance(obj, dict): - return {k: convert_datetimes(v) for k, v in obj.items()} + return {k: sanitize_plist_values(v) for k, v in obj.items()} if isinstance(obj, list): - return [convert_datetimes(item) for item in obj] + return [sanitize_plist_values(item) for item in obj] return obj @@ -143,7 +143,7 @@ def read_plist_safe(path: Path) -> dict[str, Any] | list[Any] | None: logger.warning("Failed to read plist %s: %s", path, exc) return None - return convert_datetimes(data) + return sanitize_plist_values(data) def _read_plist_via_plutil(path: Path) -> dict[str, Any] | None: diff --git a/src/mac2nix/scanners/launch_agents.py b/src/mac2nix/scanners/launch_agents.py index 2fca092..1eb973b 100644 --- a/src/mac2nix/scanners/launch_agents.py +++ b/src/mac2nix/scanners/launch_agents.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import logging import os import re @@ -73,8 +74,8 @@ def _parse_agent_data( program_arguments = data.get("ProgramArguments", []) run_at_load = data.get("RunAtLoad", False) - # Shallow copy + replace env vars to avoid mutating the shared prefetch data - raw_plist = dict(data) + # Deep copy to avoid mutating the shared prefetch data + raw_plist = copy.deepcopy(data) env_raw = data.get("EnvironmentVariables") if isinstance(env_raw, dict): redacted = { diff --git a/src/mac2nix/scanners/nix_state.py b/src/mac2nix/scanners/nix_state.py index 188f858..9476900 100644 --- a/src/mac2nix/scanners/nix_state.py +++ b/src/mac2nix/scanners/nix_state.py @@ -35,7 +35,25 @@ _PACKAGE_CAP = 500 _ADJACENT_CAP = 50 _ADJACENT_MAX_DEPTH = 2 -_PRUNE_DIRS = {".git", "node_modules", ".direnv", "__pycache__", ".venv"} +_PRUNE_DIRS = { + # VCS / build + ".git", + "node_modules", + ".direnv", + "__pycache__", + ".venv", + # macOS non-project directories (avoid wasting IO at depth 0-1) + "Library", + "Applications", + "Downloads", + "Movies", + "Music", + "Pictures", + "Public", + ".Trash", + ".cache", + ".local", +} _SYSTEM_NIX_CONF = Path("/etc/nix/nix.conf") _VERSION_RE = re.compile(r"(\d+\.\d+[\w.]*)") diff --git a/src/mac2nix/scanners/preferences.py b/src/mac2nix/scanners/preferences.py index ab7ca4a..c5653cd 100644 --- a/src/mac2nix/scanners/preferences.py +++ b/src/mac2nix/scanners/preferences.py @@ -7,7 +7,7 @@ from pathlib import Path from mac2nix.models.preferences import PreferencesDomain, PreferencesResult, PreferenceValue -from mac2nix.scanners._utils import convert_datetimes, read_plist_safe, run_command +from mac2nix.scanners._utils import read_plist_safe, run_command, sanitize_plist_values from mac2nix.scanners.base import BaseScannerPlugin, register logger = logging.getLogger(__name__) @@ -95,4 +95,4 @@ def _export_domain(domain_name: str) -> dict[str, PreferenceValue] | None: return None if not isinstance(data, dict): return None - return convert_datetimes(data) + return sanitize_plist_values(data) diff --git a/src/mac2nix/scanners/system_scanner.py b/src/mac2nix/scanners/system_scanner.py index d3e0dc5..da9d716 100644 --- a/src/mac2nix/scanners/system_scanner.py +++ b/src/mac2nix/scanners/system_scanner.py @@ -199,9 +199,9 @@ def _get_hardware_info( model = hw.get("machine_model") or hw.get("machine_name") chip = hw.get("chip_type") or hw.get("cpu_type") memory = hw.get("physical_memory") - serial = hw.get("serial_number") + # serial_number is PII with no nix-darwin migration value — do not capture - return model, chip, memory, serial + return model, chip, memory, None def _get_additional_hostnames(self) -> tuple[str | None, str | None]: """Get LocalHostName and HostName separately.""" diff --git a/tests/scanners/test_system_scanner.py b/tests/scanners/test_system_scanner.py index 131b076..1e55bf0 100644 --- a/tests/scanners/test_system_scanner.py +++ b/tests/scanners/test_system_scanner.py @@ -194,7 +194,7 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces assert result.hardware_model == "Mac14,2" assert result.hardware_chip == "Apple M2" assert result.hardware_memory == "16 GB" - assert result.hardware_serial == "XYZ123456" + assert result.hardware_serial is None # serial is PII — never captured def test_hardware_info_fallback_keys(self, cmd_result) -> None: hw_data = { diff --git a/tests/scanners/test_utils.py b/tests/scanners/test_utils.py index 1e49fef..913abb7 100644 --- a/tests/scanners/test_utils.py +++ b/tests/scanners/test_utils.py @@ -7,11 +7,11 @@ from unittest.mock import patch from mac2nix.scanners._utils import ( - convert_datetimes, hash_file, read_launchd_plists, read_plist_safe, run_command, + sanitize_plist_values, ) @@ -138,29 +138,29 @@ def test_read_plist_safe_converts_datetimes(self, tmp_path: Path) -> None: class TestConvertDatetimes: def test_datetime_converted(self) -> None: dt = datetime(2026, 3, 7, 12, 0, 0, tzinfo=UTC) - result = convert_datetimes(dt) + result = sanitize_plist_values(dt) assert isinstance(result, str) assert "2026-03-07" in result def test_nested_dict(self) -> None: dt = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC) data = {"outer": {"inner": dt, "keep": "string"}} - result = convert_datetimes(data) + result = sanitize_plist_values(data) assert isinstance(result["outer"]["inner"], str) assert result["outer"]["keep"] == "string" def test_nested_list(self) -> None: dt = datetime(2026, 6, 15, 8, 0, 0, tzinfo=UTC) data = [dt, "plain", 42] - result = convert_datetimes(data) + result = sanitize_plist_values(data) assert isinstance(result[0], str) assert result[1] == "plain" assert result[2] == 42 def test_passthrough_non_datetime(self) -> None: - assert convert_datetimes("hello") == "hello" - assert convert_datetimes(42) == 42 - assert convert_datetimes(None) is None + assert sanitize_plist_values("hello") == "hello" + assert sanitize_plist_values(42) == 42 + assert sanitize_plist_values(None) is None class TestHashFile: