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 } 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..79e0ef6 100644 --- a/src/mac2nix/models/__init__.py +++ b/src/mac2nix/models/__init__.py @@ -3,34 +3,92 @@ 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.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.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, + ICloudState, + NetworkConfig, + NetworkInterface, + PrinterInfo, + SecurityState, + SystemConfig, + SystemExtension, + TimeMachineConfig, + VpnProfile, ) -from mac2nix.models.system import NetworkConfig, NetworkInterface, SecurityState, SystemConfig from mac2nix.models.system_state import SystemState __all__ = [ @@ -40,32 +98,78 @@ "ApplicationsResult", "AudioConfig", "AudioDevice", + "BinarySource", "BrewCask", "BrewFormula", + "BrewService", + "BundleEntry", + "CondaEnvironment", + "CondaPackage", + "CondaState", "ConfigFileType", + "ContainerRuntimeInfo", + "ContainerRuntimeType", + "ContainersResult", "CronEntry", + "DevboxProject", + "DevenvProject", "DisplayConfig", "DotfileEntry", "DotfileManager", "DotfilesResult", + "FirewallAppRule", + "FontCollection", "FontEntry", "FontSource", "FontsResult", + "HomeManagerState", "HomebrewState", + "ICloudState", "InstalledApp", + "KeyBindingEntry", "LaunchAgentEntry", "LaunchAgentSource", "LaunchAgentsResult", + "LaunchdScheduledJob", + "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", "PreferencesResult", + "PrinterInfo", "ScheduledTasks", "SecurityState", "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 0b0e2d6..6022b54 100644 --- a/src/mac2nix/models/application.py +++ b/src/mac2nix/models/application.py @@ -14,6 +14,27 @@ class AppSource(StrEnum): MANUAL = "manual" +class BinarySource(StrEnum): + ASDF = "asdf" + BREW = "brew" + CARGO = "cargo" + CONDA = "conda" + GEM = "gem" + GO = "go" + JENV = "jenv" + MACPORTS = "macports" + MANUAL = "manual" + MISE = "mise" + NIX = "nix" + NPM = "npm" + NVM = "nvm" + PIPX = "pipx" + PYENV = "pyenv" + RBENV = "rbenv" + SDKMAN = "sdkman" + SYSTEM = "system" + + class InstalledApp(BaseModel): name: str bundle_id: str | None = None @@ -22,13 +43,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 +75,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/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/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..7d1fd77 100644 --- a/src/mac2nix/models/system.py +++ b/src/mac2nix/models/system.py @@ -2,7 +2,10 @@ from __future__ import annotations -from pydantic import BaseModel +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field class NetworkInterface(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): @@ -25,7 +46,41 @@ 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 + 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 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): @@ -34,3 +89,27 @@ 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 + 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 7350b14..9fa46d6 100644 --- a/src/mac2nix/models/system_state.py +++ b/src/mac2nix/models/system_state.py @@ -8,8 +8,14 @@ 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.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 @@ -41,6 +47,11 @@ class SystemState(BaseModel): display: DisplayConfig | None = None 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 d103fb5..8d99231 100644 --- a/src/mac2nix/scanners/__init__.py +++ b/src/mac2nix/scanners/__init__.py @@ -4,17 +4,22 @@ app_config, applications, audio, + containers, cron, display, dotfiles, fonts, homebrew, 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/_utils.py b/src/mac2nix/scanners/_utils.py index 337c705..2816656 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -22,18 +22,22 @@ ] -def _convert_datetimes(obj: Any) -> Any: - """Recursively convert datetime values to ISO 8601 strings. +def convert_datetimes(obj: Any) -> Any: + """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), 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()} + 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 @@ -63,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 @@ -79,7 +83,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). @@ -92,7 +96,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: @@ -173,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/app_config.py b/src/mac2nix/scanners/app_config.py index 61a3b9f..aead506 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,35 @@ ".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 +73,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 +104,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..f079348 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,39 @@ 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, + ".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"}) + @register("applications") class ApplicationsScanner(BaseScannerPlugin): @@ -27,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(): @@ -38,14 +80,27 @@ 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 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( @@ -57,7 +112,43 @@ 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, + ) + + @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.""" @@ -76,3 +167,131 @@ 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) -> list[PathBinary]: + """Walk PATH directories and collect executable binaries.""" + 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) + 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) -> BinarySource: + """Classify a binary's source based on its path.""" + path_str = str(path) + + # 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 (these are dotfile/home-relative paths + # that are unlikely to appear as substrings in unrelated paths) + for pattern, source in _SOURCE_PATTERNS.items(): + if f"/{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/containers.py b/src/mac2nix/scanners/containers.py new file mode 100644 index 0000000..94d4344 --- /dev/null +++ b/src/mac2nix/scanners/containers.py @@ -0,0 +1,219 @@ +"""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: + 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() + 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/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..3316480 100644 --- a/src/mac2nix/scanners/display.py +++ b/src/mac2nix/scanners/display.py @@ -4,10 +4,13 @@ import json import logging +import plistlib import shutil +from pathlib import Path +from typing import Any -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,22 +48,131 @@ 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")) 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: 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 + 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.""" + # 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 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 + + return None + + @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 + + 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 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/dotfiles.py b/src/mac2nix/scanners/dotfiles.py index b00f4e0..2644a67 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,62 @@ 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 +79,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 +176,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 +227,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..f9c8db5 100644 --- a/src/mac2nix/scanners/homebrew.py +++ b/src/mac2nix/scanners/homebrew.py @@ -2,11 +2,13 @@ from __future__ import annotations +import json 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 @@ -35,9 +37,27 @@ 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] - return HomebrewState(taps=taps, formulae=formulae, casks=casks, mas_apps=mas_apps) + # 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() + 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, @@ -88,11 +108,77 @@ 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 + + @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"]) + 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 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 entry in data: + if not isinstance(entry, dict): + continue + name = entry.get("name") + status = entry.get("status") + if not name or not status: + continue + 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)) + 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..3dcc47d --- /dev/null +++ b/src/mac2nix/scanners/library_audit.py @@ -0,0 +1,507 @@ +"""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_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) + # 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) + elif dirname not in _skip: + kept.append(dirname) + dirnames[:] = kept + 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": + 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"}: + 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(): + raw = read_plist_safe(doc_plist) + if isinstance(raw, dict): + definition = raw + + 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..38f855c 100644 --- a/src/mac2nix/scanners/network.py +++ b/src/mac2nix/scanners/network.py @@ -6,7 +6,7 @@ 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 @@ -23,10 +23,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 +38,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 +66,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 +83,41 @@ 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) + # Default to False; will be updated by "status:" line + active_map[current_iface] = False + elif current_iface: + 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) + 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,102 @@ 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: + # * (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=protocol, + ) + ) + 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/nix_state.py b/src/mac2nix/scanners/nix_state.py new file mode 100644 index 0000000..188f858 --- /dev/null +++ b/src/mac2nix/scanners/nix_state.py @@ -0,0 +1,559 @@ +"""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 = {"ACCESS_TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "NETRC"} +_SENSITIVE_EXACT_KEYS = {"access-tokens", "netrc-file"} + +_PACKAGE_CAP = 500 +_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+(\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: + # 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: + 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 + # 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]: + 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] = [] + elements = data.get("elements", []) + # 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 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, + ) + ) + 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 = [ + _SYSTEM_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 + 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 + + # 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=_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 known_keys}, + ) + + @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/preferences.py b/src/mac2nix/scanners/preferences.py index 56805ee..ab7ca4a 100644 --- a/src/mac2nix/scanners/preferences.py +++ b/src/mac2nix/scanners/preferences.py @@ -3,19 +3,21 @@ 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"), ] @@ -27,8 +29,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 +40,59 @@ 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, AttributeError): + 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..b14e00b 100644 --- a/src/mac2nix/scanners/security.py +++ b/src/mac2nix/scanners/security.py @@ -3,10 +3,10 @@ from __future__ import annotations import logging -import sqlite3 +import re 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 @@ -27,7 +27,11 @@ 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(), + touch_id_sudo=self._check_touch_id_sudo(), + custom_certificates=self._get_custom_certificates(), ) def _check_filevault(self) -> bool | None: @@ -58,22 +62,114 @@ def _check_firewall(self) -> bool | None: return None return "enabled" in result.stdout.lower() - 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 {} + 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 - try: - conn = sqlite3.connect(f"file:{tcc_path}?mode=ro&immutable=1", uri=True) + 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.""" + checked_any = False + for sudo_file in [Path("/etc/pam.d/sudo_local"), Path("/etc/pam.d/sudo")]: 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 {} + content = sudo_file.read_text() + checked_any = True + 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 False if checked_any else None + + 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..c518ad8 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: @@ -115,6 +200,54 @@ def _parse_rc_file(self, rc_path: Path, shell_type: str, parsed: _ParsedShellDat if not stripped or stripped.startswith("#"): continue + 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, 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, shell_type="fish") + + def _resolve_and_track_source( + self, raw_path: str, parsed: _ParsedShellData, home: Path, seen_files: set[Path], shell_type: str = "fish" + ) -> None: + """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: + 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) + + # 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: @@ -137,12 +270,21 @@ 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) 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 +313,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..4abbdac 100644 --- a/src/mac2nix/scanners/system_scanner.py +++ b/src/mac2nix/scanners/system_scanner.py @@ -1,13 +1,22 @@ -"""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 ( + ICloudState, + PrinterInfo, + SystemConfig, + SystemExtension, + TimeMachineConfig, +) +from mac2nix.scanners._utils import read_plist_safe, run_command from mac2nix.scanners.base import BaseScannerPlugin, register logger = logging.getLogger(__name__) @@ -26,17 +35,56 @@ 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() + rosetta_installed = self._detect_rosetta() + system_extensions = self._detect_system_extensions() + icloud = self._detect_icloud() + mdm_enrolled = self._detect_mdm() 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, + rosetta_installed=rosetta_installed, + system_extensions=system_extensions, + icloud=icloud, + mdm_enrolled=mdm_enrolled, ) def _get_hostname(self) -> str: @@ -104,3 +152,399 @@ 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 not isinstance(data, dict): + 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() + # 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() + # Try to parse as int (sleep minutes) + try: + settings[key] = int(value) + 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 not isinstance(data, dict): + 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 + + # Fallback: check if timed process is running (admin-free) + if ntp_enabled is None: + result = run_command(["pgrep", "-x", "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]: + """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 + + 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 + # 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 + 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_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: + identifier = part + elif team_id is None: + team_id = part + elif part.startswith("(") and part.endswith(")"): + version = part.strip("()") + 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=state_str, + ) + + 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..3b0e3eb --- /dev/null +++ b/src/mac2nix/scanners/version_managers.py @@ -0,0 +1,439 @@ +"""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 + + # 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 + 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 + + # 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 + 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 + + # 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) + + 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_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..71d0b8a 100644 --- a/tests/models/test_remaining.py +++ b/tests/models/test_remaining.py @@ -2,18 +2,42 @@ 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, + ICloudState, + NetworkConfig, + NetworkInterface, + PrinterInfo, + SecurityState, + SystemConfig, + SystemExtension, + TimeMachineConfig, + VpnProfile, ) -from mac2nix.models.system import NetworkConfig, NetworkInterface, SecurityState, SystemConfig class TestLaunchAgent: @@ -101,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: @@ -185,12 +205,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 +263,655 @@ 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.ASDF == "asdf" + assert BinarySource.BREW == "brew" + assert BinarySource.CARGO == "cargo" + assert BinarySource.CONDA == "conda" + assert BinarySource.GEM == "gem" + assert BinarySource.GO == "go" + 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.NVM == "nvm" + assert BinarySource.PIPX == "pipx" + assert BinarySource.PYENV == "pyenv" + assert BinarySource.RBENV == "rbenv" + assert BinarySource.SDKMAN == "sdkman" + assert BinarySource.SYSTEM == "system" + + 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 == {} + + +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/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..7632a7d 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,285 @@ 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")) + assert source == BinarySource.SYSTEM + + def test_sbin_dir(self) -> None: + source = ApplicationsScanner._classify_binary_source(Path("/sbin/ping")) + assert source == BinarySource.SYSTEM + + def test_brew_by_path(self) -> None: + 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")) + assert source == BinarySource.BREW + + def test_cargo_source(self) -> None: + 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")) + assert source == BinarySource.GO + + def test_pipx_source(self) -> None: + 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")) + 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")) + 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 + + +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_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_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..a3ccedd 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,289 @@ 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..10c748a 100644 --- a/tests/scanners/test_homebrew.py +++ b/tests/scanners/test_homebrew.py @@ -1,5 +1,7 @@ """Tests for Homebrew scanner.""" +import json +from pathlib import Path from unittest.mock import patch from mac2nix.models.application import HomebrewState @@ -35,10 +37,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 --json + 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 +61,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 +73,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 +85,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() @@ -83,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=[cmd_result(_BREWFILE), cmd_result(_VERSIONS)], + 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() @@ -111,9 +130,150 @@ 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_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_json), + 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_empty_json(self, cmd_result) -> None: + side_effects = [ + cmd_result(_BREWFILE), + cmd_result(_VERSIONS), + cmd_result(""), + cmd_result("[]"), + 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_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_json), + 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..43197c4 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,229 @@ 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" + "\tstatus: active\n" + "en1: flags=8822 mtu 1500\n" + "\tstatus: inactive\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_nix_state.py b/tests/scanners/test_nix_state.py new file mode 100644 index 0000000..4597196 --- /dev/null +++ b/tests/scanners/test_nix_state.py @@ -0,0 +1,945 @@ +"""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_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: + 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_all_commands_fail(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" + 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.""" + 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"]: + 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 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) == 3 + assert result[0].from_name == "nixpkgs" + 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): + 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() + 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.max_jobs == 4 + assert result.sandbox is True + 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) + 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" + "extra-trusted-public-keys = cache.example.com:abc123\n" + "max-jobs = 8\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.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: + 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() + 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 + + 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() + 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 + + 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() + 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 + + +# --------------------------------------------------------------------------- +# 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_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..381872f 100644 --- a/tests/scanners/test_security.py +++ b/tests/scanners/test_security.py @@ -102,48 +102,177 @@ def test_command_fails(self) -> None: assert result.sip_enabled is None assert result.gatekeeper_enabled is None - def test_tcc_inaccessible(self) -> None: + def test_returns_security_state(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) + + 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.tcc_summary == {} + assert result.touch_id_sudo is True - 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, - }, - )() + 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.home", return_value=Path("/Users/testuser")), + 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.sqlite3.connect", return_value=mock_conn), + patch("mac2nix.scanners.security.Path.home", return_value=_NONEXISTENT), ): 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"] + 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_returns_security_state(self) -> None: + 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), @@ -151,3 +280,24 @@ def test_returns_security_state(self) -> None: 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 == [] 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..131b076 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,718 @@ 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\nProductVersion:\t15.3.1\nBuildVersion:\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\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" + + 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 + + +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 = ( + "1 extension(s)\n" + "--- com.apple.system_extension.driver_extension\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: + 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_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"]: + 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_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: diff --git a/tests/scanners/test_version_managers.py b/tests/scanners/test_version_managers.py new file mode 100644 index 0000000..b6ec53f --- /dev/null +++ b/tests/scanners/test_version_managers.py @@ -0,0 +1,721 @@ +"""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_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() + + 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_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_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() + + 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_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_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() + + 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_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