From 4fa50f13d5b22f5c4d82783b00e07c5cbf470077 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 9 Mar 2026 16:26:08 -0400 Subject: [PATCH] =?UTF-8?q?fix(scanners):=20review=20findings=20=E2=80=94?= =?UTF-8?q?=20default=20audio,=20TCC=20errno,=20extension=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use coreaudio_default_audio_output_device marker to identify actual default audio device instead of assuming first-in-list - Add TCC errno distinction to read_launchd_plists for consistency with read_plist_safe (EPERM→DEBUG, EACCES→WARNING) - Clarify _parse_xml_dict loop condition (i+1 < len, not len-1) - Add YAML and XML to ConfigFileType enum and _EXTENSION_MAP - Remove _MAX_FILE_SIZE skip — hash_file already caps at 64KB, the size check was silently dropping entries from results --- src/mac2nix/models/files.py | 2 ++ src/mac2nix/scanners/_utils.py | 9 +++++--- src/mac2nix/scanners/app_config.py | 10 +++------ src/mac2nix/scanners/audio.py | 34 +++++++++++++++------------- tests/scanners/test_app_config.py | 29 ++++++++++++++++-------- tests/scanners/test_audio.py | 36 ++++++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 34 deletions(-) diff --git a/src/mac2nix/models/files.py b/src/mac2nix/models/files.py index 1c9c349..20bfc4b 100644 --- a/src/mac2nix/models/files.py +++ b/src/mac2nix/models/files.py @@ -30,6 +30,8 @@ class ConfigFileType(StrEnum): JSON = "json" PLIST = "plist" TOML = "toml" + YAML = "yaml" + XML = "xml" CONF = "conf" DATABASE = "database" UNKNOWN = "unknown" diff --git a/src/mac2nix/scanners/_utils.py b/src/mac2nix/scanners/_utils.py index 96f67c7..337c705 100644 --- a/src/mac2nix/scanners/_utils.py +++ b/src/mac2nix/scanners/_utils.py @@ -122,7 +122,7 @@ def _parse_xml_dict(element: ElementTree.Element) -> dict[str, Any]: result: dict[str, Any] = {} children = list(element) i = 0 - while i < len(children) - 1: + while i + 1 < len(children): if children[i].tag == "key": key = children[i].text or "" value_elem = children[i + 1] @@ -165,8 +165,11 @@ def read_launchd_plists() -> list[tuple[Path, str, dict[str, Any]]]: continue try: plist_files = sorted(agent_dir.glob("*.plist")) - except PermissionError: - logger.warning("Permission denied reading: %s", agent_dir) + except PermissionError as exc: + if exc.errno == errno.EPERM: + logger.debug("Skipping TCC-protected directory: %s", agent_dir) + else: + logger.warning("Permission denied reading: %s", agent_dir) continue for plist_path in plist_files: data = read_plist_safe(plist_path) diff --git a/src/mac2nix/scanners/app_config.py b/src/mac2nix/scanners/app_config.py index 73e0663..61a3b9f 100644 --- a/src/mac2nix/scanners/app_config.py +++ b/src/mac2nix/scanners/app_config.py @@ -16,6 +16,9 @@ ".json": ConfigFileType.JSON, ".plist": ConfigFileType.PLIST, ".toml": ConfigFileType.TOML, + ".yaml": ConfigFileType.YAML, + ".yml": ConfigFileType.YAML, + ".xml": ConfigFileType.XML, ".conf": ConfigFileType.CONF, ".cfg": ConfigFileType.CONF, ".ini": ConfigFileType.CONF, @@ -24,8 +27,6 @@ ".sqlite3": ConfigFileType.DATABASE, } -_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB - @register("app_config") class AppConfigScanner(BaseScannerPlugin): @@ -71,11 +72,6 @@ def _scan_app_dir(self, app_dir: Path, entries: list[AppConfigEntry]) -> None: for child in children: if not child.is_file(): continue - try: - if child.stat().st_size > _MAX_FILE_SIZE: - continue - except OSError: - continue ext = child.suffix.lower() file_type = _EXTENSION_MAP.get(ext, ConfigFileType.UNKNOWN) diff --git a/src/mac2nix/scanners/audio.py b/src/mac2nix/scanners/audio.py index 05e2e23..07ad5ef 100644 --- a/src/mac2nix/scanners/audio.py +++ b/src/mac2nix/scanners/audio.py @@ -23,8 +23,7 @@ def is_available(self) -> bool: return shutil.which("system_profiler") is not None def scan(self) -> AudioConfig: - input_devices, output_devices = self._get_audio_devices() - default_input, default_output = self._get_default_devices(input_devices, output_devices) + input_devices, output_devices, default_input, default_output = self._get_audio_devices() alert_volume = self._get_alert_volume() return AudioConfig( @@ -35,19 +34,23 @@ def scan(self) -> AudioConfig: alert_volume=alert_volume, ) - def _get_audio_devices(self) -> tuple[list[AudioDevice], list[AudioDevice]]: + def _get_audio_devices( + self, + ) -> tuple[list[AudioDevice], list[AudioDevice], str | None, str | None]: result = run_command(["system_profiler", "SPAudioDataType", "-json"], timeout=15) if result is None or result.returncode != 0: - return [], [] + return [], [], None, None try: data = json.loads(result.stdout) except (json.JSONDecodeError, ValueError): logger.warning("Failed to parse system_profiler audio output") - return [], [] + return [], [], None, None input_devices: list[AudioDevice] = [] output_devices: list[AudioDevice] = [] + default_input: str | None = None + default_output: str | None = None for item in data.get("SPAudioDataType", []): if not isinstance(item, dict): @@ -63,10 +66,20 @@ def _get_audio_devices(self) -> tuple[list[AudioDevice], list[AudioDevice]]: is_input, is_output = self._classify_device(device_data) if is_input: input_devices.append(device) + if "coreaudio_default_audio_input_device" in device_data: + default_input = name if is_output: output_devices.append(device) + if "coreaudio_default_audio_output_device" in device_data: + default_output = name + + # Fall back to first device if system_profiler didn't mark a default + if default_input is None and input_devices: + default_input = input_devices[0].name + if default_output is None and output_devices: + default_output = output_devices[0].name - return input_devices, output_devices + return input_devices, output_devices, default_input, default_output @staticmethod def _classify_device(device_data: dict[str, object]) -> tuple[bool, bool]: @@ -77,15 +90,6 @@ def _classify_device(device_data: dict[str, object]) -> tuple[bool, bool]: is_output = True return is_input, is_output - def _get_default_devices( - self, - input_devices: list[AudioDevice], - output_devices: list[AudioDevice], - ) -> tuple[str | None, str | None]: - default_input = input_devices[0].name if input_devices else None - default_output = output_devices[0].name if output_devices else None - return default_input, default_output - def _get_alert_volume(self) -> float | None: result = run_command(["osascript", "-e", "alert volume of (get volume settings)"]) if result is None or result.returncode != 0: diff --git a/tests/scanners/test_app_config.py b/tests/scanners/test_app_config.py index 76ad717..65ba6bf 100644 --- a/tests/scanners/test_app_config.py +++ b/tests/scanners/test_app_config.py @@ -134,21 +134,32 @@ def test_group_containers(self, tmp_path: Path) -> None: assert result.entries[0].app_name == "group.com.example.app" assert result.entries[0].file_type == ConfigFileType.JSON - def test_large_file_skipped(self, tmp_path: Path) -> None: + def test_yaml_extension(self, tmp_path: Path) -> None: app_support = _setup_app_support(tmp_path) - app_dir = app_support / "BigApp" + app_dir = app_support / "YamlApp" app_dir.mkdir() - large_file = app_dir / "huge.json" - large_file.write_text("x") + (app_dir / "config.yaml").write_text("key: value") + (app_dir / "settings.yml").write_text("other: true") - with ( - patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path), - patch("mac2nix.scanners.app_config._MAX_FILE_SIZE", 0), - ): + with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): result = AppConfigScanner().scan() assert isinstance(result, AppConfigResult) - assert result.entries == [] + assert len(result.entries) == 2 + assert all(e.file_type == ConfigFileType.YAML for e in result.entries) + + def test_xml_extension(self, tmp_path: Path) -> None: + app_support = _setup_app_support(tmp_path) + app_dir = app_support / "XmlApp" + app_dir.mkdir() + (app_dir / "config.xml").write_text("") + + with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): + result = AppConfigScanner().scan() + + assert isinstance(result, AppConfigResult) + assert len(result.entries) == 1 + assert result.entries[0].file_type == ConfigFileType.XML def test_returns_app_config_result(self, tmp_path: Path) -> None: with patch("mac2nix.scanners.app_config.Path.home", return_value=tmp_path): diff --git a/tests/scanners/test_audio.py b/tests/scanners/test_audio.py index f6698e5..a8a0694 100644 --- a/tests/scanners/test_audio.py +++ b/tests/scanners/test_audio.py @@ -153,6 +153,42 @@ def side_effect(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProces assert result.output_devices[0].name == "Mystery Device" assert result.input_devices == [] + def test_default_device_uses_explicit_marker(self, cmd_result) -> None: + """Default output should use coreaudio_default_audio_output_device, not first-in-list.""" + audio_json = { + "SPAudioDataType": [ + { + "_name": "GPU", + "_items": [ + { + "_name": "HDMI Output", + "coreaudio_device_uid": "hdmi", + "coreaudio_device_output": "yes", + }, + { + "_name": "Built-in Speakers", + "coreaudio_device_uid": "builtin", + "coreaudio_device_output": "yes", + "coreaudio_default_audio_output_device": "spaudio_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 len(result.output_devices) == 2 + # Default should be the explicitly marked device, not the first one + assert result.default_output == "Built-in Speakers" + def test_returns_audio_config(self) -> None: with patch("mac2nix.scanners.audio.run_command", return_value=None): result = AudioScanner().scan()