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 2 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
124 changes: 124 additions & 0 deletions dissect/target/plugins/os/unix/trash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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.

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

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/
"""

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

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.

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

JSCU-CNI marked this conversation as resolved.
Show resolved Hide resolved
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,
)
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
74 changes: 74 additions & 0 deletions tests/plugins/os/unix/test_trash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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
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