Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Unix trash plugin #852

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions dissect/target/plugins/os/unix/trash.py
Original file line number Diff line number Diff line change
@@ -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]:
JSCU-CNI marked this conversation as resolved.
Show resolved Hide resolved
"""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,
)
4 changes: 4 additions & 0 deletions dissect/target/tools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,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 = desc.split("\n")[0] + "\n" + textwrap.dedent("\n".join(desc.split("\n")[1:]))
Copy link
Contributor

@Horofic Horofic Sep 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
desc = desc.split("\n")[0] + "\n" + textwrap.dedent("\n".join(desc.split("\n")[1:]))
desc = inspect.cleandoc(desc)

This should do the same.


help_formatter = argparse.RawDescriptionHelpFormatter
parser = argparse.ArgumentParser(description=desc, formatter_class=help_formatter, conflict_handler="resolve")

Expand Down
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/trash/files/another-file.bin
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/trash/files/example.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/trash/files/file.txt
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/trash/files/file.txt.2
Git LFS file not shown
Git LFS file not shown
Git LFS file not shown
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/trash/info/example.jpg.trashinfo
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/trash/info/file.txt.2.trashinfo
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/trash/info/file.txt.trashinfo
Git LFS file not shown
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/trash/info/some-dir.trashinfo
Git LFS file not shown
78 changes: 78 additions & 0 deletions tests/plugins/os/unix/test_trash.py
Original file line number Diff line number Diff line change
@@ -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)

JSCU-CNI marked this conversation as resolved.
Show resolved Hide resolved
# 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