diff --git a/dissect/target/plugins/os/unix/trash.py b/dissect/target/plugins/os/unix/trash.py new file mode 100644 index 000000000..e326f7585 --- /dev/null +++ b/dissect/target/plugins/os/unix/trash.py @@ -0,0 +1,132 @@ +from typing import Iterator + +from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.helpers import configutil +from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension +from dissect.target.helpers.fsutil import TargetPath +from dissect.target.helpers.record import create_extended_descriptor +from dissect.target.plugin import Plugin, alias, export +from dissect.target.plugins.general.users import UserDetails +from dissect.target.target import Target + +TrashRecord = create_extended_descriptor([UserRecordDescriptorExtension])( + "linux/filesystem/recyclebin", + [ + ("datetime", "ts"), + ("path", "path"), + ("filesize", "filesize"), + ("path", "deleted_path"), + ("path", "source"), + ], +) + + +class GnomeTrashPlugin(Plugin): + """Linux GNOME Trash plugin.""" + + PATHS = [ + # Default $XDG_DATA_HOME/Trash + ".local/share/Trash", + ] + + def __init__(self, target: Target): + super().__init__(target) + self.trashes = list(self._garbage_collector()) + + def _garbage_collector(self) -> Iterator[tuple[UserDetails, TargetPath]]: + """it aint much, but its honest work""" + for user_details in self.target.user_details.all_with_home(): + for trash_path in self.PATHS: + if (path := user_details.home_path.joinpath(trash_path)).exists(): + yield user_details, path + + def check_compatible(self) -> None: + if not self.trashes: + raise UnsupportedPluginError("No Trash folder(s) found") + + @export(record=TrashRecord) + @alias(name="recyclebin") + def trash(self) -> Iterator[TrashRecord]: + """Yield deleted files from GNOME Trash folders. + + Recovers deleted files and artifacts from ``$HOME/.local/share/Trash``. + Probably also works with other desktop interfaces as long as they follow the Trash specification from FreeDesktop. + + Currently does not parse media trash locations such as ``/media/$Label/.Trash-1000/*``. + + Resources: + - https://specifications.freedesktop.org/trash-spec/latest/ + - https://github.com/GNOME/glib/blob/main/gio/glocalfile.c + - https://specifications.freedesktop.org/basedir-spec/latest/ + + Yields ``TrashRecord``s with the following fields: + + .. code-block:: text + + ts (datetime): timestamp when the file was deleted or for expunged files when it could not be permanently deleted + path (path): path where the file was located before it was deleted + filesize (filesize): size in bytes of the deleted file + deleted_path (path): path to the current location of the deleted file + source (path): path to the .trashinfo file + """ # noqa: E501 + + for user_details, trash in self.trashes: + for trash_info_file in trash.glob("info/*.trashinfo"): + trash_info = configutil.parse(trash_info_file, hint="ini").get("Trash Info", {}) + original_path = self.target.fs.path(trash_info.get("Path", "")) + + # We use the basename of the .trashinfo file and not the Path variable inside the + # ini file. This way we can keep duplicate basenames of trashed files separated correctly. + deleted_path = trash / "files" / trash_info_file.name.replace(".trashinfo", "") + + if deleted_path.exists(): + deleted_files = [deleted_path] + + if deleted_path.is_dir(): + for child in deleted_path.rglob("*"): + deleted_files.append(child) + + for file in deleted_files: + # NOTE: We currently do not 'fix' the original_path of files inside deleted directories. + # This would require guessing where the parent folder starts, which is impossible without + # making assumptions. + yield TrashRecord( + ts=trash_info.get("DeletionDate", 0), + path=original_path, + filesize=file.lstat().st_size if file.is_file() else None, + deleted_path=file, + source=trash_info_file, + _user=user_details.user, + _target=self.target, + ) + + # We cannot determine if the deleted entry is a directory since the path does + # not exist at $TRASH/files, so we work with what we have instead. + else: + self.target.log.warning(f"Expected trashed file(s) at {deleted_path}") + yield TrashRecord( + ts=trash_info.get("DeletionDate", 0), + path=original_path, + filesize=0, + deleted_path=deleted_path, + source=trash_info_file, + _user=user_details.user, + _target=self.target, + ) + + # We also iterate expunged folders, they can contain files that could not be + # deleted when the user pressed the "empty trash" button in the file manager. + # Resources: + # - https://gitlab.gnome.org/GNOME/glib/-/issues/1665 + # - https://bugs.launchpad.net/ubuntu/+source/nautilus/+bug/422012 + for item in (trash / "expunged").rglob("*/*"): + stat = item.lstat() + yield TrashRecord( + ts=stat.st_mtime, # NOTE: This is the timestamp at which the file failed to delete + path=None, # We do not know the original file path + filesize=stat.st_size if item.is_file() else None, + deleted_path=item, + source=trash / "expunged", + _user=user_details.user, + _target=self.target, + ) diff --git a/dissect/target/tools/utils.py b/dissect/target/tools/utils.py index da08cd6d2..eddb8d5c0 100644 --- a/dissect/target/tools/utils.py +++ b/dissect/target/tools/utils.py @@ -90,6 +90,10 @@ def generate_argparse_for_unbound_method( raise ValueError(f"Value `{method}` is not an unbound plugin method") desc = method.__doc__ or docs.get_func_description(method, with_docstrings=True) + + if "\n" in desc: + desc = inspect.cleandoc(desc) + help_formatter = argparse.RawDescriptionHelpFormatter parser = argparse.ArgumentParser(description=desc, formatter_class=help_formatter, conflict_handler="resolve") diff --git a/tests/_data/plugins/os/unix/trash/expunged/123456789/some-dir/some-file.txt b/tests/_data/plugins/os/unix/trash/expunged/123456789/some-dir/some-file.txt new file mode 100644 index 000000000..a35ec94ca --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/expunged/123456789/some-dir/some-file.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a63d1c04873abc14c2aaa73d8bcdd202153e969af00cd88e4d38fbe2932c0baa +size 79 diff --git a/tests/_data/plugins/os/unix/trash/files/another-file.bin b/tests/_data/plugins/os/unix/trash/files/another-file.bin new file mode 100644 index 000000000..f0aa9726e --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/files/another-file.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ac49bc57d843413a3b9984a015297282568fc9c6ddbe6477f16abbbbbe51471 +size 369 diff --git a/tests/_data/plugins/os/unix/trash/files/example.jpg b/tests/_data/plugins/os/unix/trash/files/example.jpg new file mode 100644 index 000000000..ad263d05d --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/files/example.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0806106c60ece2b0fde3ff39f77b51faacd3972c813a418a77d3775384cd97c8 +size 23031 diff --git a/tests/_data/plugins/os/unix/trash/files/file.txt b/tests/_data/plugins/os/unix/trash/files/file.txt new file mode 100644 index 000000000..22254b9a3 --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/files/file.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ed645ef0e1abea1bf1e4e935ff04f9e18d39812387f63cda3415b46240f0405 +size 20 diff --git a/tests/_data/plugins/os/unix/trash/files/file.txt.2 b/tests/_data/plugins/os/unix/trash/files/file.txt.2 new file mode 100644 index 000000000..0aabc743f --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/files/file.txt.2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646 +size 10 diff --git a/tests/_data/plugins/os/unix/trash/files/some-dir/another-dir/another-file.txt b/tests/_data/plugins/os/unix/trash/files/some-dir/another-dir/another-file.txt new file mode 100644 index 000000000..77e232208 --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/files/some-dir/another-dir/another-file.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f2d26bce9ba8ad2097db2283241835934c2b507c7d60d90e693ebb4d2622530 +size 22 diff --git a/tests/_data/plugins/os/unix/trash/files/some-dir/some-file.txt b/tests/_data/plugins/os/unix/trash/files/some-dir/some-file.txt new file mode 100644 index 000000000..ea4e7b602 --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/files/some-dir/some-file.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ede5152379e915aa3aaf2279a60b82ac4e6f23111e8072f367b29a397f8a6024 +size 23 diff --git a/tests/_data/plugins/os/unix/trash/info/another-file.bin.trashinfo b/tests/_data/plugins/os/unix/trash/info/another-file.bin.trashinfo new file mode 100644 index 000000000..92aaf8bd8 --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/info/another-file.bin.trashinfo @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:559bd386869b13f8f376e569013113204fab70f56318f6747e517bba60d23278 +size 103 diff --git a/tests/_data/plugins/os/unix/trash/info/example.jpg.trashinfo b/tests/_data/plugins/os/unix/trash/info/example.jpg.trashinfo new file mode 100644 index 000000000..053c49ae1 --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/info/example.jpg.trashinfo @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:245ec880cab92de5eb315434a921cab48cea2196504eeaf41dfe1163bd7b8f75 +size 83 diff --git a/tests/_data/plugins/os/unix/trash/info/file.txt.2.trashinfo b/tests/_data/plugins/os/unix/trash/info/file.txt.2.trashinfo new file mode 100644 index 000000000..d1fb41420 --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/info/file.txt.2.trashinfo @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78c6153c1acd2674eb2c80210b52c2a54bb3107ecbcb3047cc4f6aee72953a0f +size 79 diff --git a/tests/_data/plugins/os/unix/trash/info/file.txt.trashinfo b/tests/_data/plugins/os/unix/trash/info/file.txt.trashinfo new file mode 100644 index 000000000..8ad8572b2 --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/info/file.txt.trashinfo @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09314dffe9fae3363266902d7b9f75719a79f477f74fd1be21960a0a20129dde +size 81 diff --git a/tests/_data/plugins/os/unix/trash/info/missing-file.txt.trashinfo b/tests/_data/plugins/os/unix/trash/info/missing-file.txt.trashinfo new file mode 100644 index 000000000..9735a798d --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/info/missing-file.txt.trashinfo @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8eab07f3f18a26474bd32316c3581c0c914efa7b7e322340a7c1e1689c2ff65b +size 89 diff --git a/tests/_data/plugins/os/unix/trash/info/some-dir.trashinfo b/tests/_data/plugins/os/unix/trash/info/some-dir.trashinfo new file mode 100644 index 000000000..2fe174757 --- /dev/null +++ b/tests/_data/plugins/os/unix/trash/info/some-dir.trashinfo @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6875290b9aefaa75ccd714e91884be61a64cd3e8ffe1b456130f00cc1ce59c8e +size 81 diff --git a/tests/plugins/os/unix/test_trash.py b/tests/plugins/os/unix/test_trash.py new file mode 100644 index 000000000..7ca2075bd --- /dev/null +++ b/tests/plugins/os/unix/test_trash.py @@ -0,0 +1,78 @@ +from datetime import datetime, timezone +from io import BytesIO + +from dissect.target.filesystem import VirtualFilesystem +from dissect.target.plugins.os.unix._os import UnixPlugin +from dissect.target.plugins.os.unix.trash import GnomeTrashPlugin +from dissect.target.target import Target +from tests._utils import absolute_path + + +def test_gnome_trash(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + """test if GNOME Trash plugin finds all deleted files including recursively deleted folders and expunged items.""" + + fs_unix.map_file_fh("etc/hostname", BytesIO(b"hostname")) + fs_unix.map_dir("home/user/.local/share/Trash", absolute_path("_data/plugins/os/unix/trash")) + target_unix_users.add_plugin(UnixPlugin) + target_unix_users.add_plugin(GnomeTrashPlugin) + + # test if the plugin and its alias were registered + assert target_unix_users.has_function("trash") + assert target_unix_users.has_function("recyclebin") + + results = sorted(list(target_unix_users.trash()), key=lambda r: r.source) + assert len(results) == 11 + + # test if we find a deleted file + assert results[2].ts == datetime(2024, 12, 31, 13, 37, 0, tzinfo=timezone.utc) + assert results[2].path == "/home/user/Documents/some-location/another-file.bin" + assert results[2].filesize == 369 + assert results[2].deleted_path == "/home/user/.local/share/Trash/files/another-file.bin" + assert results[2].source == "/home/user/.local/share/Trash/info/another-file.bin.trashinfo" + assert results[2].username == "user" + assert results[2].hostname == "hostname" + + # test if we still find a file by just the .trashinfo file and no entry in the $Trash/files folder + assert results[6].path == "/home/user/Downloads/missing-file.txt" + assert results[6].filesize == 0 + assert results[6].source == "/home/user/.local/share/Trash/info/missing-file.txt.trashinfo" + + # test if we find a deleted directory + assert results[7].ts == datetime(2024, 12, 31, 1, 2, 3, tzinfo=timezone.utc) + assert results[7].path == "/home/user/Downloads/some-dir" + assert results[7].filesize is None + assert results[7].deleted_path == "/home/user/.local/share/Trash/files/some-dir" + assert results[7].source == "/home/user/.local/share/Trash/info/some-dir.trashinfo" + assert results[7].username == "user" + assert results[7].hostname == "hostname" + + # test if we find files nested inside a deleted directory + deleted_paths = [r.deleted_path for r in results] + assert deleted_paths == [ + "/home/user/.local/share/Trash/expunged/123456789/some-dir", + "/home/user/.local/share/Trash/expunged/123456789/some-dir/some-file.txt", + "/home/user/.local/share/Trash/files/another-file.bin", + "/home/user/.local/share/Trash/files/example.jpg", + "/home/user/.local/share/Trash/files/file.txt.2", + "/home/user/.local/share/Trash/files/file.txt", + "/home/user/.local/share/Trash/files/missing-file.txt", + "/home/user/.local/share/Trash/files/some-dir", + "/home/user/.local/share/Trash/files/some-dir/another-dir", + "/home/user/.local/share/Trash/files/some-dir/some-file.txt", + "/home/user/.local/share/Trash/files/some-dir/another-dir/another-file.txt", + ] + + # test if we find two deleted files that had the same basename + assert results[4].path == "/home/user/Desktop/file.txt" + assert results[4].deleted_path == "/home/user/.local/share/Trash/files/file.txt.2" + assert results[4].filesize == 10 + assert results[5].path == "/home/user/Documents/file.txt" + assert results[5].deleted_path == "/home/user/.local/share/Trash/files/file.txt" + assert results[5].filesize == 20 + + # test if we find expunged files + assert results[0].path is None + assert results[0].deleted_path == "/home/user/.local/share/Trash/expunged/123456789/some-dir" + assert results[1].path is None + assert results[1].deleted_path == "/home/user/.local/share/Trash/expunged/123456789/some-dir/some-file.txt" + assert results[1].filesize == 79