From bf0e0f431bab891cce20d3f8fd98fd25e35412bf Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 16:06:23 -0400 Subject: [PATCH 1/8] fix(scanners): adds computer_name and known_network_services fields --- src/mac2nix/models/system.py | 2 ++ src/mac2nix/scanners/network.py | 18 ++++++++++++ src/mac2nix/scanners/system_scanner.py | 9 ++++++ tests/scanners/test_network.py | 38 ++++++++++++++++++++++++++ tests/scanners/test_system_scanner.py | 17 ++++++++++++ 5 files changed, 84 insertions(+) diff --git a/src/mac2nix/models/system.py b/src/mac2nix/models/system.py index 7d1fd77..0c21616 100644 --- a/src/mac2nix/models/system.py +++ b/src/mac2nix/models/system.py @@ -32,6 +32,7 @@ class NetworkConfig(BaseModel): wifi_networks: list[str] = [] vpn_profiles: list[VpnProfile] = [] proxy_bypass_domains: list[str] = [] + known_network_services: list[str] = [] locations: list[str] = [] current_location: str | None = None @@ -85,6 +86,7 @@ class ICloudState(BaseModel): class SystemConfig(BaseModel): hostname: str + computer_name: str | None = None timezone: str | None = None locale: str | None = None power_settings: dict[str, str] = {} # pmset key-value pairs diff --git a/src/mac2nix/scanners/network.py b/src/mac2nix/scanners/network.py index 38f855c..a694f14 100644 --- a/src/mac2nix/scanners/network.py +++ b/src/mac2nix/scanners/network.py @@ -30,6 +30,7 @@ def scan(self) -> NetworkConfig: proxy_bypass_domains = self._get_proxy_bypass_domains(interfaces) wifi_networks = self._get_wifi_networks(interfaces) vpn_profiles = self._get_vpn_profiles() + known_network_services = self._get_network_services() locations, current_location = self._get_locations() return NetworkConfig( @@ -40,6 +41,7 @@ def scan(self) -> NetworkConfig: wifi_networks=wifi_networks, vpn_profiles=vpn_profiles, proxy_bypass_domains=proxy_bypass_domains, + known_network_services=known_network_services, locations=locations, current_location=current_location, ) @@ -252,6 +254,22 @@ def _get_vpn_profiles(self) -> list[VpnProfile]: ) return profiles + def _get_network_services(self) -> list[str]: + """Get all network service names for networking.knownNetworkServices.""" + result = run_command(["networksetup", "-listallnetworkservices"]) + if result is None or result.returncode != 0: + return [] + services: list[str] = [] + for line in result.stdout.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("An asterisk"): + continue + if stripped.startswith("*"): + stripped = stripped.lstrip("*").strip() + if stripped: + services.append(stripped) + return services + def _get_locations(self) -> tuple[list[str], str | None]: """Get network locations and current location.""" locations: list[str] = [] diff --git a/src/mac2nix/scanners/system_scanner.py b/src/mac2nix/scanners/system_scanner.py index da9d716..2ba4fbc 100644 --- a/src/mac2nix/scanners/system_scanner.py +++ b/src/mac2nix/scanners/system_scanner.py @@ -37,6 +37,7 @@ def is_available(self) -> bool: return shutil.which("scutil") is not None def scan(self) -> SystemConfig: + computer_name = self._get_computer_name() hostname = self._get_hostname() local_hostname, dns_hostname = self._get_additional_hostnames() timezone = self._get_timezone() @@ -60,6 +61,7 @@ def scan(self) -> SystemConfig: return SystemConfig( hostname=hostname, + computer_name=computer_name, local_hostname=local_hostname, dns_hostname=dns_hostname, timezone=timezone, @@ -90,6 +92,13 @@ def scan(self) -> SystemConfig: mdm_enrolled=mdm_enrolled, ) + def _get_computer_name(self) -> str | None: + result = run_command(["scutil", "--get", "ComputerName"]) + if result is not None and result.returncode == 0: + name = result.stdout.strip() + return name or None + return None + def _get_hostname(self) -> str: result = run_command(["scutil", "--get", "ComputerName"]) if result is not None and result.returncode == 0: diff --git a/tests/scanners/test_network.py b/tests/scanners/test_network.py index 43197c4..7af700c 100644 --- a/tests/scanners/test_network.py +++ b/tests/scanners/test_network.py @@ -349,6 +349,44 @@ def test_network_locations(self, cmd_result) -> None: assert result.locations == ["Automatic", "Work", "Home"] assert result.current_location == "Work" + def test_known_network_services(self, cmd_result) -> None: + services_output = ( + "An asterisk (*) denotes that a network service is disabled.\nWi-Fi\nThunderbolt Ethernet\n*Bluetooth PAN\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", "-listallnetworkservices"): cmd_result(services_output), + } + + with patch( + "mac2nix.scanners.network.run_command", + side_effect=_network_side_effect(responses), + ): + result = NetworkScanner().scan() + + assert result.known_network_services == ["Wi-Fi", "Thunderbolt Ethernet", "Bluetooth PAN"] + + def test_known_network_services_command_fails(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(""), + } + + with patch( + "mac2nix.scanners.network.run_command", + side_effect=_network_side_effect(responses), + ): + result = NetworkScanner().scan() + + assert result.known_network_services == [] + def test_wifi_preferred_networks(self, cmd_result) -> None: preferred = "Preferred networks on en0:\n\tHomeNetwork\n\tOfficeWifi\n\tCoffeeShop\n" responses = { diff --git a/tests/scanners/test_system_scanner.py b/tests/scanners/test_system_scanner.py index 1e55bf0..a1fcd0d 100644 --- a/tests/scanners/test_system_scanner.py +++ b/tests/scanners/test_system_scanner.py @@ -54,6 +54,23 @@ def test_hostname_unknown_fallback(self) -> None: assert isinstance(result, SystemConfig) assert result.hostname == "unknown" + def test_computer_name(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("Will's MacBook Pro\n") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert result.computer_name == "Will's MacBook Pro" + + def test_computer_name_not_set(self) -> None: + with patch("mac2nix.scanners.system_scanner.run_command", return_value=None): + result = SystemScanner().scan() + + assert result.computer_name is None + def test_timezone(self, cmd_result) -> None: def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: if cmd == ["scutil", "--get", "ComputerName"]: From 7fdd2ccf910ada88727ce219dbcb635a414e3d63 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 16:57:16 -0400 Subject: [PATCH 2/8] fix(scanners): falls back to plutil for plistlib InvalidFileException --- src/mac2nix/scanners/_utils.py | 11 ++++------- tests/scanners/test_utils.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index 88b1d1c..d6565d3 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -348,15 +348,12 @@ def read_plist_safe(path: Path) -> dict[str, Any] | list[Any] | None: else: logger.warning("Permission denied reading plist: %s", path) return None - except plistlib.InvalidFileException: - 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). - # Fall back to plutil XML conversion which preserves dates as strings. + except (plistlib.InvalidFileException, ValueError, OverflowError): + # plistlib can't handle NeXTStep-format plists, some newer binary plist + # variants, or dates like year 0. Fall back to plutil XML conversion. data = _read_plist_via_plutil(path) if data is None: - logger.warning("Plist contains unrepresentable data: %s", path) + logger.debug("Skipping unreadable plist: %s", path) return None except OSError as exc: logger.warning("Failed to read plist %s: %s", path, exc) diff --git a/tests/scanners/test_utils.py b/tests/scanners/test_utils.py index 913abb7..c625db5 100644 --- a/tests/scanners/test_utils.py +++ b/tests/scanners/test_utils.py @@ -100,6 +100,19 @@ def test_read_plist_safe_permission_denied(self, tmp_path: Path) -> None: assert result is None + def test_read_plist_safe_invalid_falls_back_to_plutil(self, tmp_path: Path) -> None: + plist_file = tmp_path / "nextstep.plist" + plist_file.write_bytes(plistlib.dumps({"key": "recovered"})) + + with patch( + "mac2nix.scanners._utils.plistlib.load", + side_effect=plistlib.InvalidFileException("Invalid file"), + ): + result = read_plist_safe(plist_file) + + assert result is not None + assert result["key"] == "recovered" + def test_read_plist_safe_corrupt_datetime_falls_back_to_plutil(self, tmp_path: Path) -> None: plist_file = tmp_path / "corrupt_date.plist" plist_file.write_bytes(plistlib.dumps({"key": "value"})) From dd76e38b26db6c438da2dfa4f31c05bcf146de9b Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:11:46 -0400 Subject: [PATCH 3/8] feat(scanners): adds pipx, cargo, npm, go, gem package listing --- src/mac2nix/models/package_managers.py | 43 +++ .../scanners/package_managers_scanner.py | 223 ++++++++++++++- tests/scanners/test_package_managers.py | 260 ++++++++++++++++++ 3 files changed, 525 insertions(+), 1 deletion(-) diff --git a/src/mac2nix/models/package_managers.py b/src/mac2nix/models/package_managers.py index 3398b42..344f034 100644 --- a/src/mac2nix/models/package_managers.py +++ b/src/mac2nix/models/package_managers.py @@ -182,11 +182,54 @@ class CondaState(BaseModel): environments: list[CondaEnvironment] = [] +class LanguagePackage(BaseModel): + """A package installed by a language ecosystem package manager.""" + + name: str + version: str | None = None + binaries: list[str] = [] + + +class PipxState(BaseModel): + present: bool = False + version: str | None = None + packages: list[LanguagePackage] = [] + + +class CargoState(BaseModel): + present: bool = False + version: str | None = None + packages: list[LanguagePackage] = [] + + +class NpmGlobalState(BaseModel): + present: bool = False + version: str | None = None + packages: list[LanguagePackage] = [] + + +class GoState(BaseModel): + present: bool = False + version: str | None = None + packages: list[LanguagePackage] = [] + + +class GemState(BaseModel): + present: bool = False + version: str | None = None + packages: list[LanguagePackage] = [] + + class PackageManagersResult(BaseModel): """Third-party (non-Homebrew, non-Nix) package managers.""" macports: MacPortsState = Field(default_factory=MacPortsState) conda: CondaState = Field(default_factory=CondaState) + pipx: PipxState = Field(default_factory=PipxState) + cargo: CargoState = Field(default_factory=CargoState) + npm_global: NpmGlobalState = Field(default_factory=NpmGlobalState) + go: GoState = Field(default_factory=GoState) + gem: GemState = Field(default_factory=GemState) class ContainerRuntimeType(StrEnum): diff --git a/src/mac2nix/scanners/package_managers_scanner.py b/src/mac2nix/scanners/package_managers_scanner.py index 069677f..5f8a4a3 100644 --- a/src/mac2nix/scanners/package_managers_scanner.py +++ b/src/mac2nix/scanners/package_managers_scanner.py @@ -1,4 +1,4 @@ -"""Package managers scanner — detects MacPorts and Conda/Mamba.""" +"""Package managers scanner — detects MacPorts, Conda/Mamba, and language ecosystem managers.""" from __future__ import annotations @@ -9,12 +9,18 @@ from pathlib import Path from mac2nix.models.package_managers import ( + CargoState, CondaEnvironment, CondaPackage, CondaState, + GemState, + GoState, + LanguagePackage, MacPortsPackage, MacPortsState, + NpmGlobalState, PackageManagersResult, + PipxState, ) from mac2nix.scanners._utils import run_command from mac2nix.scanners.base import BaseScannerPlugin, register @@ -35,6 +41,11 @@ def scan(self) -> PackageManagersResult: return PackageManagersResult( macports=self._detect_macports(), conda=self._detect_conda(), + pipx=self._detect_pipx(), + cargo=self._detect_cargo(), + npm_global=self._detect_npm_global(), + go=self._detect_go(), + gem=self._detect_gem(), ) def _detect_macports(self) -> MacPortsState: @@ -216,3 +227,213 @@ def _get_conda_packages(conda_cmd: str, env_path: str) -> list[CondaPackage]: ) return packages + + # --- Language ecosystem package managers --- + + def _detect_pipx(self) -> PipxState: + if shutil.which("pipx") is None: + return PipxState(present=False) + + version: str | None = None + result = run_command(["pipx", "--version"]) + if result is not None and result.returncode == 0: + version = result.stdout.strip() or None + + packages = self._get_pipx_packages() + return PipxState(present=True, version=version, packages=packages) + + @staticmethod + def _get_pipx_packages() -> list[LanguagePackage]: + result = run_command(["pipx", "list", "--json"], timeout=15) + if result is None or result.returncode != 0: + return [] + try: + data = json.loads(result.stdout) + except (json.JSONDecodeError, ValueError): + return [] + + venvs = data.get("venvs", {}) + if not isinstance(venvs, dict): + return [] + + packages: list[LanguagePackage] = [] + for _venv_name, venv_data in sorted(venvs.items()): + meta = venv_data.get("metadata", {}).get("main_package", {}) + name = meta.get("package") + if not name: + continue + version = meta.get("package_version") + apps = sorted(meta.get("apps", [])) + packages.append(LanguagePackage(name=name, version=version, binaries=apps)) + + return packages + + def _detect_cargo(self) -> CargoState: + if shutil.which("cargo") is None: + return CargoState(present=False) + + version: str | None = None + result = run_command(["cargo", "--version"]) + if result is not None and result.returncode == 0: + match = re.search(r"(\d+\.\d+[\.\d]*)", result.stdout) + if match: + version = match.group(1) + + packages = self._get_cargo_packages() + return CargoState(present=True, version=version, packages=packages) + + @staticmethod + def _get_cargo_packages() -> list[LanguagePackage]: + result = run_command(["cargo", "install", "--list"], timeout=15) + if result is None or result.returncode != 0: + return [] + + packages: list[LanguagePackage] = [] + current_name: str | None = None + current_version: str | None = None + current_bins: list[str] = [] + + for line in result.stdout.splitlines(): + if not line.startswith(" "): + if current_name: + packages.append( + LanguagePackage( + name=current_name, + version=current_version, + binaries=sorted(current_bins), + ) + ) + match = re.match(r"(\S+)\s+v(\S+):", line) + if match: + current_name = match.group(1) + current_version = match.group(2) + current_bins = [] + else: + current_name = None + elif current_name: + binary = line.strip() + if binary: + current_bins.append(binary) + + if current_name: + packages.append( + LanguagePackage( + name=current_name, + version=current_version, + binaries=sorted(current_bins), + ) + ) + + return packages + + def _detect_npm_global(self) -> NpmGlobalState: + if shutil.which("npm") is None: + return NpmGlobalState(present=False) + + version: str | None = None + result = run_command(["npm", "--version"]) + if result is not None and result.returncode == 0: + version = result.stdout.strip() or None + + packages = self._get_npm_global_packages() + return NpmGlobalState(present=True, version=version, packages=packages) + + @staticmethod + def _get_npm_global_packages() -> list[LanguagePackage]: + result = run_command(["npm", "list", "-g", "--json", "--depth=0"], timeout=15) + if result is None or result.returncode != 0: + return [] + try: + data = json.loads(result.stdout) + except (json.JSONDecodeError, ValueError): + return [] + + deps = data.get("dependencies", {}) + if not isinstance(deps, dict): + return [] + + packages: list[LanguagePackage] = [] + for name in sorted(deps): + if name == "npm": + continue + info = deps[name] + version = info.get("version") if isinstance(info, dict) else None + packages.append(LanguagePackage(name=name, version=version)) + + return packages + + def _detect_go(self) -> GoState: + if shutil.which("go") is None: + return GoState(present=False) + + version: str | None = None + result = run_command(["go", "version"]) + if result is not None and result.returncode == 0: + match = re.search(r"go(\d+\.\d+[\.\d]*)", result.stdout) + if match: + version = match.group(1) + + packages = self._get_go_packages() + return GoState(present=True, version=version, packages=packages) + + @staticmethod + def _get_go_packages() -> list[LanguagePackage]: + go_bin = Path.home() / "go" / "bin" + if not go_bin.is_dir(): + return [] + + packages: list[LanguagePackage] = [] + for binary in sorted(go_bin.iterdir()): + if not binary.is_file(): + continue + result = run_command(["go", "version", "-m", str(binary)], timeout=5) + if result is None or result.returncode != 0: + continue + mod_path: str | None = None + mod_version: str | None = None + for line in result.stdout.splitlines(): + parts = line.strip().split() + if len(parts) >= 3 and parts[0] == "mod": + mod_path = parts[1] + mod_version = parts[2].lstrip("v") + break + if mod_path: + packages.append( + LanguagePackage( + name=mod_path, + version=mod_version, + binaries=[binary.name], + ) + ) + + return packages + + def _detect_gem(self) -> GemState: + if shutil.which("gem") is None: + return GemState(present=False) + + version: str | None = None + result = run_command(["gem", "--version"]) + if result is not None and result.returncode == 0: + version = result.stdout.strip() or None + + packages = self._get_gem_packages() + return GemState(present=True, version=version, packages=packages) + + @staticmethod + def _get_gem_packages() -> list[LanguagePackage]: + result = run_command(["gem", "list", "--no-verbose"], timeout=15) + if result is None or result.returncode != 0: + return [] + + packages: list[LanguagePackage] = [] + for line in result.stdout.splitlines(): + match = re.match(r"(\S+)\s+\((.+)\)", line.strip()) + if match: + name = match.group(1) + version_str = match.group(2).split(",")[0].strip() + if version_str.startswith("default:"): + version_str = version_str.split(":", 1)[1].strip() + packages.append(LanguagePackage(name=name, version=version_str)) + + return packages diff --git a/tests/scanners/test_package_managers.py b/tests/scanners/test_package_managers.py index 9626dc7..6f8395f 100644 --- a/tests/scanners/test_package_managers.py +++ b/tests/scanners/test_package_managers.py @@ -430,3 +430,263 @@ def cmd_side_effect(cmd, **_kwargs): result = PackageManagersScanner()._detect_conda() assert len(result.environments) == 1 assert result.environments[0].packages == [] + + +class TestPipxDetection: + def test_not_present(self) -> None: + with patch(f"{_SCANNER_MODULE}.shutil.which", return_value=None): + result = PackageManagersScanner()._detect_pipx() + assert result.present is False + assert result.packages == [] + + def test_parses_packages(self, cmd_result) -> None: + pipx_json = json.dumps( + { + "venvs": { + "httpie": { + "metadata": { + "main_package": { + "package": "httpie", + "package_version": "3.2.3", + "apps": ["http", "https", "httpie"], + } + } + }, + "black": { + "metadata": { + "main_package": { + "package": "black", + "package_version": "24.4.0", + "apps": ["black", "blackd"], + } + } + }, + } + } + ) + + def side_effect(cmd, **_kwargs): + if cmd == ["pipx", "--version"]: + return cmd_result("1.6.0\n") + if cmd == ["pipx", "list", "--json"]: + return cmd_result(pipx_json) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/bin/pipx"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_pipx() + + assert result.present is True + assert result.version == "1.6.0" + assert len(result.packages) == 2 + black = next(p for p in result.packages if p.name == "black") + assert black.version == "24.4.0" + assert black.binaries == ["black", "blackd"] + + def test_invalid_json(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["pipx", "--version"]: + return cmd_result("1.6.0\n") + if cmd == ["pipx", "list", "--json"]: + return cmd_result("not json") + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/bin/pipx"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_pipx() + + assert result.present is True + assert result.packages == [] + + +class TestCargoDetection: + def test_not_present(self) -> None: + with patch(f"{_SCANNER_MODULE}.shutil.which", return_value=None): + result = PackageManagersScanner()._detect_cargo() + assert result.present is False + + def test_parses_packages(self, cmd_result) -> None: + cargo_output = "ast-grep v0.40.5:\n ast-grep\n sg\ncargo-audit v0.22.0:\n cargo-audit\n" + + def side_effect(cmd, **_kwargs): + if cmd == ["cargo", "--version"]: + return cmd_result("cargo 1.82.0 (8f40fc59f 2025-01-20)\n") + if cmd[:3] == ["cargo", "install", "--list"]: + return cmd_result(cargo_output) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/bin/cargo"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_cargo() + + assert result.present is True + assert result.version == "1.82.0" + assert len(result.packages) == 2 + ast = next(p for p in result.packages if p.name == "ast-grep") + assert ast.version == "0.40.5" + assert ast.binaries == ["ast-grep", "sg"] + + def test_empty_list(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["cargo", "--version"]: + return cmd_result("cargo 1.82.0\n") + if cmd[:3] == ["cargo", "install", "--list"]: + return cmd_result("") + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/bin/cargo"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_cargo() + + assert result.present is True + assert result.packages == [] + + +class TestNpmGlobalDetection: + def test_not_present(self) -> None: + with patch(f"{_SCANNER_MODULE}.shutil.which", return_value=None): + result = PackageManagersScanner()._detect_npm_global() + assert result.present is False + + def test_parses_packages(self, cmd_result) -> None: + npm_json = json.dumps( + { + "dependencies": { + "npm": {"version": "11.12.1"}, + "typescript": {"version": "5.9.3"}, + "pyright": {"version": "1.1.407"}, + } + } + ) + + def side_effect(cmd, **_kwargs): + if cmd == ["npm", "--version"]: + return cmd_result("11.12.1\n") + if "list" in cmd: + return cmd_result(npm_json) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/bin/npm"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_npm_global() + + assert result.present is True + assert result.version == "11.12.1" + assert len(result.packages) == 2 + assert not any(p.name == "npm" for p in result.packages) + ts = next(p for p in result.packages if p.name == "typescript") + assert ts.version == "5.9.3" + + +class TestGoDetection: + def test_not_present(self) -> None: + with patch(f"{_SCANNER_MODULE}.shutil.which", return_value=None): + result = PackageManagersScanner()._detect_go() + assert result.present is False + + def test_parses_packages(self, cmd_result, tmp_path: Path) -> None: + go_bin = tmp_path / "go" / "bin" + go_bin.mkdir(parents=True) + (go_bin / "gopls").write_text("binary") + + go_version_output = ( + "/tmp/go/bin/gopls: go1.23.0\n" + "\tpath\tgolang.org/x/tools/gopls\n" + "\tmod\tgolang.org/x/tools/gopls\tv0.17.1\t(none)\n" + "\tbuild\t...\n" + ) + + def side_effect(cmd, **_kwargs): + if cmd == ["go", "version"]: + return cmd_result("go version go1.23.0 darwin/arm64\n") + if cmd[0] == "go" and "version" in cmd and "-m" in cmd: + return cmd_result(go_version_output) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/go/bin/go"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + patch(f"{_SCANNER_MODULE}.Path.home", return_value=tmp_path), + ): + result = PackageManagersScanner()._detect_go() + + assert result.present is True + assert result.version == "1.23.0" + assert len(result.packages) == 1 + assert result.packages[0].name == "golang.org/x/tools/gopls" + assert result.packages[0].version == "0.17.1" + assert result.packages[0].binaries == ["gopls"] + + def test_no_go_bin_dir(self, cmd_result, tmp_path: Path) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["go", "version"]: + return cmd_result("go version go1.23.0 darwin/arm64\n") + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/go/bin/go"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + patch(f"{_SCANNER_MODULE}.Path.home", return_value=tmp_path), + ): + result = PackageManagersScanner()._detect_go() + + assert result.present is True + assert result.packages == [] + + +class TestGemDetection: + def test_not_present(self) -> None: + with patch(f"{_SCANNER_MODULE}.shutil.which", return_value=None): + result = PackageManagersScanner()._detect_gem() + assert result.present is False + + def test_parses_packages(self, cmd_result) -> None: + gem_output = "bundler (2.5.4, default: 2.4.22)\nrake (13.2.1)\nrdoc (6.7.0, 6.6.3)\n" + + def side_effect(cmd, **_kwargs): + if cmd == ["gem", "--version"]: + return cmd_result("3.5.11\n") + if "list" in cmd: + return cmd_result(gem_output) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/bin/gem"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_gem() + + assert result.present is True + assert result.version == "3.5.11" + assert len(result.packages) == 3 + bundler = next(p for p in result.packages if p.name == "bundler") + assert bundler.version == "2.5.4" + rake = next(p for p in result.packages if p.name == "rake") + assert rake.version == "13.2.1" + + def test_command_fails(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["gem", "--version"]: + return cmd_result("3.5.11\n") + if "list" in cmd: + return None + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/bin/gem"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_gem() + + assert result.present is True + assert result.packages == [] From e059843319375cae1a60f7400c37d1f766a527ce Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:20:01 -0400 Subject: [PATCH 4/8] fix(scanners): uses removeprefix for disabled service marker --- src/mac2nix/scanners/network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mac2nix/scanners/network.py b/src/mac2nix/scanners/network.py index a694f14..9fc986d 100644 --- a/src/mac2nix/scanners/network.py +++ b/src/mac2nix/scanners/network.py @@ -265,7 +265,7 @@ def _get_network_services(self) -> list[str]: if not stripped or stripped.startswith("An asterisk"): continue if stripped.startswith("*"): - stripped = stripped.lstrip("*").strip() + stripped = stripped.removeprefix("*").strip() if stripped: services.append(stripped) return services From 7a27f115b6b2b53b072bac27699f379557798602 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:26:19 -0400 Subject: [PATCH 5/8] refactor(scanners): review fixes from quality gate --- .../scanners/package_managers_scanner.py | 34 +++--- src/mac2nix/scanners/system_scanner.py | 10 +- tests/scanners/test_package_managers.py | 104 ++++++++++++++++++ tests/scanners/test_system_scanner.py | 11 ++ 4 files changed, 136 insertions(+), 23 deletions(-) diff --git a/src/mac2nix/scanners/package_managers_scanner.py b/src/mac2nix/scanners/package_managers_scanner.py index 5f8a4a3..7ad7f50 100644 --- a/src/mac2nix/scanners/package_managers_scanner.py +++ b/src/mac2nix/scanners/package_managers_scanner.py @@ -1,4 +1,4 @@ -"""Package managers scanner — detects MacPorts, Conda/Mamba, and language ecosystem managers.""" +"""Package managers scanner — detects MacPorts, Conda/Mamba, pipx, Cargo, npm global, Go, and gem.""" from __future__ import annotations @@ -6,6 +6,7 @@ import logging import re import shutil +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from mac2nix.models.package_managers import ( @@ -382,30 +383,29 @@ def _get_go_packages() -> list[LanguagePackage]: if not go_bin.is_dir(): return [] - packages: list[LanguagePackage] = [] - for binary in sorted(go_bin.iterdir()): - if not binary.is_file(): - continue + binaries = sorted(b for b in go_bin.iterdir() if b.is_file()) + if not binaries: + return [] + + def _inspect(binary: Path) -> LanguagePackage | None: result = run_command(["go", "version", "-m", str(binary)], timeout=5) if result is None or result.returncode != 0: - continue - mod_path: str | None = None - mod_version: str | None = None + return None for line in result.stdout.splitlines(): parts = line.strip().split() if len(parts) >= 3 and parts[0] == "mod": - mod_path = parts[1] - mod_version = parts[2].lstrip("v") - break - if mod_path: - packages.append( - LanguagePackage( - name=mod_path, - version=mod_version, + return LanguagePackage( + name=parts[1], + version=parts[2].lstrip("v"), binaries=[binary.name], ) - ) + return None + packages: list[LanguagePackage] = [] + with ThreadPoolExecutor(max_workers=min(8, len(binaries))) as pool: + for pkg in pool.map(_inspect, binaries): + if pkg is not None: + packages.append(pkg) return packages def _detect_gem(self) -> GemState: diff --git a/src/mac2nix/scanners/system_scanner.py b/src/mac2nix/scanners/system_scanner.py index 2ba4fbc..59c8863 100644 --- a/src/mac2nix/scanners/system_scanner.py +++ b/src/mac2nix/scanners/system_scanner.py @@ -38,7 +38,7 @@ def is_available(self) -> bool: def scan(self) -> SystemConfig: computer_name = self._get_computer_name() - hostname = self._get_hostname() + hostname = self._get_hostname(computer_name) local_hostname, dns_hostname = self._get_additional_hostnames() timezone = self._get_timezone() locale = self._get_locale() @@ -99,11 +99,9 @@ def _get_computer_name(self) -> str | None: return name or None return None - def _get_hostname(self) -> str: - result = run_command(["scutil", "--get", "ComputerName"]) - if result is not None and result.returncode == 0: - return result.stdout.strip() - # Fallback to LocalHostName + def _get_hostname(self, computer_name: str | None = None) -> str: + if computer_name: + return computer_name result = run_command(["scutil", "--get", "LocalHostName"]) if result is not None and result.returncode == 0: return result.stdout.strip() diff --git a/tests/scanners/test_package_managers.py b/tests/scanners/test_package_managers.py index 6f8395f..845200f 100644 --- a/tests/scanners/test_package_managers.py +++ b/tests/scanners/test_package_managers.py @@ -502,6 +502,23 @@ def side_effect(cmd, **_kwargs): assert result.present is True assert result.packages == [] + def test_venvs_not_dict(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["pipx", "--version"]: + return cmd_result("1.6.0\n") + if cmd == ["pipx", "list", "--json"]: + return cmd_result(json.dumps({"venvs": []})) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/bin/pipx"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_pipx() + + assert result.present is True + assert result.packages == [] + class TestCargoDetection: def test_not_present(self) -> None: @@ -531,6 +548,9 @@ def side_effect(cmd, **_kwargs): ast = next(p for p in result.packages if p.name == "ast-grep") assert ast.version == "0.40.5" assert ast.binaries == ["ast-grep", "sg"] + audit = next(p for p in result.packages if p.name == "cargo-audit") + assert audit.version == "0.22.0" + assert audit.binaries == ["cargo-audit"] def test_empty_list(self, cmd_result) -> None: def side_effect(cmd, **_kwargs): @@ -587,6 +607,23 @@ def side_effect(cmd, **_kwargs): ts = next(p for p in result.packages if p.name == "typescript") assert ts.version == "5.9.3" + def test_list_command_fails(self, cmd_result) -> None: + def side_effect(cmd, **_kwargs): + if cmd == ["npm", "--version"]: + return cmd_result("11.12.1\n") + if "list" in cmd: + return cmd_result("ERR!", returncode=1) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/bin/npm"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_npm_global() + + assert result.present is True + assert result.packages == [] + class TestGoDetection: def test_not_present(self) -> None: @@ -643,6 +680,54 @@ def side_effect(cmd, **_kwargs): assert result.present is True assert result.packages == [] + def test_binary_fails_version_check(self, cmd_result, tmp_path: Path) -> None: + go_bin = tmp_path / "go" / "bin" + go_bin.mkdir(parents=True) + (go_bin / "gopls").write_text("binary") + (go_bin / "bad-tool").write_text("not-go") + + go_version_output = "/tmp/go/bin/gopls: go1.23.0\n\tmod\tgolang.org/x/tools/gopls\tv0.17.1\t(none)\n" + + def side_effect(cmd, **_kwargs): + if cmd == ["go", "version"]: + return cmd_result("go version go1.23.0 darwin/arm64\n") + if cmd[0] == "go" and "-m" in cmd and "bad-tool" in cmd[-1]: + return cmd_result("", returncode=1) + if cmd[0] == "go" and "-m" in cmd: + return cmd_result(go_version_output) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/go/bin/go"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + patch(f"{_SCANNER_MODULE}.Path.home", return_value=tmp_path), + ): + result = PackageManagersScanner()._detect_go() + + assert len(result.packages) == 1 + assert result.packages[0].name == "golang.org/x/tools/gopls" + + def test_binary_no_mod_line(self, cmd_result, tmp_path: Path) -> None: + go_bin = tmp_path / "go" / "bin" + go_bin.mkdir(parents=True) + (go_bin / "old-tool").write_text("binary") + + def side_effect(cmd, **_kwargs): + if cmd == ["go", "version"]: + return cmd_result("go version go1.23.0 darwin/arm64\n") + if cmd[0] == "go" and "-m" in cmd: + return cmd_result("/tmp/go/bin/old-tool: go1.23.0\n\tpath\tcmd/old-tool\n") + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/go/bin/go"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + patch(f"{_SCANNER_MODULE}.Path.home", return_value=tmp_path), + ): + result = PackageManagersScanner()._detect_go() + + assert result.packages == [] + class TestGemDetection: def test_not_present(self) -> None: @@ -690,3 +775,22 @@ def side_effect(cmd, **_kwargs): assert result.present is True assert result.packages == [] + + def test_default_prefix_version(self, cmd_result) -> None: + gem_output = "bundler (default: 2.4.22)\nrake (13.2.1)\n" + + def side_effect(cmd, **_kwargs): + if cmd == ["gem", "--version"]: + return cmd_result("3.5.11\n") + if "list" in cmd: + return cmd_result(gem_output) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/bin/gem"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_gem() + + bundler = next(p for p in result.packages if p.name == "bundler") + assert bundler.version == "2.4.22" diff --git a/tests/scanners/test_system_scanner.py b/tests/scanners/test_system_scanner.py index a1fcd0d..3e90f6b 100644 --- a/tests/scanners/test_system_scanner.py +++ b/tests/scanners/test_system_scanner.py @@ -71,6 +71,17 @@ def test_computer_name_not_set(self) -> None: assert result.computer_name is None + def test_computer_name_empty_string(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("") + return None + + with patch("mac2nix.scanners.system_scanner.run_command", side_effect=side_effect): + result = SystemScanner().scan() + + assert result.computer_name is None + def test_timezone(self, cmd_result) -> None: def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: if cmd == ["scutil", "--get", "ComputerName"]: From c4f56c99a3f1ba0d2980d18d3077fc66074077bc Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:33:03 -0400 Subject: [PATCH 6/8] fix(scanners): addresses adversarial review findings --- src/mac2nix/scanners/_utils.py | 4 +++- src/mac2nix/scanners/package_managers_scanner.py | 16 +++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index d6565d3..6726283 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -417,7 +417,9 @@ def _parse_xml_value(element: ElementTree.Element) -> Any: # string, date, data → text; integer/real → parsed; unknown → text converters: dict[str, Any] = {"integer": int, "real": float} converter = converters.get(tag) - return converter(text) if converter else text + if converter and text: + return converter(text) + return text def read_launchd_plists() -> list[tuple[Path, str, dict[str, Any]]]: diff --git a/src/mac2nix/scanners/package_managers_scanner.py b/src/mac2nix/scanners/package_managers_scanner.py index 7ad7f50..2897b40 100644 --- a/src/mac2nix/scanners/package_managers_scanner.py +++ b/src/mac2nix/scanners/package_managers_scanner.py @@ -259,6 +259,8 @@ def _get_pipx_packages() -> list[LanguagePackage]: packages: list[LanguagePackage] = [] for _venv_name, venv_data in sorted(venvs.items()): + if not isinstance(venv_data, dict): + continue meta = venv_data.get("metadata", {}).get("main_package", {}) name = meta.get("package") if not name: @@ -304,7 +306,7 @@ def _get_cargo_packages() -> list[LanguagePackage]: binaries=sorted(current_bins), ) ) - match = re.match(r"(\S+)\s+v(\S+):", line) + match = re.match(r"(\S+)\s+v(\S+?)(?:\s+\(.*\))?:", line) if match: current_name = match.group(1) current_version = match.group(2) @@ -401,12 +403,16 @@ def _inspect(binary: Path) -> LanguagePackage | None: ) return None - packages: list[LanguagePackage] = [] + merged: dict[str, LanguagePackage] = {} with ThreadPoolExecutor(max_workers=min(8, len(binaries))) as pool: for pkg in pool.map(_inspect, binaries): - if pkg is not None: - packages.append(pkg) - return packages + if pkg is None: + continue + if pkg.name in merged: + merged[pkg.name].binaries = sorted({*merged[pkg.name].binaries, *pkg.binaries}) + else: + merged[pkg.name] = pkg + return sorted(merged.values(), key=lambda p: p.name) def _detect_gem(self) -> GemState: if shutil.which("gem") is None: From fa198c19b0fbcd3b1d0879878f0363a0586e4934 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:38:09 -0400 Subject: [PATCH 7/8] fix(scanners): addresses adversarial pass 2 findings --- .../scanners/package_managers_scanner.py | 12 ++++- tests/scanners/test_package_managers.py | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/mac2nix/scanners/package_managers_scanner.py b/src/mac2nix/scanners/package_managers_scanner.py index 2897b40..98749ac 100644 --- a/src/mac2nix/scanners/package_managers_scanner.py +++ b/src/mac2nix/scanners/package_managers_scanner.py @@ -4,6 +4,7 @@ import json import logging +import os import re import shutil from concurrent.futures import ThreadPoolExecutor @@ -344,7 +345,9 @@ def _detect_npm_global(self) -> NpmGlobalState: @staticmethod def _get_npm_global_packages() -> list[LanguagePackage]: result = run_command(["npm", "list", "-g", "--json", "--depth=0"], timeout=15) - if result is None or result.returncode != 0: + if result is None: + return [] + if result.returncode != 0 and not result.stdout.strip(): return [] try: data = json.loads(result.stdout) @@ -381,7 +384,12 @@ def _detect_go(self) -> GoState: @staticmethod def _get_go_packages() -> list[LanguagePackage]: - go_bin = Path.home() / "go" / "bin" + gobin = os.environ.get("GOBIN") + if gobin: + go_bin = Path(gobin) + else: + gopath = os.environ.get("GOPATH") + go_bin = Path(gopath) / "bin" if gopath else Path.home() / "go" / "bin" if not go_bin.is_dir(): return [] diff --git a/tests/scanners/test_package_managers.py b/tests/scanners/test_package_managers.py index 845200f..c336d19 100644 --- a/tests/scanners/test_package_managers.py +++ b/tests/scanners/test_package_managers.py @@ -552,6 +552,33 @@ def side_effect(cmd, **_kwargs): assert audit.version == "0.22.0" assert audit.binaries == ["cargo-audit"] + def test_path_installed_crate(self, cmd_result) -> None: + cargo_output = ( + "my-tool v0.1.0 (/Users/me/projects/my-tool):\n" + " my-tool\n" + "git-crate v0.3.0 (https://github.com/user/repo#abc1234):\n" + " git-crate\n" + ) + + def side_effect(cmd, **_kwargs): + if cmd == ["cargo", "--version"]: + return cmd_result("cargo 1.82.0\n") + if cmd[:3] == ["cargo", "install", "--list"]: + return cmd_result(cargo_output) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/bin/cargo"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_cargo() + + assert len(result.packages) == 2 + my_tool = next(p for p in result.packages if p.name == "my-tool") + assert my_tool.version == "0.1.0" + git_crate = next(p for p in result.packages if p.name == "git-crate") + assert git_crate.version == "0.3.0" + def test_empty_list(self, cmd_result) -> None: def side_effect(cmd, **_kwargs): if cmd == ["cargo", "--version"]: @@ -728,6 +755,32 @@ def side_effect(cmd, **_kwargs): assert result.packages == [] + def test_multi_binary_same_module_merged(self, cmd_result, tmp_path: Path) -> None: + go_bin = tmp_path / "go" / "bin" + go_bin.mkdir(parents=True) + (go_bin / "goimports").write_text("binary") + (go_bin / "gorename").write_text("binary") + + def side_effect(cmd, **_kwargs): + if cmd == ["go", "version"]: + return cmd_result("go version go1.23.0 darwin/arm64\n") + if cmd[0] == "go" and "-m" in cmd: + return cmd_result(f"{cmd[-1]}: go1.23.0\n\tmod\tgolang.org/x/tools\tv0.28.0\t(none)\n") + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/go/bin/go"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + patch(f"{_SCANNER_MODULE}.Path.home", return_value=tmp_path), + patch.dict("os.environ", {}, clear=True), + ): + result = PackageManagersScanner()._detect_go() + + assert len(result.packages) == 1 + assert result.packages[0].name == "golang.org/x/tools" + assert result.packages[0].version == "0.28.0" + assert sorted(result.packages[0].binaries) == ["goimports", "gorename"] + class TestGemDetection: def test_not_present(self) -> None: From e2cac44a90a651898bae8fbe2639ade09d03c1bb Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 4 May 2026 10:00:51 -0400 Subject: [PATCH 8/8] fix(scanners): addresses pr-review findings and gaps --- pyproject.toml | 2 +- src/mac2nix/scanners/_utils.py | 28 +++- src/mac2nix/scanners/launch_agents.py | 15 +- src/mac2nix/scanners/library_scanner.py | 20 +-- src/mac2nix/scanners/network.py | 1 - .../scanners/package_managers_scanner.py | 35 +++-- src/mac2nix/scanners/system_scanner.py | 4 +- tests/scanners/test_launch_agents.py | 31 ++++ tests/scanners/test_library_scanner.py | 12 +- tests/scanners/test_package_managers.py | 137 ++++++++++++++++++ tests/scanners/test_system_scanner.py | 14 ++ tests/scanners/test_utils.py | 55 +++++++ 12 files changed, 300 insertions(+), 54 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee2466a..7e0ea7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"**/tests/**/*.py" = ["S101", "S105", "S106", "S107", "S108", "SLF001", "ARG001"] # assert + test data + credentials + private access + unused mock args OK in tests +"**/tests/**/*.py" = ["S101", "S105", "S106", "S107", "S108", "S314", "SLF001", "ARG001"] # assert + test data + credentials + XML parsing + private access + unused mock args OK in tests [tool.ruff.lint.isort] known-first-party = ["mac2nix"] diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index 6726283..d42d563 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -288,6 +288,22 @@ def parallel_walk_dirs[T]( ] +SENSITIVE_KEY_PATTERNS = frozenset({"_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH"}) + + +def redact_sensitive_keys(data: dict[str, Any]) -> None: + """Recursively redact values whose key contains a sensitive pattern.""" + 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) + + def sanitize_plist_values(obj: Any) -> Any: """Recursively convert non-JSON-safe plist values. @@ -362,7 +378,7 @@ def read_plist_safe(path: Path) -> dict[str, Any] | list[Any] | None: return sanitize_plist_values(data) -def _read_plist_via_plutil(path: Path) -> dict[str, Any] | None: +def _read_plist_via_plutil(path: Path) -> dict[str, Any] | list[Any] | None: """Fallback plist reader using plutil XML conversion. Parses the XML manually to handle dates that Python's datetime can't @@ -378,10 +394,14 @@ def _read_plist_via_plutil(path: Path) -> dict[str, Any] | None: return None top_dict = root.find("dict") - if top_dict is None: - return None + if top_dict is not None: + return _parse_xml_dict(top_dict) + + top_array = root.find("array") + if top_array is not None: + return [_parse_xml_value(child) for child in top_array] - return _parse_xml_dict(top_dict) + return None def _parse_xml_dict(element: ElementTree.Element) -> dict[str, Any]: diff --git a/src/mac2nix/scanners/launch_agents.py b/src/mac2nix/scanners/launch_agents.py index 1eb973b..7b8f291 100644 --- a/src/mac2nix/scanners/launch_agents.py +++ b/src/mac2nix/scanners/launch_agents.py @@ -10,7 +10,7 @@ from typing import Any from mac2nix.models.services import LaunchAgentEntry, LaunchAgentSource, LaunchAgentsResult -from mac2nix.scanners._utils import read_launchd_plists, run_command +from mac2nix.scanners._utils import SENSITIVE_KEY_PATTERNS, read_launchd_plists, redact_sensitive_keys, run_command from mac2nix.scanners.base import BaseScannerPlugin, register logger = logging.getLogger(__name__) @@ -23,8 +23,6 @@ "daemon": LaunchAgentSource.DAEMON, } -_SENSITIVE_ENV_PATTERNS = {"_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH"} - @register("launch_agents") class LaunchAgentsScanner(BaseScannerPlugin): @@ -76,14 +74,7 @@ def _parse_agent_data( # Deep copy to avoid mutating the shared prefetch data raw_plist = copy.deepcopy(data) - env_raw = data.get("EnvironmentVariables") - if isinstance(env_raw, dict): - redacted = { - k: "***REDACTED***" if any(p in k.upper() for p in _SENSITIVE_ENV_PATTERNS) else v - for k, v in env_raw.items() - } - if redacted != env_raw: - raw_plist["EnvironmentVariables"] = redacted + redact_sensitive_keys(raw_plist) # Extract filtered environment variables env_vars = data.get("EnvironmentVariables") @@ -91,7 +82,7 @@ def _parse_agent_data( 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): + if any(p in key.upper() for p in SENSITIVE_KEY_PATTERNS): filtered_env[key] = "***REDACTED***" else: filtered_env[key] = str(val) diff --git a/src/mac2nix/scanners/library_scanner.py b/src/mac2nix/scanners/library_scanner.py index dab9dc4..220bc98 100644 --- a/src/mac2nix/scanners/library_scanner.py +++ b/src/mac2nix/scanners/library_scanner.py @@ -28,6 +28,7 @@ hash_file, parallel_walk_dirs, read_plist_safe, + redact_sensitive_keys, run_command, ) from mac2nix.scanners.base import BaseScannerPlugin, register @@ -83,8 +84,6 @@ } ) -_SENSITIVE_KEY_PATTERNS = {"_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH"} - # Redacts values in key=value / key: value lines where the key contains a sensitive word. # Uses separator-prefixed compound patterns ([_.-]key, [_.-]token, etc.) to avoid false # positives on words like "monkey", "turkey", "keyboard". Standalone patterns (password, @@ -116,19 +115,6 @@ ) -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") class LibraryScanner(BaseScannerPlugin): @property @@ -393,7 +379,7 @@ def _classify_file(self, filepath: Path) -> LibraryFileEntry | None: raw_plist = read_plist_safe(filepath) if isinstance(raw_plist, dict): plist_content = raw_plist - _redact_sensitive_keys(plist_content) + redact_sensitive_keys(plist_content) content_hash = hash_file(filepath) strategy = "plist_capture" if plist_content else "hash_only" elif suffix in {".txt", ".md", ".cfg", ".conf", ".ini", ".yaml", ".yml", ".json", ".xml", ".toml"}: @@ -508,7 +494,7 @@ def _parse_workflow(wf_path: Path) -> WorkflowEntry | None: if doc_plist.is_file(): raw = read_plist_safe(doc_plist) if isinstance(raw, dict): - _redact_sensitive_keys(raw) + redact_sensitive_keys(raw) definition = raw return WorkflowEntry( diff --git a/src/mac2nix/scanners/network.py b/src/mac2nix/scanners/network.py index 9fc986d..7057616 100644 --- a/src/mac2nix/scanners/network.py +++ b/src/mac2nix/scanners/network.py @@ -211,7 +211,6 @@ def _get_wifi_networks(self, interfaces: list[NetworkInterface]) -> list[str]: networks = [] for line in result.stdout.splitlines(): stripped = line.strip() - # Skip header line if stripped.startswith("Preferred networks"): continue if stripped: diff --git a/src/mac2nix/scanners/package_managers_scanner.py b/src/mac2nix/scanners/package_managers_scanner.py index 98749ac..1d6dc62 100644 --- a/src/mac2nix/scanners/package_managers_scanner.py +++ b/src/mac2nix/scanners/package_managers_scanner.py @@ -40,15 +40,24 @@ def name(self) -> str: return "package_managers" def scan(self) -> PackageManagersResult: - return PackageManagersResult( - macports=self._detect_macports(), - conda=self._detect_conda(), - pipx=self._detect_pipx(), - cargo=self._detect_cargo(), - npm_global=self._detect_npm_global(), - go=self._detect_go(), - gem=self._detect_gem(), - ) + detectors = { + "macports": self._detect_macports, + "conda": self._detect_conda, + "pipx": self._detect_pipx, + "cargo": self._detect_cargo, + "npm_global": self._detect_npm_global, + "go": self._detect_go, + "gem": self._detect_gem, + } + results: dict[str, object] = {} + with ThreadPoolExecutor(max_workers=len(detectors)) as pool: + futures = {pool.submit(fn): name for name, fn in detectors.items()} + for future, name in futures.items(): + try: + results[name] = future.result() + except Exception: + logger.exception("Detector '%s' failed", name) + return PackageManagersResult(**results) # type: ignore[arg-type] def _detect_macports(self) -> MacPortsState: port_bin = Path("/opt/local/bin/port") @@ -83,7 +92,6 @@ def _get_macports_packages() -> list[MacPortsPackage]: packages: list[MacPortsPackage] = [] for line in result.stdout.splitlines(): - # Skip header line if not line.startswith(" "): continue stripped = line.strip() @@ -417,7 +425,12 @@ def _inspect(binary: Path) -> LanguagePackage | None: if pkg is None: continue if pkg.name in merged: - merged[pkg.name].binaries = sorted({*merged[pkg.name].binaries, *pkg.binaries}) + existing = merged[pkg.name] + merged[pkg.name] = LanguagePackage( + name=existing.name, + version=existing.version, + binaries=sorted({*existing.binaries, *pkg.binaries}), + ) else: merged[pkg.name] = pkg return sorted(merged.values(), key=lambda p: p.name) diff --git a/src/mac2nix/scanners/system_scanner.py b/src/mac2nix/scanners/system_scanner.py index 59c8863..f63f51a 100644 --- a/src/mac2nix/scanners/system_scanner.py +++ b/src/mac2nix/scanners/system_scanner.py @@ -99,12 +99,12 @@ def _get_computer_name(self) -> str | None: return name or None return None - def _get_hostname(self, computer_name: str | None = None) -> str: + def _get_hostname(self, computer_name: str | None) -> str: if computer_name: return computer_name result = run_command(["scutil", "--get", "LocalHostName"]) if result is not None and result.returncode == 0: - return result.stdout.strip() + return result.stdout.strip() or "unknown" return "unknown" def _get_timezone(self) -> str | None: diff --git a/tests/scanners/test_launch_agents.py b/tests/scanners/test_launch_agents.py index 72db2e1..2b96433 100644 --- a/tests/scanners/test_launch_agents.py +++ b/tests/scanners/test_launch_agents.py @@ -304,6 +304,37 @@ def test_raw_plist_env_also_redacted(self) -> None: assert entry.raw_plist["EnvironmentVariables"]["API_TOKEN"] == redacted assert entry.raw_plist["EnvironmentVariables"]["SAFE"] == "ok" + def test_raw_plist_nested_sensitive_keys_redacted(self) -> None: + plist_path = Path("/Users/test/Library/LaunchAgents/com.test.nested.plist") + redacted = "***REDACTED***" + plist_data = { + "Label": "com.test.nested", + "ProgramArguments": ["/usr/bin/curl"], + "CustomConfig": { + "API_TOKEN": "secret_token_value", + "Endpoint": "https://example.com", + "Nested": { + "DB_PASSWORD": "hunter2", + "Port": 5432, + }, + }, + } + + 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["CustomConfig"]["API_TOKEN"] == redacted + assert entry.raw_plist["CustomConfig"]["Endpoint"] == "https://example.com" + assert entry.raw_plist["CustomConfig"]["Nested"]["DB_PASSWORD"] == redacted + assert entry.raw_plist["CustomConfig"]["Nested"]["Port"] == 5432 + def test_raw_plist_is_deep_copy(self) -> None: plist_data = { "Label": "com.test.copy", diff --git a/tests/scanners/test_library_scanner.py b/tests/scanners/test_library_scanner.py index c9c9bd3..16bbe58 100644 --- a/tests/scanners/test_library_scanner.py +++ b/tests/scanners/test_library_scanner.py @@ -7,12 +7,12 @@ import pytest from mac2nix.models.files import ConfigFileType, LibraryResult +from mac2nix.scanners._utils import redact_sensitive_keys from mac2nix.scanners.library_scanner import ( _COVERED_DIRS, _SENSITIVE_VALUE_RE, _TRANSIENT_DIRS, LibraryScanner, - _redact_sensitive_keys, ) @@ -827,7 +827,7 @@ def test_no_library_dir(self, tmp_path: Path) -> None: class TestRedactSensitiveKeys: def test_redacts_api_key(self) -> None: data = {"API_KEY": "secret123", "name": "test"} - _redact_sensitive_keys(data) + redact_sensitive_keys(data) redacted = "***REDACTED***" assert data["API_KEY"] == redacted @@ -835,7 +835,7 @@ def test_redacts_api_key(self) -> None: def test_redacts_nested_dict(self) -> None: data = {"config": {"DB_PASSWORD": "secret", "host": "localhost"}} - _redact_sensitive_keys(data) + redact_sensitive_keys(data) redacted = "***REDACTED***" assert data["config"]["DB_PASSWORD"] == redacted @@ -843,7 +843,7 @@ def test_redacts_nested_dict(self) -> None: def test_redacts_in_list(self) -> None: data = {"items": [{"ACCESS_TOKEN": "token123"}, {"normal": "value"}]} - _redact_sensitive_keys(data) + redact_sensitive_keys(data) redacted = "***REDACTED***" assert data["items"][0]["ACCESS_TOKEN"] == redacted @@ -851,14 +851,14 @@ def test_redacts_in_list(self) -> None: def test_case_insensitive_match(self) -> None: data = {"my_auth_header": "Bearer xyz"} - _redact_sensitive_keys(data) + 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) + redact_sensitive_keys(data) assert data == {"name": "test", "count": 42} diff --git a/tests/scanners/test_package_managers.py b/tests/scanners/test_package_managers.py index c336d19..87b891a 100644 --- a/tests/scanners/test_package_managers.py +++ b/tests/scanners/test_package_managers.py @@ -485,6 +485,40 @@ def side_effect(cmd, **_kwargs): assert black.version == "24.4.0" assert black.binaries == ["black", "blackd"] + def test_package_with_no_apps(self, cmd_result) -> None: + pipx_json = json.dumps( + { + "venvs": { + "lib-only": { + "metadata": { + "main_package": { + "package": "lib-only", + "package_version": "1.0.0", + "apps": [], + } + } + }, + } + } + ) + + def side_effect(cmd, **_kwargs): + if cmd == ["pipx", "--version"]: + return cmd_result("1.6.0\n") + if cmd == ["pipx", "list", "--json"]: + return cmd_result(pipx_json) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/bin/pipx"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_pipx() + + assert len(result.packages) == 1 + assert result.packages[0].name == "lib-only" + assert result.packages[0].binaries == [] + def test_invalid_json(self, cmd_result) -> None: def side_effect(cmd, **_kwargs): if cmd == ["pipx", "--version"]: @@ -579,6 +613,33 @@ def side_effect(cmd, **_kwargs): git_crate = next(p for p in result.packages if p.name == "git-crate") assert git_crate.version == "0.3.0" + def test_malformed_header_skipped(self, cmd_result) -> None: + cargo_output = ( + "good-tool v1.0.0:\n" + " good-bin\n" + "GARBAGE LINE WITH NO VERSION\n" + " orphan-bin\n" + "another-tool v2.0.0:\n" + " another-bin\n" + ) + + def side_effect(cmd, **_kwargs): + if cmd == ["cargo", "--version"]: + return cmd_result("cargo 1.82.0\n") + if cmd[:3] == ["cargo", "install", "--list"]: + return cmd_result(cargo_output) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/bin/cargo"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_cargo() + + assert len(result.packages) == 2 + names = {p.name for p in result.packages} + assert names == {"good-tool", "another-tool"} + def test_empty_list(self, cmd_result) -> None: def side_effect(cmd, **_kwargs): if cmd == ["cargo", "--version"]: @@ -651,6 +712,33 @@ def side_effect(cmd, **_kwargs): assert result.present is True assert result.packages == [] + def test_nonzero_exit_with_valid_json_returns_packages(self, cmd_result) -> None: + npm_json = json.dumps( + { + "dependencies": { + "npm": {"version": "11.12.1"}, + "eslint": {"version": "9.1.0"}, + } + } + ) + + def side_effect(cmd, **_kwargs): + if cmd == ["npm", "--version"]: + return cmd_result("11.12.1\n") + if "list" in cmd: + return cmd_result(npm_json, returncode=1) + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/bin/npm"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + ): + result = PackageManagersScanner()._detect_npm_global() + + assert result.present is True + assert len(result.packages) == 1 + assert result.packages[0].name == "eslint" + class TestGoDetection: def test_not_present(self) -> None: @@ -681,6 +769,7 @@ def side_effect(cmd, **_kwargs): patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/go/bin/go"), patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), patch(f"{_SCANNER_MODULE}.Path.home", return_value=tmp_path), + patch.dict("os.environ", {}, clear=True), ): result = PackageManagersScanner()._detect_go() @@ -701,6 +790,7 @@ def side_effect(cmd, **_kwargs): patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/go/bin/go"), patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), patch(f"{_SCANNER_MODULE}.Path.home", return_value=tmp_path), + patch.dict("os.environ", {}, clear=True), ): result = PackageManagersScanner()._detect_go() @@ -728,6 +818,7 @@ def side_effect(cmd, **_kwargs): patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/go/bin/go"), patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), patch(f"{_SCANNER_MODULE}.Path.home", return_value=tmp_path), + patch.dict("os.environ", {}, clear=True), ): result = PackageManagersScanner()._detect_go() @@ -750,6 +841,7 @@ def side_effect(cmd, **_kwargs): patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/go/bin/go"), patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), patch(f"{_SCANNER_MODULE}.Path.home", return_value=tmp_path), + patch.dict("os.environ", {}, clear=True), ): result = PackageManagersScanner()._detect_go() @@ -781,6 +873,51 @@ def side_effect(cmd, **_kwargs): assert result.packages[0].version == "0.28.0" assert sorted(result.packages[0].binaries) == ["goimports", "gorename"] + def test_gobin_env_var(self, cmd_result, tmp_path: Path) -> None: + custom_bin = tmp_path / "custom-bin" + custom_bin.mkdir() + (custom_bin / "mytool").write_text("binary") + + def side_effect(cmd, **_kwargs): + if cmd == ["go", "version"]: + return cmd_result("go version go1.23.0 darwin/arm64\n") + if cmd[0] == "go" and "-m" in cmd: + return cmd_result(f"{cmd[-1]}: go1.23.0\n\tmod\texample.com/mytool\tv1.0.0\t(none)\n") + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/go/bin/go"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + patch.dict("os.environ", {"GOBIN": str(custom_bin)}, clear=True), + ): + result = PackageManagersScanner()._detect_go() + + assert len(result.packages) == 1 + assert result.packages[0].name == "example.com/mytool" + + def test_gopath_env_var(self, cmd_result, tmp_path: Path) -> None: + gopath = tmp_path / "gopath" + go_bin = gopath / "bin" + go_bin.mkdir(parents=True) + (go_bin / "tool").write_text("binary") + + def side_effect(cmd, **_kwargs): + if cmd == ["go", "version"]: + return cmd_result("go version go1.23.0 darwin/arm64\n") + if cmd[0] == "go" and "-m" in cmd: + return cmd_result(f"{cmd[-1]}: go1.23.0\n\tmod\texample.com/tool\tv2.0.0\t(none)\n") + return None + + with ( + patch(f"{_SCANNER_MODULE}.shutil.which", return_value="/usr/local/go/bin/go"), + patch(f"{_SCANNER_MODULE}.run_command", side_effect=side_effect), + patch.dict("os.environ", {"GOPATH": str(gopath)}, clear=True), + ): + result = PackageManagersScanner()._detect_go() + + assert len(result.packages) == 1 + assert result.packages[0].name == "example.com/tool" + class TestGemDetection: def test_not_present(self) -> None: diff --git a/tests/scanners/test_system_scanner.py b/tests/scanners/test_system_scanner.py index 3e90f6b..57d9b5e 100644 --- a/tests/scanners/test_system_scanner.py +++ b/tests/scanners/test_system_scanner.py @@ -54,6 +54,20 @@ def test_hostname_unknown_fallback(self) -> None: assert isinstance(result, SystemConfig) assert result.hostname == "unknown" + def test_hostname_empty_local_hostname_returns_unknown(self, cmd_result) -> None: + def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: + if cmd == ["scutil", "--get", "ComputerName"]: + return cmd_result("", returncode=1) + if cmd == ["scutil", "--get", "LocalHostName"]: + return cmd_result("\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 == "unknown" + def test_computer_name(self, cmd_result) -> None: def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str] | None: if cmd == ["scutil", "--get", "ComputerName"]: diff --git a/tests/scanners/test_utils.py b/tests/scanners/test_utils.py index c625db5..094ed95 100644 --- a/tests/scanners/test_utils.py +++ b/tests/scanners/test_utils.py @@ -5,8 +5,11 @@ from datetime import UTC, datetime from pathlib import Path from unittest.mock import patch +from xml.etree import ElementTree from mac2nix.scanners._utils import ( + _parse_xml_dict, + _parse_xml_value, hash_file, read_launchd_plists, read_plist_safe, @@ -267,3 +270,55 @@ def test_multiple_dirs(self, tmp_path: Path) -> None: assert len(results) == 2 source_keys = {r[1] for r in results} assert source_keys == {"user", "system"} + + +class TestParseXmlValue: + def test_empty_integer_element(self) -> None: + elem = ElementTree.fromstring("") + assert _parse_xml_value(elem) == "" + + def test_valid_integer(self) -> None: + elem = ElementTree.fromstring("42") + assert _parse_xml_value(elem) == 42 + + def test_valid_real(self) -> None: + elem = ElementTree.fromstring("3.14") + assert _parse_xml_value(elem) == 3.14 + + def test_empty_real_element(self) -> None: + elem = ElementTree.fromstring("") + assert _parse_xml_value(elem) == "" + + def test_data_element(self) -> None: + elem = ElementTree.fromstring("SGVsbG8=") + assert _parse_xml_value(elem) == "SGVsbG8=" + + def test_date_element(self) -> None: + elem = ElementTree.fromstring("2026-01-01T00:00:00Z") + assert _parse_xml_value(elem) == "2026-01-01T00:00:00Z" + + def test_nested_array_in_dict(self) -> None: + xml = "itemsab" + elem = ElementTree.fromstring(xml) + result = _parse_xml_dict(elem) + assert result == {"items": ["a", "b"]} + + def test_boolean_values(self) -> None: + assert _parse_xml_value(ElementTree.fromstring("")) is True + assert _parse_xml_value(ElementTree.fromstring("")) is False + + +class TestPlutilArrayRoot: + def test_plutil_fallback_handles_array_root(self, tmp_path: Path) -> None: + plist_file = tmp_path / "array.plist" + plist_file.write_bytes(plistlib.dumps(["item1", "item2", "item3"])) + + with patch( + "mac2nix.scanners._utils.plistlib.load", + side_effect=plistlib.InvalidFileException("Invalid file"), + ): + result = read_plist_safe(plist_file) + + assert result is not None + assert isinstance(result, list) + assert result == ["item1", "item2", "item3"]