Repository: fox-it/dissect.target
Pull request #1136: Support systemd drop files
Author: login: twiggler
Created: 2025-05-06T14:35:17Z | Merged: 2025-05-19T12:04:56Z

Fixes:
-  `_select_parser` could be invoked with a `Path`, while it expects a `FileSystemEntry`. Additionally, there were multiple code paths in `parse` and `parse_config` to support `TargetPath`, `Path`, and `File` objects. Removed the dependence on `FileSystemEntry` by converting entries to `TargetPath` in the config file system.
- Systemd network parser did not collapse singleton values
- When multiple sections with the same name are encountered, the resulting key is a list of those sections:
Example:
```
key value1
   key2 value2a
key value1
   key2 value2b
   key3 value3b
```
  
 now parses as  `{"key value1": [{"key2": "value2a"}, {"key2": "value2b", "key3": "value3b"}]}`. Previously, this parsed as `{"key value1": {"key2": ["value2a", "value2b"], "key3": "value3b"}}`, where the parallel lists could become misaligned.

Supports:
- Drop files when invoking parse with a systemd file
- Empty value now clears corresponding key when merging systemd files.





Diff:
diff --git a/dissect/target/filesystems/config.py b/dissect/target/filesystems/config.py
index 189dbe58ae..f864b5aedb 100644
--- a/dissect/target/filesystems/config.py
+++ b/dissect/target/filesystems/config.py
@@ -122,7 +122,8 @@ def _convert_entry(self, file_entry: FilesystemEntry, *args, **kwargs) -> Config
         entry = file_entry
         config_parser = None
         try:
-            config_parser = parse(entry, *args, **kwargs)
+            target_path = entry.fs.path(entry.path)
+            config_parser = parse(target_path, *args, **kwargs)
         except ConfigurationParsingError as e:
             # If a parsing error gets created, it should return the `entry`
             log.debug("Error when parsing %s with message '%s'", entry.path, e)
diff --git a/dissect/target/helpers/configutil.py b/dissect/target/helpers/configutil.py
index 5a1bf069d4..641e70d599 100644
--- a/dissect/target/helpers/configutil.py
+++ b/dissect/target/helpers/configutil.py
@@ -9,8 +9,6 @@
 from collections.abc import ItemsView, Iterable, Iterator, KeysView
 from configparser import ConfigParser, MissingSectionHeaderError
 from dataclasses import dataclass
-from fnmatch import fnmatch
-from pathlib import Path
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -22,15 +20,14 @@
 from defusedxml import ElementTree
 
 from dissect.target.exceptions import ConfigurationParsingError, FileNotFoundError
-from dissect.target.helpers.fsutil import TargetPath
+from dissect.target.helpers.utils import to_list
 
 if TYPE_CHECKING:
+    from pathlib import Path
     from types import TracebackType
 
     from typing_extensions import Self
 
-    from dissect.target.filesystem import FilesystemEntry
-
 try:
     from ruamel.yaml import YAML
 
@@ -54,9 +51,8 @@
 
 
 def _update_dictionary(current: dict[str, Any], key: str, value: Any) -> None:
-    if prev_value := current.get(key):
-        if isinstance(prev_value, dict):
-            # We can assume the value would be a dict too here.
+    if (prev_value := current.get(key)) is not None:  #  "" is a value
+        if isinstance(prev_value, dict) and isinstance(value, dict):
             prev_value.update(value)
             return
 
@@ -180,6 +176,44 @@ def read_file(self, fh: TextIO | io.BytesIO) -> None:
         if not isinstance(self.parsed_data, dict):
             self.parsed_data = self._collapse_dict(self.parsed_data, False)
 
+    def merge(self, other: ConfigurationParser) -> ConfigurationParser:
+        """Merge the contents of another parser into this one.
+        On conflict, the values of the other parser will be used.
+
+        Args:
+            other: The other parser to merge.
+
+        Returns:
+            The merged parser.
+        """
+
+        self._merge(self.parsed_data, other.parsed_data)
+        return self
+
+    def _merge(self, dict1: dict, dict2: dict) -> dict:
+        for key, value2 in dict2.items():
+            value1 = dict1.get(key)
+            if value1 is None:
+                # Result does not have key yet, add it
+                dict1[key] = value2
+                continue
+
+            collapse = self.collapse_all or self._collapse_check(key)
+
+            if collapse:
+                if isinstance(value1, dict) and isinstance(value2, dict):
+                    self._merge(value1, value2)  # There should only be one, merge both
+                else:
+                    dict1[key] = value2
+            else:
+                combined = to_list(value1) + to_list(value2)
+                # An empty string clears the list of values for the current key
+                # Possibly turn this into an option if other parsers require different behavior
+                combined = combined[combined.index("") + 1 :] if "" in combined else combined
+                dict1[key] = combined
+
+        return dict1
+
     def keys(self) -> KeysView:
         return self.parsed_data.keys()
 
@@ -568,13 +602,26 @@ def _set_prev(self, keep_prev: bool) -> None:
 
     def push(self, name: str, keep_prev: bool = False) -> Literal[True]:
         """Push a new key to the :attr:`_current` dictionary and return that we did."""
-        child = self._current.get(name, {})
-
+        child = self._current.get(name)
+        new_child = {}
         parent = self._current
-        self._parents[id(child)] = parent
-        parent[name] = child
+        if isinstance(child, dict):
+            # A second scope with the same name, turn the existing one into a list and append a new one
+            parent[name] = [child, new_child]
+            child = parent[name]
+        elif isinstance(child, list):
+            # Multiple scopes with the same name, append a new one to the list
+            child.append(new_child)
+        elif isinstance(child, str):
+            # Child is not a scope but a scalar value, do nothing
+            pass
+        else:
+            # Create a new scope
+            parent[name] = new_child
+
+        self._parents[id(new_child)] = parent
         self._set_prev(keep_prev)
-        self._current = child
+        self._current = new_child
         return True
 
     def pop(self, keep_prev: bool = False) -> bool:
@@ -888,7 +935,7 @@ def create_parser(self, options: ParserOptions | None = None) -> ConfigurationPa
 }
 
 
-def parse(path: FilesystemEntry | TargetPath | Path, hint: str | None = None, *args, **kwargs) -> ConfigurationParser:
+def parse(path: Path, hint: str | None = None, *args, **kwargs) -> ConfigurationParser:
     """Parses the content of an ``path`` or ``entry`` to a dictionary.
 
     Args:
@@ -903,42 +950,55 @@ def parse(path: FilesystemEntry | TargetPath | Path, hint: str | None = None, *a
         FileNotFoundError: If the ``path`` is not a file.
     """
 
-    entry = path
-    if isinstance(path, TargetPath):
-        entry = path.get()
-
-    if not isinstance(entry, Path) and not entry.is_file(follow_symlinks=True):
+    if not path.is_file():
         raise FileNotFoundError(f"Could not parse {path} as a dictionary.")
 
     options = ParserOptions(*args, **kwargs)
 
-    return parse_config(entry, hint, options)
+    return parse_config(path, hint, options)
 
 
 def parse_config(
-    entry: FilesystemEntry | Path,
+    entry: Path,
     hint: str | None = None,
     options: ParserOptions | None = None,
 ) -> ConfigurationParser:
     parser_type = _select_parser(entry, hint)
 
     parser = parser_type.create_parser(options)
-    with entry.open("rb") if isinstance(entry, Path) else entry.open() as fh:
+    with entry.open("rb") as fh:
         open_file = io.TextIOWrapper(fh, encoding="utf-8") if not isinstance(parser, Bin) else io.BytesIO(fh.read())
         parser.read_file(open_file)
 
+    if isinstance(parser, SystemD):
+        return _parse_drop_files(entry, options, parser)
+
     return parser
 
 
-def _select_parser(entry: FilesystemEntry, hint: str | None = None) -> ParserConfig:
+def _parse_drop_files(path: Path, options: ParserOptions, main_parser: ConfigurationParser) -> ConfigurationParser:
+    if not (drop_folder := path.with_name(path.name + ".d")).exists():
+        return main_parser
+
+    for drop_file in sorted(drop_folder.glob("*.conf")):
+        if not drop_file.is_file():
+            continue
+
+        drop_file_parser = ParserConfig(SystemD).create_parser(options)
+        with drop_file.open("r") as fh:
+            drop_file_parser.read_file(fh)
+            main_parser.merge(drop_file_parser)
+
+    return main_parser
+
+
+def _select_parser(path: Path, hint: str | None = None) -> ParserConfig:
     if hint and (parser_type := CONFIG_MAP.get(hint)):
         return parser_type
 
     for match, value in MATCH_MAP.items():
-        if fnmatch(entry.path, f"{match}"):
+        if path.match(match):
             return value
 
-    extension = entry.path.rsplit(".", 1)[-1]
-
-    extention_parser = CONFIG_MAP.get(extension, ParserConfig(Default))
-    return KNOWN_FILES.get(entry.name, extention_parser)
+    extention_parser = CONFIG_MAP.get(path.suffix.lstrip("."), ParserConfig(Default))
+    return KNOWN_FILES.get(path.name, extention_parser)
diff --git a/dissect/target/plugins/os/unix/linux/network.py b/dissect/target/plugins/os/unix/linux/network.py
index 0d9e119fee..62c44e3f36 100644
--- a/dissect/target/plugins/os/unix/linux/network.py
+++ b/dissect/target/plugins/os/unix/linux/network.py
@@ -4,6 +4,7 @@
 from dataclasses import dataclass, field
 from datetime import datetime, timezone
 from ipaddress import ip_address, ip_interface
+from itertools import chain
 from typing import TYPE_CHECKING, Any, Literal, NamedTuple
 
 from dissect.target.helpers import configutil
@@ -199,6 +200,8 @@ class SystemdNetworkConfigParser(LinuxNetworkConfigParser):
     Documentation: https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html
     """
 
+    collapsable_items: tuple[str, ...] = ("Match", "Network", "Link", "MACAddress", "Name", "Type")
+
     config_paths: tuple[str, ...] = (
         "/etc/systemd/network/",
         "/run/systemd/network/",
@@ -245,7 +248,7 @@ def _parse_networks(self, virtual_networks: VlanIdByInterface) -> Iterator[UnixI
         """Parse network configurations from systemd network configuration files."""
         for config_file in self._config_files(self.config_paths, "*.network"):
             try:
-                config = configutil.parse(config_file, hint="systemd")
+                config = configutil.parse(config_file, hint="systemd", collapse=self.collapsable_items)
 
                 match_section: dict[str, str] = config.get("Match", {})
                 network_section: dict[str, str] = config.get("Network", {})
@@ -276,10 +279,9 @@ def _parse_networks(self, virtual_networks: VlanIdByInterface) -> Iterator[UnixI
                     if ids := virtual_networks.get(vlan_name):
                         vlan_ids.update(ids)
 
-                # There are possibly multiple route sections, but they are collapsed into one by the parser.
-                route_section: dict[str, Any] = config.get("Route", {})
-                gateway_values = to_list(route_section.get("Gateway", []))
-                gateways.update(filter(None, map(self._parse_gateway, gateway_values)))
+                route_sections: list[dict[str, Any]] = config.get("Route", [])
+                gateway_values = (to_list(route_section.get("Gateway", [])) for route_section in route_sections)
+                gateways.update(filter(None, map(self._parse_gateway, chain.from_iterable(gateway_values))))
 
                 dhcp_ipv4, dhcp_ipv6 = self._parse_dhcp(network_section.get("DHCP"))
 
diff --git a/tests/_data/plugins/os/unix/linux/systemd.network/20-wired-static.network b/tests/_data/plugins/os/unix/linux/systemd.network/20-wired-static.network
index 3914e18cf5..28db331c1a 100644
--- a/tests/_data/plugins/os/unix/linux/systemd.network/20-wired-static.network
+++ b/tests/_data/plugins/os/unix/linux/systemd.network/20-wired-static.network
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:2644524e0f127e1ce21751b32fa0d166405dd488fe435f4b7eb08faf7e4a8048
-size 132
+oid sha256:775b4bf34861a174c73d6dd90c156805305d9d22df53d2473b8fa1923d11f008
+size 166
diff --git a/tests/_data/plugins/os/unix/linux/systemd.network/30-wired-static-complex.network.d/10-override.conf b/tests/_data/plugins/os/unix/linux/systemd.network/30-wired-static-complex.network.d/10-override.conf
new file mode 100644
index 0000000000..5f007bd0d0
--- /dev/null
+++ b/tests/_data/plugins/os/unix/linux/systemd.network/30-wired-static-complex.network.d/10-override.conf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c1adf450a1fb41386c8f880d17c292dbf62b12eebb8468f955171c5241d0d2b4
+size 94
diff --git a/tests/_data/plugins/os/unix/linux/systemd.network/30-wired-static-complex.network.d/20-override.conf b/tests/_data/plugins/os/unix/linux/systemd.network/30-wired-static-complex.network.d/20-override.conf
new file mode 100644
index 0000000000..bab94067f3
--- /dev/null
+++ b/tests/_data/plugins/os/unix/linux/systemd.network/30-wired-static-complex.network.d/20-override.conf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:37ea95ee96de6585b2e6226a2ab63e57d458ba9d34919e50bef1755a58d46bc2
+size 72
diff --git a/tests/filesystems/test_config.py b/tests/filesystems/test_config.py
index 18677a2440..44af1094fc 100644
--- a/tests/filesystems/test_config.py
+++ b/tests/filesystems/test_config.py
@@ -1,9 +1,7 @@
 from __future__ import annotations
 
-from io import BytesIO
 from pathlib import Path
 from typing import TYPE_CHECKING, Any
-from unittest.mock import Mock
 
 import pytest
 
@@ -11,7 +9,6 @@
     ConfigurationEntry,
     ConfigurationFilesystem,
 )
-from dissect.target.helpers.configutil import parse_config
 from tests._utils import absolute_path
 
 if TYPE_CHECKING:
@@ -167,40 +164,6 @@ def test_unix_registry(target_unix: Target, etc_directory: VirtualFilesystem) ->
     assert isinstance(config_fs.get("/new/path/config"), ConfigurationEntry)
 
 
-def test_config_entry() -> None:
-    class MockableRead(Mock):
-        def __enter__(self):
-            return self.binary_data
-
-        def __exit__(self, *args):
-            return
-
-    mocked_open = MockableRead(binary_data=BytesIO(b"default=test\n[Unit]\nhelp=me\n"))
-    mocked_entry = Mock()
-    mocked_entry.open.return_value = mocked_open
-    mocked_entry.path = "config.ini"
-
-    parser_items = parse_config(mocked_entry)
-
-    entry = ConfigurationEntry(
-        Mock(),
-        "config.ini",
-        entry=mocked_entry,
-        parser_items=parser_items,
-    )
-    assert entry.is_dir()
-
-    assert list(entry.iterdir()) == ["DEFAULT", "Unit"]
-
-    default_section = entry.get("DEFAULT")
-    assert default_section.is_dir()
-    assert list(default_section.iterdir()) == ["default"]
-    assert default_section.open().read() == b"default\n    test\n"
-
-    default_key_values = default_section.get("default")
-    assert default_key_values.open().read() == b"test\n"
-
-
 def test_parse_functions(target_unix: Target, etc_directory: VirtualFilesystem) -> None:
     config_fs = ConfigurationFilesystem(target_unix, "/etc")
     entry: ConfigurationEntry = config_fs.get("/new/path/config", collapse=True, separator=(r"\s",))
diff --git a/tests/helpers/test_configutil.py b/tests/helpers/test_configutil.py
index 8b08f27f24..bd2a9c5ca2 100644
--- a/tests/helpers/test_configutil.py
+++ b/tests/helpers/test_configutil.py
@@ -104,6 +104,17 @@ def test_custom_comments(comment_string: str, comment_prefixes: tuple[str, ...])
             """,
             {"key value1": ["value2", "value3"]},
         ),
+        (
+            """
+            key value1
+              key2 value2a
+              key3 value3a
+            key value1
+              key2 value2b
+              key3 value3b
+            """,
+            {"key value1": [{"key2": "value2a", "key3": "value3a"}, {"key2": "value2b", "key3": "value3b"}]},
+        ),
         (
             """
             key value
diff --git a/tests/plugins/os/unix/linux/test_network.py b/tests/plugins/os/unix/linux/test_network.py
index d219df4a5c..5a187b9fbd 100644
--- a/tests/plugins/os/unix/linux/test_network.py
+++ b/tests/plugins/os/unix/linux/test_network.py
@@ -63,7 +63,6 @@ def test_networkmanager_parser(target_linux: Target, fs_linux: VirtualFilesystem
 
 def test_systemd_network_parser(target_linux: Target, fs_linux: VirtualFilesystem) -> None:
     fixture_dir = "tests/_data/plugins/os/unix/linux/systemd.network/"
-    fs_linux.makedirs("/etc/systemd/network")
 
     fs_linux.map_file(
         "/etc/systemd/network/20-wired-static.network", posixpath.join(fixture_dir, "20-wired-static.network")
@@ -148,6 +147,49 @@ def test_systemd_network_parser(target_linux: Target, fs_linux: VirtualFilesyste
     assert wireless_ipv6.dhcp_ipv6
 
 
+def test_systemd_network_drop(target_linux: Target, fs_linux: VirtualFilesystem) -> None:
+    fixture_dir = "tests/_data/plugins/os/unix/linux/systemd.network/"
+
+    fs_linux.map_file(
+        "/etc/systemd/network/30-wired-static-complex.network",
+        posixpath.join(fixture_dir, "30-wired-static-complex.network"),
+    )
+    fs_linux.map_file(
+        "/etc/systemd/network/30-wired-static-complex.network.d/10-override.conf",
+        posixpath.join(fixture_dir, "30-wired-static-complex.network.d/10-override.conf"),
+    )
+    fs_linux.map_file(
+        "/etc/systemd/network/30-wired-static-complex.network.d/20-override.conf",
+        posixpath.join(fixture_dir, "30-wired-static-complex.network.d/20-override.conf"),
+    )
+
+    systemd_network_config_parser = SystemdNetworkConfigParser(target_linux)
+    interfaces = list(systemd_network_config_parser.interfaces())
+
+    assert len(interfaces) == 1
+    [wired_static_complex] = interfaces
+
+    assert wired_static_complex.name == "wlp2s0"
+    assert wired_static_complex.type == "wifi"
+    assert wired_static_complex.enabled is None
+    assert Counter(wired_static_complex.cidr) == Counter([ip_interface("10.1.10.11/16")])
+    assert Counter(wired_static_complex.gateway) == Counter(
+        [ip_address("10.1.6.3"), ip_address("10.1.10.2"), ip_address("10.1.9.3"), ip_address("10.1.10.4")]
+    )
+    assert Counter(wired_static_complex.dns) == Counter(
+        [ip_address("10.1.10.1"), ip_address("10.1.10.2"), ip_address("1111:2222::3333")]
+    )
+    assert Counter(wired_static_complex.mac) == Counter(
+        ["aa::bb::cc::dd::ee::ff", "ff::ee::dd::cc::bb::aa", "cc::ff::bb::aa::dd", "bb::aa::dd::cc::ff"]
+    )
+    assert wired_static_complex.source == "/etc/systemd/network/30-wired-static-complex.network"
+    assert wired_static_complex.dhcp_ipv4
+    assert not wired_static_complex.dhcp_ipv6
+    assert wired_static_complex.last_connected is None
+    assert wired_static_complex.vlan == []
+    assert wired_static_complex.configurator == "systemd-networkd"
+
+
 def test_linux_network_plugin_interfaces(target_linux: Target) -> None:
     """Assert that the LinuxNetworkPlugin aggregates from all Config Parsers."""
 


Reviews:
user: login: twiggler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T14:35:37Z
user: login: twiggler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T14:38:24Z
user: login: Schamper
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-07T08:53:33Z
user: login: twiggler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T07:46:31Z
user: login: twiggler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T07:53:49Z
user: login: twiggler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T11:30:16Z
user: login: Schamper
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T11:36:32Z

Review comments:
diff_hunk: @@ -122,7 +123,8 @@ def _convert_entry(self, file_entry: FilesystemEntry, *args, **kwargs) -> Config
         entry = file_entry
         config_parser = None
         try:
-            config_parser = parse(entry, *args, **kwargs)
+            target_path = TargetPath(entry.fs, entry.path)
path: dissect/target/filesystems/config.py
user: login: twiggler
body: reviewer: is there is a nicer way to convert a file system entry to a target path?
created_at: 2025-05-06T14:35:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -180,6 +180,43 @@ def read_file(self, fh: TextIO | io.BytesIO) -> None:
         if not isinstance(self.parsed_data, dict):
             self.parsed_data = self._collapse_dict(self.parsed_data, False)
 
+    def merge(self, other: ConfigurationParser) -> ConfigurationParser:
path: dissect/target/helpers/configutil.py
user: login: twiggler
body: Reviewer: could push this down to SystemDParser
created_at: 2025-05-06T14:38:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -122,7 +123,8 @@ def _convert_entry(self, file_entry: FilesystemEntry, *args, **kwargs) -> Config
         entry = file_entry
         config_parser = None
         try:
-            config_parser = parse(entry, *args, **kwargs)
+            target_path = TargetPath(entry.fs, entry.path)
path: dissect/target/filesystems/config.py
user: login: Schamper
body: Perhaps `entry.fs.path(entry.path)`?

I still think mixing the filesystem layer with the config layer is very confusing to begin with, but this is probably at least closer to the intended API.
created_at: 2025-05-07T08:45:06Z
author_association: MEMBER
diff_hunk: @@ -180,6 +180,43 @@ def read_file(self, fh: TextIO | io.BytesIO) -> None:
         if not isinstance(self.parsed_data, dict):
             self.parsed_data = self._collapse_dict(self.parsed_data, False)
 
+    def merge(self, other: ConfigurationParser) -> ConfigurationParser:
path: dissect/target/helpers/configutil.py
user: login: Schamper
body: I think it's fine, could maybe be useful for other parsers in general.
created_at: 2025-05-07T08:46:14Z
author_association: MEMBER
diff_hunk: @@ -903,42 +949,63 @@ def parse(path: FilesystemEntry | TargetPath | Path, hint: str | None = None, *a
         FileNotFoundError: If the ``path`` is not a file.
     """
 
-    entry = path
-    if isinstance(path, TargetPath):
-        entry = path.get()
-
-    if not isinstance(entry, Path) and not entry.is_file(follow_symlinks=True):
+    if not path.is_file():
         raise FileNotFoundError(f"Could not parse {path} as a dictionary.")
 
     options = ParserOptions(*args, **kwargs)
 
-    return parse_config(entry, hint, options)
+    return parse_config(path, hint, options)
 
 
 def parse_config(
-    entry: FilesystemEntry | Path,
+    entry: Path,
     hint: str | None = None,
     options: ParserOptions | None = None,
 ) -> ConfigurationParser:
     parser_type = _select_parser(entry, hint)
 
     parser = parser_type.create_parser(options)
-    with entry.open("rb") if isinstance(entry, Path) else entry.open() as fh:
+    with entry.open("rb") as fh:
         open_file = io.TextIOWrapper(fh, encoding="utf-8") if not isinstance(parser, Bin) else io.BytesIO(fh.read())
         parser.read_file(open_file)
 
+    if isinstance(parser, SystemD) and isinstance(entry, Path):
path: dissect/target/helpers/configutil.py
user: login: Schamper
body: `entry` is now always a `Path` right?
created_at: 2025-05-07T08:48:35Z
author_association: MEMBER
diff_hunk: @@ -903,42 +949,63 @@ def parse(path: FilesystemEntry | TargetPath | Path, hint: str | None = None, *a
         FileNotFoundError: If the ``path`` is not a file.
     """
 
-    entry = path
-    if isinstance(path, TargetPath):
-        entry = path.get()
-
-    if not isinstance(entry, Path) and not entry.is_file(follow_symlinks=True):
+    if not path.is_file():
         raise FileNotFoundError(f"Could not parse {path} as a dictionary.")
 
     options = ParserOptions(*args, **kwargs)
 
-    return parse_config(entry, hint, options)
+    return parse_config(path, hint, options)
 
 
 def parse_config(
-    entry: FilesystemEntry | Path,
+    entry: Path,
     hint: str | None = None,
     options: ParserOptions | None = None,
 ) -> ConfigurationParser:
     parser_type = _select_parser(entry, hint)
 
     parser = parser_type.create_parser(options)
-    with entry.open("rb") if isinstance(entry, Path) else entry.open() as fh:
+    with entry.open("rb") as fh:
         open_file = io.TextIOWrapper(fh, encoding="utf-8") if not isinstance(parser, Bin) else io.BytesIO(fh.read())
         parser.read_file(open_file)
 
+    if isinstance(parser, SystemD) and isinstance(entry, Path):
+        return _parse_drop_files(entry, options, parser)
+
     return parser
 
 
-def _select_parser(entry: FilesystemEntry, hint: str | None = None) -> ParserConfig:
+def _parse_drop_files(
+    pathOrEntry: Path, options: ParserOptions, main_parser: ConfigurationParser
path: dissect/target/helpers/configutil.py
user: login: Schamper
body: ```suggestion
    path: Path, options: ParserOptions, main_parser: ConfigurationParser
```
And change accordingly.
created_at: 2025-05-07T08:48:52Z
author_association: MEMBER
diff_hunk: @@ -903,42 +949,63 @@ def parse(path: FilesystemEntry | TargetPath | Path, hint: str | None = None, *a
         FileNotFoundError: If the ``path`` is not a file.
     """
 
-    entry = path
-    if isinstance(path, TargetPath):
-        entry = path.get()
-
-    if not isinstance(entry, Path) and not entry.is_file(follow_symlinks=True):
+    if not path.is_file():
         raise FileNotFoundError(f"Could not parse {path} as a dictionary.")
 
     options = ParserOptions(*args, **kwargs)
 
-    return parse_config(entry, hint, options)
+    return parse_config(path, hint, options)
 
 
 def parse_config(
-    entry: FilesystemEntry | Path,
+    entry: Path,
     hint: str | None = None,
     options: ParserOptions | None = None,
 ) -> ConfigurationParser:
     parser_type = _select_parser(entry, hint)
 
     parser = parser_type.create_parser(options)
-    with entry.open("rb") if isinstance(entry, Path) else entry.open() as fh:
+    with entry.open("rb") as fh:
         open_file = io.TextIOWrapper(fh, encoding="utf-8") if not isinstance(parser, Bin) else io.BytesIO(fh.read())
         parser.read_file(open_file)
 
+    if isinstance(parser, SystemD) and isinstance(entry, Path):
+        return _parse_drop_files(entry, options, parser)
+
     return parser
 
 
-def _select_parser(entry: FilesystemEntry, hint: str | None = None) -> ParserConfig:
+def _parse_drop_files(
+    pathOrEntry: Path, options: ParserOptions, main_parser: ConfigurationParser
+) -> ConfigurationParser:
+    if not isinstance(pathOrEntry, Path):
+        return main_parser
path: dissect/target/helpers/configutil.py
user: login: Schamper
body: Is this check still necessary?
created_at: 2025-05-07T08:49:25Z
author_association: MEMBER
diff_hunk: @@ -903,42 +949,63 @@ def parse(path: FilesystemEntry | TargetPath | Path, hint: str | None = None, *a
         FileNotFoundError: If the ``path`` is not a file.
     """
 
-    entry = path
-    if isinstance(path, TargetPath):
-        entry = path.get()
-
-    if not isinstance(entry, Path) and not entry.is_file(follow_symlinks=True):
+    if not path.is_file():
         raise FileNotFoundError(f"Could not parse {path} as a dictionary.")
 
     options = ParserOptions(*args, **kwargs)
 
-    return parse_config(entry, hint, options)
+    return parse_config(path, hint, options)
 
 
 def parse_config(
-    entry: FilesystemEntry | Path,
+    entry: Path,
     hint: str | None = None,
     options: ParserOptions | None = None,
 ) -> ConfigurationParser:
     parser_type = _select_parser(entry, hint)
 
     parser = parser_type.create_parser(options)
-    with entry.open("rb") if isinstance(entry, Path) else entry.open() as fh:
+    with entry.open("rb") as fh:
         open_file = io.TextIOWrapper(fh, encoding="utf-8") if not isinstance(parser, Bin) else io.BytesIO(fh.read())
         parser.read_file(open_file)
 
+    if isinstance(parser, SystemD) and isinstance(entry, Path):
+        return _parse_drop_files(entry, options, parser)
+
     return parser
 
 
-def _select_parser(entry: FilesystemEntry, hint: str | None = None) -> ParserConfig:
+def _parse_drop_files(
+    pathOrEntry: Path, options: ParserOptions, main_parser: ConfigurationParser
+) -> ConfigurationParser:
+    if not isinstance(pathOrEntry, Path):
+        return main_parser
+
+    drop_folder = pathOrEntry.with_name(pathOrEntry.name + ".d")
+    if not drop_folder.exists():
path: dissect/target/helpers/configutil.py
user: login: Schamper
body: ```suggestion
    if not (drop_folder := path.with_suffix(".d")).exists():
```
created_at: 2025-05-07T08:50:53Z
author_association: MEMBER
diff_hunk: @@ -903,42 +949,63 @@ def parse(path: FilesystemEntry | TargetPath | Path, hint: str | None = None, *a
         FileNotFoundError: If the ``path`` is not a file.
     """
 
-    entry = path
-    if isinstance(path, TargetPath):
-        entry = path.get()
-
-    if not isinstance(entry, Path) and not entry.is_file(follow_symlinks=True):
+    if not path.is_file():
         raise FileNotFoundError(f"Could not parse {path} as a dictionary.")
 
     options = ParserOptions(*args, **kwargs)
 
-    return parse_config(entry, hint, options)
+    return parse_config(path, hint, options)
 
 
 def parse_config(
-    entry: FilesystemEntry | Path,
+    entry: Path,
     hint: str | None = None,
     options: ParserOptions | None = None,
 ) -> ConfigurationParser:
     parser_type = _select_parser(entry, hint)
 
     parser = parser_type.create_parser(options)
-    with entry.open("rb") if isinstance(entry, Path) else entry.open() as fh:
+    with entry.open("rb") as fh:
         open_file = io.TextIOWrapper(fh, encoding="utf-8") if not isinstance(parser, Bin) else io.BytesIO(fh.read())
         parser.read_file(open_file)
 
+    if isinstance(parser, SystemD) and isinstance(entry, Path):
+        return _parse_drop_files(entry, options, parser)
+
     return parser
 
 
-def _select_parser(entry: FilesystemEntry, hint: str | None = None) -> ParserConfig:
+def _parse_drop_files(
+    pathOrEntry: Path, options: ParserOptions, main_parser: ConfigurationParser
+) -> ConfigurationParser:
+    if not isinstance(pathOrEntry, Path):
+        return main_parser
+
+    drop_folder = pathOrEntry.with_name(pathOrEntry.name + ".d")
+    if not drop_folder.exists():
+        return main_parser
+    drop_files = sorted(drop_folder.glob("*.conf"))
+
+    for drop_file in drop_files:
path: dissect/target/helpers/configutil.py
user: login: Schamper
body: ```suggestion

    for drop_file in sorted(drop_folder.glob("*.conf")):
```
created_at: 2025-05-07T08:51:19Z
author_association: MEMBER
diff_hunk: @@ -903,42 +949,63 @@ def parse(path: FilesystemEntry | TargetPath | Path, hint: str | None = None, *a
         FileNotFoundError: If the ``path`` is not a file.
     """
 
-    entry = path
-    if isinstance(path, TargetPath):
-        entry = path.get()
-
-    if not isinstance(entry, Path) and not entry.is_file(follow_symlinks=True):
+    if not path.is_file():
         raise FileNotFoundError(f"Could not parse {path} as a dictionary.")
 
     options = ParserOptions(*args, **kwargs)
 
-    return parse_config(entry, hint, options)
+    return parse_config(path, hint, options)
 
 
 def parse_config(
-    entry: FilesystemEntry | Path,
+    entry: Path,
     hint: str | None = None,
     options: ParserOptions | None = None,
 ) -> ConfigurationParser:
     parser_type = _select_parser(entry, hint)
 
     parser = parser_type.create_parser(options)
-    with entry.open("rb") if isinstance(entry, Path) else entry.open() as fh:
+    with entry.open("rb") as fh:
         open_file = io.TextIOWrapper(fh, encoding="utf-8") if not isinstance(parser, Bin) else io.BytesIO(fh.read())
         parser.read_file(open_file)
 
+    if isinstance(parser, SystemD) and isinstance(entry, Path):
+        return _parse_drop_files(entry, options, parser)
+
     return parser
 
 
-def _select_parser(entry: FilesystemEntry, hint: str | None = None) -> ParserConfig:
+def _parse_drop_files(
+    pathOrEntry: Path, options: ParserOptions, main_parser: ConfigurationParser
+) -> ConfigurationParser:
+    if not isinstance(pathOrEntry, Path):
+        return main_parser
+
+    drop_folder = pathOrEntry.with_name(pathOrEntry.name + ".d")
+    if not drop_folder.exists():
+        return main_parser
+    drop_files = sorted(drop_folder.glob("*.conf"))
+
+    for drop_file in drop_files:
+        if not drop_file.is_file():
+            continue
+
+        drop_file_parser = ParserConfig(SystemD).create_parser(options)
+        with drop_file.open("rb") as fh:
+            open_drop_file = io.TextIOWrapper(fh, encoding="utf-8")
path: dissect/target/helpers/configutil.py
user: login: Schamper
body: Why not open the `drop_file` immediately in text mode?
created_at: 2025-05-07T08:51:59Z
author_association: MEMBER
diff_hunk: @@ -903,42 +949,63 @@ def parse(path: FilesystemEntry | TargetPath | Path, hint: str | None = None, *a
         FileNotFoundError: If the ``path`` is not a file.
     """
 
-    entry = path
-    if isinstance(path, TargetPath):
-        entry = path.get()
-
-    if not isinstance(entry, Path) and not entry.is_file(follow_symlinks=True):
+    if not path.is_file():
         raise FileNotFoundError(f"Could not parse {path} as a dictionary.")
 
     options = ParserOptions(*args, **kwargs)
 
-    return parse_config(entry, hint, options)
+    return parse_config(path, hint, options)
 
 
 def parse_config(
-    entry: FilesystemEntry | Path,
+    entry: Path,
     hint: str | None = None,
     options: ParserOptions | None = None,
 ) -> ConfigurationParser:
     parser_type = _select_parser(entry, hint)
 
     parser = parser_type.create_parser(options)
-    with entry.open("rb") if isinstance(entry, Path) else entry.open() as fh:
+    with entry.open("rb") as fh:
         open_file = io.TextIOWrapper(fh, encoding="utf-8") if not isinstance(parser, Bin) else io.BytesIO(fh.read())
         parser.read_file(open_file)
 
+    if isinstance(parser, SystemD) and isinstance(entry, Path):
+        return _parse_drop_files(entry, options, parser)
+
     return parser
 
 
-def _select_parser(entry: FilesystemEntry, hint: str | None = None) -> ParserConfig:
+def _parse_drop_files(
+    pathOrEntry: Path, options: ParserOptions, main_parser: ConfigurationParser
+) -> ConfigurationParser:
+    if not isinstance(pathOrEntry, Path):
+        return main_parser
+
+    drop_folder = pathOrEntry.with_name(pathOrEntry.name + ".d")
+    if not drop_folder.exists():
+        return main_parser
+    drop_files = sorted(drop_folder.glob("*.conf"))
+
+    for drop_file in drop_files:
+        if not drop_file.is_file():
+            continue
+
+        drop_file_parser = ParserConfig(SystemD).create_parser(options)
+        with drop_file.open("rb") as fh:
+            open_drop_file = io.TextIOWrapper(fh, encoding="utf-8")
+            drop_file_parser.read_file(open_drop_file)
+            main_parser.merge(drop_file_parser)
+
+    return main_parser
+
+
+def _select_parser(path: Path, hint: str | None = None) -> ParserConfig:
     if hint and (parser_type := CONFIG_MAP.get(hint)):
         return parser_type
 
     for match, value in MATCH_MAP.items():
-        if fnmatch(entry.path, f"{match}"):
+        if path.match(f"{match}"):
path: dissect/target/helpers/configutil.py
user: login: Schamper
body: ```suggestion
        if path.match(match):
```
Useless f-string?
created_at: 2025-05-07T08:52:19Z
author_association: MEMBER
diff_hunk: @@ -122,7 +123,8 @@ def _convert_entry(self, file_entry: FilesystemEntry, *args, **kwargs) -> Config
         entry = file_entry
         config_parser = None
         try:
-            config_parser = parse(entry, *args, **kwargs)
+            target_path = TargetPath(entry.fs, entry.path)
path: dissect/target/filesystems/config.py
user: login: twiggler
body: `entry.fs.path(entry.path)` is indeed a bit nicer.

> I still think mixing the filesystem layer with the config layer is very confusing to begin with, but this is probably at least closer to the intended API.

Yeah at least with this change the parsers are decoupled from the config layer.

I did not want to make more substantial changes without fully understanding why it was written this way, and the principle author was on holiday. I will discuss with him and possibly create a ticket depending on the outcome. 
created_at: 2025-05-14T07:46:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -903,42 +949,63 @@ def parse(path: FilesystemEntry | TargetPath | Path, hint: str | None = None, *a
         FileNotFoundError: If the ``path`` is not a file.
     """
 
-    entry = path
-    if isinstance(path, TargetPath):
-        entry = path.get()
-
-    if not isinstance(entry, Path) and not entry.is_file(follow_symlinks=True):
+    if not path.is_file():
         raise FileNotFoundError(f"Could not parse {path} as a dictionary.")
 
     options = ParserOptions(*args, **kwargs)
 
-    return parse_config(entry, hint, options)
+    return parse_config(path, hint, options)
 
 
 def parse_config(
-    entry: FilesystemEntry | Path,
+    entry: Path,
     hint: str | None = None,
     options: ParserOptions | None = None,
 ) -> ConfigurationParser:
     parser_type = _select_parser(entry, hint)
 
     parser = parser_type.create_parser(options)
-    with entry.open("rb") if isinstance(entry, Path) else entry.open() as fh:
+    with entry.open("rb") as fh:
         open_file = io.TextIOWrapper(fh, encoding="utf-8") if not isinstance(parser, Bin) else io.BytesIO(fh.read())
         parser.read_file(open_file)
 
+    if isinstance(parser, SystemD) and isinstance(entry, Path):
+        return _parse_drop_files(entry, options, parser)
+
     return parser
 
 
-def _select_parser(entry: FilesystemEntry, hint: str | None = None) -> ParserConfig:
+def _parse_drop_files(
+    pathOrEntry: Path, options: ParserOptions, main_parser: ConfigurationParser
+) -> ConfigurationParser:
+    if not isinstance(pathOrEntry, Path):
+        return main_parser
path: dissect/target/helpers/configutil.py
user: login: twiggler
body: No, thanks
created_at: 2025-05-14T07:53:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -903,42 +949,63 @@ def parse(path: FilesystemEntry | TargetPath | Path, hint: str | None = None, *a
         FileNotFoundError: If the ``path`` is not a file.
     """
 
-    entry = path
-    if isinstance(path, TargetPath):
-        entry = path.get()
-
-    if not isinstance(entry, Path) and not entry.is_file(follow_symlinks=True):
+    if not path.is_file():
         raise FileNotFoundError(f"Could not parse {path} as a dictionary.")
 
     options = ParserOptions(*args, **kwargs)
 
-    return parse_config(entry, hint, options)
+    return parse_config(path, hint, options)
 
 
 def parse_config(
-    entry: FilesystemEntry | Path,
+    entry: Path,
     hint: str | None = None,
     options: ParserOptions | None = None,
 ) -> ConfigurationParser:
     parser_type = _select_parser(entry, hint)
 
     parser = parser_type.create_parser(options)
-    with entry.open("rb") if isinstance(entry, Path) else entry.open() as fh:
+    with entry.open("rb") as fh:
         open_file = io.TextIOWrapper(fh, encoding="utf-8") if not isinstance(parser, Bin) else io.BytesIO(fh.read())
         parser.read_file(open_file)
 
+    if isinstance(parser, SystemD) and isinstance(entry, Path):
+        return _parse_drop_files(entry, options, parser)
+
     return parser
 
 
-def _select_parser(entry: FilesystemEntry, hint: str | None = None) -> ParserConfig:
+def _parse_drop_files(
+    pathOrEntry: Path, options: ParserOptions, main_parser: ConfigurationParser
+) -> ConfigurationParser:
+    if not isinstance(pathOrEntry, Path):
+        return main_parser
+
+    drop_folder = pathOrEntry.with_name(pathOrEntry.name + ".d")
+    if not drop_folder.exists():
path: dissect/target/helpers/configutil.py
user: login: twiggler
body: Partially applied, `with_suffix`  removes  `.network` in `x.network.d` 
created_at: 2025-05-14T11:30:16Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-05-06T14:40:04Z
body: ## [Codecov](https://app.codecov.io/gh/fox-it/dissect.target/pull/1136?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fox-it) Report
Attention: Patch coverage is `95.23810%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 77.85%. Comparing base [(`e56c684`)](https://app.codecov.io/gh/fox-it/dissect.target/commit/e56c6846dec161a5ed24f8d68502cd00e9797af1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fox-it) to head [(`50f3a87`)](https://app.codecov.io/gh/fox-it/dissect.target/commit/50f3a87b1da5530085cc5f248e39e6f18021843d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fox-it).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/fox-it/dissect.target/pull/1136?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fox-it) | Patch % | Lines |
|---|---|---|
| [dissect/target/helpers/configutil.py](https://app.codecov.io/gh/fox-it/dissect.target/pull/1136?src=pr&el=tree&filepath=dissect%2Ftarget%2Fhelpers%2Fconfigutil.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fox-it#diff-ZGlzc2VjdC90YXJnZXQvaGVscGVycy9jb25maWd1dGlsLnB5) | 94.54% | [3 Missing :warning: ](https://app.codecov.io/gh/fox-it/dissect.target/pull/1136?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fox-it) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1136      +/-   ##
==========================================
- Coverage   77.91%   77.85%   -0.06%     
==========================================
  Files         359      359              
  Lines       32665    32701      +36     
==========================================
+ Hits        25450    25460      +10     
- Misses       7215     7241      +26     
```

| [Flag](https://app.codecov.io/gh/fox-it/dissect.target/pull/1136/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fox-it) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/fox-it/dissect.target/pull/1136/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fox-it) | `77.85% <95.23%> (-0.06%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fox-it#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/fox-it/dissect.target/pull/1136?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fox-it).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fox-it).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: codspeed-hq[bot]
created_at: 2025-05-19T10:53:13Z
body: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Performance Report](https://codspeed.io/fox-it/dissect.target/branches/systemd-drop)

### Merging #1136 will **not alter performance**

<sub>Comparing <code>systemd-drop</code> (50f3a87) with <code>main</code> (e56c684)</sub>



### Summary

`✅ 3` untouched benchmarks  




author_association: NONE