From 3e1a67e31e4e18d16c28d076c295d0cb41a54650 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 17 Mar 2026 19:07:37 +0200 Subject: [PATCH 1/9] add ini plugin --- dissect/target/plugins/filesystem/ini.py | 79 ++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 dissect/target/plugins/filesystem/ini.py diff --git a/dissect/target/plugins/filesystem/ini.py b/dissect/target/plugins/filesystem/ini.py new file mode 100644 index 0000000000..61d0f2cc8b --- /dev/null +++ b/dissect/target/plugins/filesystem/ini.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError +from dissect.target.helpers import configutil +from dissect.target.helpers.record import TargetRecordDescriptor +from dissect.target.plugin import Plugin, arg, export + +if TYPE_CHECKING: + from collections.abc import Iterator + + +WindowsIniRecord = TargetRecordDescriptor( + "filesystem/windows/ini", + [ + ("datetime", "atime"), + ("datetime", "mtime"), + ("datetime", "ctime"), + ("datetime", "btime"), + ("string", "section"), + ("string", "key"), + ("string", "value"), + ("path", "source"), + ], +) + + +class IniPlugin(Plugin): + """INI file plugin.""" + + def check_compatible(self) -> None: + if not len(self.target.fs.mounts): + raise UnsupportedPluginError("No filesystems found on target") + + def _iter_ini_files(self, path: str) -> Iterator: + target_path = self.target.fs.path(path) + if not target_path.exists(): + self.target.log.error("Provided path %s does not exist on target", target_path) + return + + yield from target_path.rglob("*.ini") + + @export(record=WindowsIniRecord) + @arg("-p", "--path", default="/", help="path to an .ini file or directory in target") + def ini(self, path: str = "/") -> Iterator[WindowsIniRecord]: + sources = self._iter_ini_files(path) + + for source in sources: + try: + config = configutil.parse(source, hint="ini") + stat = source.stat() + except FileNotFoundError as e: + # File may disappear between compatibility check and parse. + self.target.log.warning("File not found: %s", source) + self.target.log.debug("", exc_info=e) + continue + except Exception as e: + self.target.log.warning("Exception generating ini record for %s: %s", source, e) + self.target.log.debug("", exc_info=e) + continue + + for section_name in config: + section = config.get(section_name) + if section is None: + continue + + for key, value in section.items(): + yield WindowsIniRecord( + atime=stat.st_atime, + mtime=stat.st_mtime, + ctime=stat.st_ctime, + btime=stat.st_birthtime, + section=section_name, + key=key, + value="" if value is None else str(value), + source=source, + _target=self.target, + ) From 4aa5b910e0678e96d925c74edde475ac46229732 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 18 Mar 2026 14:47:39 +0200 Subject: [PATCH 2/9] add ini plugin with exceptions and tests --- dissect/target/plugins/filesystem/ini.py | 104 +++++++++++++++--- .../_data/plugins/filesystem/ini/not_ini.txt | 3 + .../_data/plugins/filesystem/ini/shutdown.ini | 3 + .../_data/plugins/filesystem/ini/startup.ini | 3 + tests/_data/plugins/filesystem/ini/utf16.ini | 3 + tests/plugins/filesystem/test_ini.py | 76 +++++++++++++ 6 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 tests/_data/plugins/filesystem/ini/not_ini.txt create mode 100644 tests/_data/plugins/filesystem/ini/shutdown.ini create mode 100644 tests/_data/plugins/filesystem/ini/startup.ini create mode 100644 tests/_data/plugins/filesystem/ini/utf16.ini create mode 100644 tests/plugins/filesystem/test_ini.py diff --git a/dissect/target/plugins/filesystem/ini.py b/dissect/target/plugins/filesystem/ini.py index 61d0f2cc8b..208dc055fe 100644 --- a/dissect/target/plugins/filesystem/ini.py +++ b/dissect/target/plugins/filesystem/ini.py @@ -1,8 +1,9 @@ from __future__ import annotations +import io from typing import TYPE_CHECKING -from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError +from dissect.target.exceptions import ConfigurationParsingError, FileNotFoundError, UnsupportedPluginError from dissect.target.helpers import configutil from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import Plugin, arg, export @@ -10,14 +11,14 @@ if TYPE_CHECKING: from collections.abc import Iterator + from dissect.target.helpers.fsutil import TargetPath -WindowsIniRecord = TargetRecordDescriptor( - "filesystem/windows/ini", +IniRecord = TargetRecordDescriptor( + "filesystem/ini", [ ("datetime", "atime"), ("datetime", "mtime"), ("datetime", "ctime"), - ("datetime", "btime"), ("string", "section"), ("string", "key"), ("string", "value"), @@ -27,28 +28,71 @@ class IniPlugin(Plugin): - """INI file plugin.""" + """INI file plugin. + + This plugin scans target filesystems for INI configuration files and parses them into + structured records. It handles both UTF-8 and UTF-16 encoded INI files + """ def check_compatible(self) -> None: if not len(self.target.fs.mounts): raise UnsupportedPluginError("No filesystems found on target") def _iter_ini_files(self, path: str) -> Iterator: + """Find all INI files under the given path. + + Handles both explicit file paths and directory traversal. Continues traversal even if + permission errors are encountered on individual directories. + + Args: + path: Target filesystem path to scan. Can be a file or directory. + + Returns: + TargetPath objects for each discovered .ini file. + """ target_path = self.target.fs.path(path) if not target_path.exists(): self.target.log.error("Provided path %s does not exist on target", target_path) return - yield from target_path.rglob("*.ini") + if target_path.is_file(): + if target_path.suffix.lower() == ".ini": + yield target_path + return - @export(record=WindowsIniRecord) + def on_error(error: Exception) -> None: + if isinstance(error, PermissionError): + self.target.log.warning("Permission denied while scanning for ini files: %s", error) + self.target.log.debug("", exc_info=error) + return + + self.target.log.warning("Exception while scanning for ini files: %s", error) + self.target.log.debug("", exc_info=error) + + for root, _dirs, files in self.target.fs.walk(path, onerror=on_error): + root_path = self.target.fs.path(root) + for file_name in files: + if file_name.lower().endswith(".ini"): + yield root_path.joinpath(file_name) + + @export(record=IniRecord) @arg("-p", "--path", default="/", help="path to an .ini file or directory in target") - def ini(self, path: str = "/") -> Iterator[WindowsIniRecord]: - sources = self._iter_ini_files(path) + def ini(self, path: str = "/") -> Iterator[IniRecord]: + """Scan for and parse INI files, yielding structured records. + + This method recursively discovers INI configuration files under the specified path, + parses them, and yields an IniRecord for each + key-value pair found. - for source in sources: + Args: + path: Target filesystem path to scan (default "/"). Can be a file or directory. + + Returns: + IniRecord: One record per key-value pair in discovered INI files. + """ + for source in self._iter_ini_files(path): try: - config = configutil.parse(source, hint="ini") + config = _parse_ini(source) stat = source.stat() except FileNotFoundError as e: # File may disappear between compatibility check and parse. @@ -60,20 +104,44 @@ def ini(self, path: str = "/") -> Iterator[WindowsIniRecord]: self.target.log.debug("", exc_info=e) continue - for section_name in config: - section = config.get(section_name) - if section is None: - continue - + for section_name, section in config.items(): for key, value in section.items(): - yield WindowsIniRecord( + yield IniRecord( atime=stat.st_atime, mtime=stat.st_mtime, ctime=stat.st_ctime, - btime=stat.st_birthtime, section=section_name, key=key, value="" if value is None else str(value), source=source, _target=self.target, ) + + +def _parse_ini(source: TargetPath) -> configutil.ConfigurationParser: + """Parse an INI file, with automatic fallback for UTF-16 encoded files. + + First attempts to parse the file with the default UTF-8 encoding. If a UnicodeDecodeError + occurs (often wrapped in ConfigurationParsingError), retries using UTF-16 decoding, which + handles Windows INI files with BOM markers. + + Args: + source: TargetPath to the INI file to parse. + + Returns: + ConfigurationParser: Parsed INI configuration object. + """ + try: + return configutil.parse(source, hint="ini") + except (UnicodeDecodeError, ConfigurationParsingError) as e: + # ConfigurationParsingError may wrap a UnicodeDecodeError + if isinstance(e, ConfigurationParsingError) and not isinstance(e.__cause__, UnicodeDecodeError): + raise + + # Many Windows INI files are UTF-16 with BOM; parse those explicitly. + raw_data = source.open("rb").read() + text_data = raw_data.decode("utf-16") + + parser = configutil.Ini() + parser.read_file(io.StringIO(text_data)) + return parser diff --git a/tests/_data/plugins/filesystem/ini/not_ini.txt b/tests/_data/plugins/filesystem/ini/not_ini.txt new file mode 100644 index 0000000000..5e2f0c6ff0 --- /dev/null +++ b/tests/_data/plugins/filesystem/ini/not_ini.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c898b0ed4b7a4f14e2719ef0fbfd36ec88d8f9236b8fc2adb6b8cd7d8f89e4bf +size 24 diff --git a/tests/_data/plugins/filesystem/ini/shutdown.ini b/tests/_data/plugins/filesystem/ini/shutdown.ini new file mode 100644 index 0000000000..f0f725cc4f --- /dev/null +++ b/tests/_data/plugins/filesystem/ini/shutdown.ini @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12cee45f24504bb594261169a710ee217cb4a45b5494362c2bb05eaa8840e5bb +size 34 diff --git a/tests/_data/plugins/filesystem/ini/startup.ini b/tests/_data/plugins/filesystem/ini/startup.ini new file mode 100644 index 0000000000..a1006e9027 --- /dev/null +++ b/tests/_data/plugins/filesystem/ini/startup.ini @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1485f85bd22a2c1867406c360ec56bc7d9471e02bfab364847134a01319d705 +size 61 diff --git a/tests/_data/plugins/filesystem/ini/utf16.ini b/tests/_data/plugins/filesystem/ini/utf16.ini new file mode 100644 index 0000000000..ec049d15f4 --- /dev/null +++ b/tests/_data/plugins/filesystem/ini/utf16.ini @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72e876ed59044113a27c358b2729721d2d0cf6ceddb166a0bcc17d1518d8a2b2 +size 68 diff --git a/tests/plugins/filesystem/test_ini.py b/tests/plugins/filesystem/test_ini.py new file mode 100644 index 0000000000..79f2da251e --- /dev/null +++ b/tests/plugins/filesystem/test_ini.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from dissect.target.plugins.filesystem.ini import IniPlugin +from tests._utils import absolute_path + +if TYPE_CHECKING: + from dissect.target.filesystem import VirtualFilesystem + from dissect.target.target import Target + + +startup_ini_file = absolute_path("_data/plugins/filesystem/ini/startup.ini") +shutdown_ini_file = absolute_path("_data/plugins/filesystem/ini/shutdown.ini") +ignored_text_file = absolute_path("_data/plugins/filesystem/ini/not_ini.txt") +utf16_ini_file = absolute_path("_data/plugins/filesystem/ini/utf16.ini") + + +@pytest.fixture +def target_ini(target_unix: Target, fs_unix: VirtualFilesystem) -> Target: + fs_unix.map_file("/etc/config/startup.ini", startup_ini_file) + fs_unix.map_file("/etc/config/sub/shutdown.ini", shutdown_ini_file) + fs_unix.map_file("/etc/config/sub/not_ini.txt", ignored_text_file) + fs_unix.map_file("/etc/config/sub/utf16.ini", utf16_ini_file) + + target_unix.add_plugin(IniPlugin) + return target_unix + + +def test_ini_parses_records(target_ini: Target) -> None: + """Test INI file discovery and parsing from a directory.""" + records = list(target_ini.ini("/etc/config")) + + assert len(records) == 6 + + by_key = {(record.section, record.key): record for record in records} + + assert by_key[("Run", "Program")].value == "calc.exe" + assert by_key[("Run", "NoValue")].value == "" + assert by_key[("Display", "Theme")].value == "Dark" + assert by_key[("Shutdown", "Script")].value == "cleanup.cmd" + + sources = {str(record.source).lower() for record in records} + assert any(source.endswith("startup.ini") for source in sources) + assert any(source.endswith("shutdown.ini") for source in sources) + assert all(not source.endswith("not_ini.txt") for source in sources) + + +def test_ini_parses_explicit_file(target_ini: Target) -> None: + """Test parsing a single explicitly specified INI file.""" + records = list(target_ini.ini("/etc/config/startup.ini")) + + assert len(records) == 3 + assert {record.section for record in records} == {"Run", "Display"} + assert all(str(record.source).lower().endswith("startup.ini") for record in records) + + +def test_ini_missing_path_logs_error(target_ini: Target, caplog: pytest.LogCaptureFixture) -> None: + """Test that missing paths log an error and return no records.""" + records = list(target_ini.ini("/etc/does-not-exist")) + + assert not records + assert "does not exist on target" in caplog.text + + +def test_ini_parses_utf16_encoded_file(target_ini: Target) -> None: + """Test parsing UTF-16 encoded INI files with BOM markers.""" + records = list(target_ini.ini("/etc/config/sub/utf16.ini")) + + assert len(records) == 2 + by_key = {(record.section, record.key): record for record in records} + assert by_key[("Setting", "Timeout")].value == "30" + assert by_key[("Setting", "Delay")].value == "60" + assert all(str(record.source).lower().endswith("utf16.ini") for record in records) From fff5a26f867b42981a337835374a3c16912d81c0 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 18 Mar 2026 15:04:54 +0200 Subject: [PATCH 3/9] add ini plugin with exceptions and tests --- dissect/target/plugins/filesystem/ini.py | 13 ++++--------- tests/plugins/filesystem/test_ini.py | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/dissect/target/plugins/filesystem/ini.py b/dissect/target/plugins/filesystem/ini.py index 208dc055fe..d1dd20bad6 100644 --- a/dissect/target/plugins/filesystem/ini.py +++ b/dissect/target/plugins/filesystem/ini.py @@ -121,9 +121,8 @@ def ini(self, path: str = "/") -> Iterator[IniRecord]: def _parse_ini(source: TargetPath) -> configutil.ConfigurationParser: """Parse an INI file, with automatic fallback for UTF-16 encoded files. - First attempts to parse the file with the default UTF-8 encoding. If a UnicodeDecodeError - occurs (often wrapped in ConfigurationParsingError), retries using UTF-16 decoding, which - handles Windows INI files with BOM markers. + First attempts to parse the file with the default UTF-8 encoding. If a ConfigurationParsingError, + retries using UTF-16 decoding Args: source: TargetPath to the INI file to parse. @@ -133,12 +132,8 @@ def _parse_ini(source: TargetPath) -> configutil.ConfigurationParser: """ try: return configutil.parse(source, hint="ini") - except (UnicodeDecodeError, ConfigurationParsingError) as e: - # ConfigurationParsingError may wrap a UnicodeDecodeError - if isinstance(e, ConfigurationParsingError) and not isinstance(e.__cause__, UnicodeDecodeError): - raise - - # Many Windows INI files are UTF-16 with BOM; parse those explicitly. + except (UnicodeDecodeError, ConfigurationParsingError): + # Many Windows INI files are UTF-16 raw_data = source.open("rb").read() text_data = raw_data.decode("utf-16") diff --git a/tests/plugins/filesystem/test_ini.py b/tests/plugins/filesystem/test_ini.py index 79f2da251e..73ed45c1e2 100644 --- a/tests/plugins/filesystem/test_ini.py +++ b/tests/plugins/filesystem/test_ini.py @@ -66,7 +66,7 @@ def test_ini_missing_path_logs_error(target_ini: Target, caplog: pytest.LogCaptu def test_ini_parses_utf16_encoded_file(target_ini: Target) -> None: - """Test parsing UTF-16 encoded INI files with BOM markers.""" + """Test parsing UTF-16 encoded INI files.""" records = list(target_ini.ini("/etc/config/sub/utf16.ini")) assert len(records) == 2 From 463336fa2e23cc43777debb1d4764c14625d5159 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 18 Mar 2026 15:12:16 +0200 Subject: [PATCH 4/9] minor fixes --- dissect/target/plugins/filesystem/ini.py | 4 ++-- tests/plugins/filesystem/test_ini.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dissect/target/plugins/filesystem/ini.py b/dissect/target/plugins/filesystem/ini.py index d1dd20bad6..26a9e9cd66 100644 --- a/dissect/target/plugins/filesystem/ini.py +++ b/dissect/target/plugins/filesystem/ini.py @@ -22,7 +22,7 @@ ("string", "section"), ("string", "key"), ("string", "value"), - ("path", "source"), + ("path", "path"), ], ) @@ -113,7 +113,7 @@ def ini(self, path: str = "/") -> Iterator[IniRecord]: section=section_name, key=key, value="" if value is None else str(value), - source=source, + path=source, _target=self.target, ) diff --git a/tests/plugins/filesystem/test_ini.py b/tests/plugins/filesystem/test_ini.py index 73ed45c1e2..461f7e1850 100644 --- a/tests/plugins/filesystem/test_ini.py +++ b/tests/plugins/filesystem/test_ini.py @@ -42,10 +42,10 @@ def test_ini_parses_records(target_ini: Target) -> None: assert by_key[("Display", "Theme")].value == "Dark" assert by_key[("Shutdown", "Script")].value == "cleanup.cmd" - sources = {str(record.source).lower() for record in records} - assert any(source.endswith("startup.ini") for source in sources) - assert any(source.endswith("shutdown.ini") for source in sources) - assert all(not source.endswith("not_ini.txt") for source in sources) + paths = {str(record.path).lower() for record in records} + assert any(path.endswith("startup.ini") for path in paths) + assert any(path.endswith("shutdown.ini") for path in paths) + assert all(not path.endswith("not_ini.txt") for path in paths) def test_ini_parses_explicit_file(target_ini: Target) -> None: @@ -54,7 +54,7 @@ def test_ini_parses_explicit_file(target_ini: Target) -> None: assert len(records) == 3 assert {record.section for record in records} == {"Run", "Display"} - assert all(str(record.source).lower().endswith("startup.ini") for record in records) + assert all(str(record.path).lower().endswith("startup.ini") for record in records) def test_ini_missing_path_logs_error(target_ini: Target, caplog: pytest.LogCaptureFixture) -> None: @@ -73,4 +73,4 @@ def test_ini_parses_utf16_encoded_file(target_ini: Target) -> None: by_key = {(record.section, record.key): record for record in records} assert by_key[("Setting", "Timeout")].value == "30" assert by_key[("Setting", "Delay")].value == "60" - assert all(str(record.source).lower().endswith("utf16.ini") for record in records) + assert all(str(record.path).lower().endswith("utf16.ini") for record in records) From e094f1c46e2554931f9ed9ad39cbb9669652cf2a Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 19 Mar 2026 15:44:35 +0200 Subject: [PATCH 5/9] CR fixes --- dissect/target/plugins/filesystem/ini.py | 62 +++++++++++++----------- tests/plugins/filesystem/test_ini.py | 15 ++---- 2 files changed, 37 insertions(+), 40 deletions(-) diff --git a/dissect/target/plugins/filesystem/ini.py b/dissect/target/plugins/filesystem/ini.py index 26a9e9cd66..605581edc7 100644 --- a/dissect/target/plugins/filesystem/ini.py +++ b/dissect/target/plugins/filesystem/ini.py @@ -14,7 +14,7 @@ from dissect.target.helpers.fsutil import TargetPath IniRecord = TargetRecordDescriptor( - "filesystem/ini", + "filesystem/iniFileRecord", [ ("datetime", "atime"), ("datetime", "mtime"), @@ -38,17 +38,32 @@ def check_compatible(self) -> None: if not len(self.target.fs.mounts): raise UnsupportedPluginError("No filesystems found on target") - def _iter_ini_files(self, path: str) -> Iterator: + def on_error(self, error: Exception) -> None: + """Error handler for filesystem traversal. Logs warnings for permission errors and other exceptions, + but continues traversal. + + Args: + error: ''Exception'' the exception thrown during filesystem traversal. + """ + if isinstance(error, PermissionError): + self.target.log.warning("Permission denied while scanning for ini files: %s", error) + self.target.log.debug("", exc_info=error) + return + + self.target.log.warning("Exception while scanning for ini files: %s", error) + self.target.log.debug("", exc_info=error) + + def _iter_ini_files(self, path: str) -> Iterator[TargetPath]: """Find all INI files under the given path. Handles both explicit file paths and directory traversal. Continues traversal even if permission errors are encountered on individual directories. Args: - path: Target filesystem path to scan. Can be a file or directory. + path: ''string'' of Target filesystem path to scan. Can be a file or directory. Returns: - TargetPath objects for each discovered .ini file. + Iterator yields ``TargetPath`` """ target_path = self.target.fs.path(path) if not target_path.exists(): @@ -60,16 +75,7 @@ def _iter_ini_files(self, path: str) -> Iterator: yield target_path return - def on_error(error: Exception) -> None: - if isinstance(error, PermissionError): - self.target.log.warning("Permission denied while scanning for ini files: %s", error) - self.target.log.debug("", exc_info=error) - return - - self.target.log.warning("Exception while scanning for ini files: %s", error) - self.target.log.debug("", exc_info=error) - - for root, _dirs, files in self.target.fs.walk(path, onerror=on_error): + for root, _dirs, files in self.target.fs.walk(path, onerror=self.on_error): root_path = self.target.fs.path(root) for file_name in files: if file_name.lower().endswith(".ini"): @@ -85,22 +91,22 @@ def ini(self, path: str = "/") -> Iterator[IniRecord]: key-value pair found. Args: - path: Target filesystem path to scan (default "/"). Can be a file or directory. + path: ''string'' of Target filesystem path to scan (default "/"). Can be a file or directory. Returns: - IniRecord: One record per key-value pair in discovered INI files. + Iterator yields ``IniRecord``: One record per key-value pair in discovered INI files. """ - for source in self._iter_ini_files(path): + for ini_file_path in self._iter_ini_files(path): try: - config = _parse_ini(source) - stat = source.stat() + config = _parse_ini(ini_file_path) + stat = ini_file_path.stat() except FileNotFoundError as e: # File may disappear between compatibility check and parse. - self.target.log.warning("File not found: %s", source) + self.target.log.warning("File not found: %s", ini_file_path) self.target.log.debug("", exc_info=e) continue except Exception as e: - self.target.log.warning("Exception generating ini record for %s: %s", source, e) + self.target.log.warning("Exception generating ini record for %s: %s", ini_file_path, e) self.target.log.debug("", exc_info=e) continue @@ -112,29 +118,29 @@ def ini(self, path: str = "/") -> Iterator[IniRecord]: ctime=stat.st_ctime, section=section_name, key=key, - value="" if value is None else str(value), - path=source, + value=str(value), + path=ini_file_path, _target=self.target, ) -def _parse_ini(source: TargetPath) -> configutil.ConfigurationParser: +def _parse_ini(ini_file_path: TargetPath) -> configutil.ConfigurationParser: """Parse an INI file, with automatic fallback for UTF-16 encoded files. First attempts to parse the file with the default UTF-8 encoding. If a ConfigurationParsingError, retries using UTF-16 decoding Args: - source: TargetPath to the INI file to parse. + ini_file_path: ''TargetPath'' to the INI file to parse. Returns: - ConfigurationParser: Parsed INI configuration object. + ConfigurationParser: ''ConfigurationParser'' Parsed INI configuration object. """ try: - return configutil.parse(source, hint="ini") + return configutil.parse(ini_file_path, hint="ini") except (UnicodeDecodeError, ConfigurationParsingError): # Many Windows INI files are UTF-16 - raw_data = source.open("rb").read() + raw_data = ini_file_path.open("rb").read() text_data = raw_data.decode("utf-16") parser = configutil.Ini() diff --git a/tests/plugins/filesystem/test_ini.py b/tests/plugins/filesystem/test_ini.py index 461f7e1850..701b76b25c 100644 --- a/tests/plugins/filesystem/test_ini.py +++ b/tests/plugins/filesystem/test_ini.py @@ -12,18 +12,9 @@ from dissect.target.target import Target -startup_ini_file = absolute_path("_data/plugins/filesystem/ini/startup.ini") -shutdown_ini_file = absolute_path("_data/plugins/filesystem/ini/shutdown.ini") -ignored_text_file = absolute_path("_data/plugins/filesystem/ini/not_ini.txt") -utf16_ini_file = absolute_path("_data/plugins/filesystem/ini/utf16.ini") - - @pytest.fixture def target_ini(target_unix: Target, fs_unix: VirtualFilesystem) -> Target: - fs_unix.map_file("/etc/config/startup.ini", startup_ini_file) - fs_unix.map_file("/etc/config/sub/shutdown.ini", shutdown_ini_file) - fs_unix.map_file("/etc/config/sub/not_ini.txt", ignored_text_file) - fs_unix.map_file("/etc/config/sub/utf16.ini", utf16_ini_file) + fs_unix.map_dir("/etc/config", absolute_path("_data/plugins/filesystem/ini")) target_unix.add_plugin(IniPlugin) return target_unix @@ -38,7 +29,7 @@ def test_ini_parses_records(target_ini: Target) -> None: by_key = {(record.section, record.key): record for record in records} assert by_key[("Run", "Program")].value == "calc.exe" - assert by_key[("Run", "NoValue")].value == "" + assert by_key[("Run", "NoValue")].value == "None" assert by_key[("Display", "Theme")].value == "Dark" assert by_key[("Shutdown", "Script")].value == "cleanup.cmd" @@ -67,7 +58,7 @@ def test_ini_missing_path_logs_error(target_ini: Target, caplog: pytest.LogCaptu def test_ini_parses_utf16_encoded_file(target_ini: Target) -> None: """Test parsing UTF-16 encoded INI files.""" - records = list(target_ini.ini("/etc/config/sub/utf16.ini")) + records = list(target_ini.ini("/etc/config/utf16.ini")) assert len(records) == 2 by_key = {(record.section, record.key): record for record in records} From f00f9d3b73f8ad02a661e7ed1ef343e9f0e4e7c8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 23 Mar 2026 18:42:48 +0200 Subject: [PATCH 6/9] added minor fixes to tests and plugin --- dissect/target/plugins/filesystem/ini.py | 10 +++++----- tests/plugins/filesystem/test_ini.py | 14 +++----------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/dissect/target/plugins/filesystem/ini.py b/dissect/target/plugins/filesystem/ini.py index 605581edc7..b63d652fac 100644 --- a/dissect/target/plugins/filesystem/ini.py +++ b/dissect/target/plugins/filesystem/ini.py @@ -43,7 +43,7 @@ def on_error(self, error: Exception) -> None: but continues traversal. Args: - error: ''Exception'' the exception thrown during filesystem traversal. + error: ``Exception`` the exception thrown during filesystem traversal. """ if isinstance(error, PermissionError): self.target.log.warning("Permission denied while scanning for ini files: %s", error) @@ -60,7 +60,7 @@ def _iter_ini_files(self, path: str) -> Iterator[TargetPath]: permission errors are encountered on individual directories. Args: - path: ''string'' of Target filesystem path to scan. Can be a file or directory. + path: ``string`` of Target filesystem path to scan. Can be a file or directory. Returns: Iterator yields ``TargetPath`` @@ -91,7 +91,7 @@ def ini(self, path: str = "/") -> Iterator[IniRecord]: key-value pair found. Args: - path: ''string'' of Target filesystem path to scan (default "/"). Can be a file or directory. + path: ``string`` of Target filesystem path to scan (default "/"). Can be a file or directory. Returns: Iterator yields ``IniRecord``: One record per key-value pair in discovered INI files. @@ -131,10 +131,10 @@ def _parse_ini(ini_file_path: TargetPath) -> configutil.ConfigurationParser: retries using UTF-16 decoding Args: - ini_file_path: ''TargetPath'' to the INI file to parse. + ini_file_path: ``TargetPath`` to the INI file to parse. Returns: - ConfigurationParser: ''ConfigurationParser'' Parsed INI configuration object. + ConfigurationParser: ``ConfigurationParser`` Parsed INI configuration object. """ try: return configutil.parse(ini_file_path, hint="ini") diff --git a/tests/plugins/filesystem/test_ini.py b/tests/plugins/filesystem/test_ini.py index 701b76b25c..689a72c632 100644 --- a/tests/plugins/filesystem/test_ini.py +++ b/tests/plugins/filesystem/test_ini.py @@ -34,9 +34,9 @@ def test_ini_parses_records(target_ini: Target) -> None: assert by_key[("Shutdown", "Script")].value == "cleanup.cmd" paths = {str(record.path).lower() for record in records} - assert any(path.endswith("startup.ini") for path in paths) - assert any(path.endswith("shutdown.ini") for path in paths) - assert all(not path.endswith("not_ini.txt") for path in paths) + assert "/etc/config/startup.ini" in paths + assert "/etc/config/shutdown.ini" in paths + assert "/etc/config/not_ini.txt" not in paths def test_ini_parses_explicit_file(target_ini: Target) -> None: @@ -48,14 +48,6 @@ def test_ini_parses_explicit_file(target_ini: Target) -> None: assert all(str(record.path).lower().endswith("startup.ini") for record in records) -def test_ini_missing_path_logs_error(target_ini: Target, caplog: pytest.LogCaptureFixture) -> None: - """Test that missing paths log an error and return no records.""" - records = list(target_ini.ini("/etc/does-not-exist")) - - assert not records - assert "does not exist on target" in caplog.text - - def test_ini_parses_utf16_encoded_file(target_ini: Target) -> None: """Test parsing UTF-16 encoded INI files.""" records = list(target_ini.ini("/etc/config/utf16.ini")) From 3559a380451220ab727bff4940e0221d04a7e897 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 18 Mar 2026 14:47:39 +0200 Subject: [PATCH 7/9] add ini plugin with exceptions and tests --- dissect/target/plugins/filesystem/ini.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dissect/target/plugins/filesystem/ini.py b/dissect/target/plugins/filesystem/ini.py index b63d652fac..386907de6d 100644 --- a/dissect/target/plugins/filesystem/ini.py +++ b/dissect/target/plugins/filesystem/ini.py @@ -1,9 +1,8 @@ from __future__ import annotations -import io from typing import TYPE_CHECKING -from dissect.target.exceptions import ConfigurationParsingError, FileNotFoundError, UnsupportedPluginError +from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError from dissect.target.helpers import configutil from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import Plugin, arg, export From 99093d6465257bd6e9aaa6ef7134be4a82ac99a8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 23 Mar 2026 18:52:28 +0200 Subject: [PATCH 8/9] add ini plugin with exceptions and tests # Conflicts: # dissect/target/plugins/filesystem/ini.py # tests/plugins/filesystem/test_ini.py --- dissect/target/plugins/filesystem/ini.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dissect/target/plugins/filesystem/ini.py b/dissect/target/plugins/filesystem/ini.py index 386907de6d..b63d652fac 100644 --- a/dissect/target/plugins/filesystem/ini.py +++ b/dissect/target/plugins/filesystem/ini.py @@ -1,8 +1,9 @@ from __future__ import annotations +import io from typing import TYPE_CHECKING -from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError +from dissect.target.exceptions import ConfigurationParsingError, FileNotFoundError, UnsupportedPluginError from dissect.target.helpers import configutil from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import Plugin, arg, export From fc803efb8bdf2e7a9fe190851e7f3cb76b326ad4 Mon Sep 17 00:00:00 2001 From: Daniel Erbesfeld <102467176+DanielErb@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:05:18 +0200 Subject: [PATCH 9/9] Update dissect/target/plugins/filesystem/ini.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dissect/target/plugins/filesystem/ini.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dissect/target/plugins/filesystem/ini.py b/dissect/target/plugins/filesystem/ini.py index b63d652fac..13023b01b6 100644 --- a/dissect/target/plugins/filesystem/ini.py +++ b/dissect/target/plugins/filesystem/ini.py @@ -140,7 +140,8 @@ def _parse_ini(ini_file_path: TargetPath) -> configutil.ConfigurationParser: return configutil.parse(ini_file_path, hint="ini") except (UnicodeDecodeError, ConfigurationParsingError): # Many Windows INI files are UTF-16 - raw_data = ini_file_path.open("rb").read() + with ini_file_path.open("rb") as f: + raw_data = f.read() text_data = raw_data.decode("utf-16") parser = configutil.Ini()