$ debdiff *.dsc | filterdiff -p1 -x'po/*.po' -x'subprojects/libglnx/*' >| xdg-desktop-portal_1.20.4+ds-1\~deb13u1.diff

subprojects/libglnx is identical to what's in the Flatpak update proposed
for CVE-2026-34078:
$ diff -ru ../flatpak_trixie/subprojects/libglnx subprojects/libglnx
(no output)

diff -Nru xdg-desktop-portal-1.20.3+ds/debian/changelog xdg-desktop-portal-1.20.4+ds/debian/changelog
--- xdg-desktop-portal-1.20.3+ds/debian/changelog	2025-05-21 11:05:04.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/debian/changelog	2026-04-10 23:16:10.000000000 +0100
@@ -1,3 +1,51 @@
+xdg-desktop-portal (1.20.4+ds-1~deb13u1) trixie-security; urgency=medium
+
+  * d/control, d/gbp.conf: Branch for trixie
+  * Backport versions from unstable to trixie
+    - 1.20.4 fixes a vulnerability in which a malicious or compromised
+      Flatpak app could send any file or directory to the trash, including
+      those outside its sandbox.
+      (GHSA-rqr9-jwwf-wxgj) (Closes: #1132958)
+    - d/p/validate-icon-sound-Print-debug-logs-to-stderr-only.patch:
+      Add forward-compatibility with newer gdk-pixbuf
+  * Revert changes that are not applicable to a stable update
+    - Revert Standards-Version, Priority, Rules-Requires-Root, d/watch*
+      to their Debian 13 content
+
+ -- Simon McVittie <smcv@debian.org>  Fri, 10 Apr 2026 23:16:10 +0100
+
+xdg-desktop-portal (1.20.4+ds-1) unstable; urgency=medium
+
+  * New upstream release
+    - This version fixes a vulnerability in which a malicious or compromised
+      Flatpak app could send any file or directory to the trash, including
+      those outside its sandbox.
+      (GHSA-rqr9-jwwf-wxgj) (Closes: #1132958)
+
+ -- Simon McVittie <smcv@debian.org>  Wed, 08 Apr 2026 11:30:32 +0100
+
+xdg-desktop-portal (1.20.3+ds-3) unstable; urgency=medium
+
+  * d/p/validate-icon-sound-Print-debug-logs-to-stderr-only.patch:
+    Add patch from upstream to fix a test regression with updated
+    gdk-pixbuf (Closes: #1129171)
+  * Remove Lintian override for #1115463, no longer needed
+
+ -- Simon McVittie <smcv@debian.org>  Wed, 11 Mar 2026 12:28:40 +0000
+
+xdg-desktop-portal (1.20.3+ds-2) unstable; urgency=medium
+
+  * d/watch: Convert to v5 format
+    - Add Lintian override for #1125489
+  * d/gbp.conf, d/watch: Only watch for stable (even-numbered) releases
+  * d/watch.devel: Add alternative uscan watch file for development
+    (odd-numbered) releases
+  * d/control: Remove Rules-Requires-Root, unnecessary since Debian 13
+  * d/control: Standards-Version: 4.7.3
+    - Remove Priority: optional, not required since Debian 13
+
+ -- Simon McVittie <smcv@debian.org>  Sat, 24 Jan 2026 14:47:56 +0000
+
 xdg-desktop-portal (1.20.3+ds-1) unstable; urgency=medium
 
   * New upstream stable release
diff -Nru xdg-desktop-portal-1.20.3+ds/debian/control xdg-desktop-portal-1.20.4+ds/debian/control
--- xdg-desktop-portal-1.20.3+ds/debian/control	2025-05-21 11:05:04.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/debian/control	2026-04-10 23:16:10.000000000 +0100
@@ -44,7 +44,7 @@
 Rules-Requires-Root: no
 Standards-Version: 4.7.2
 Homepage: https://flatpak.github.io/xdg-desktop-portal/
-Vcs-Git: https://salsa.debian.org/debian/xdg-desktop-portal.git
+Vcs-Git: https://salsa.debian.org/debian/xdg-desktop-portal.git -b debian/trixie
 Vcs-Browser: https://salsa.debian.org/debian/xdg-desktop-portal
 
 Package: xdg-desktop-portal
diff -Nru xdg-desktop-portal-1.20.3+ds/debian/copyright xdg-desktop-portal-1.20.4+ds/debian/copyright
--- xdg-desktop-portal-1.20.3+ds/debian/copyright	2025-05-21 11:05:04.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/debian/copyright	2026-04-10 23:16:10.000000000 +0100
@@ -19,7 +19,7 @@
  2017 Jan Alexander Steffens
  2021-2022 Matthew Leeds
  2016 Piotr Drag
- 2013-2024 Red Hat, Inc
+ 2013-2026 Red Hat, Inc
  2025 XDG Desktop Portal authors
 License: LGPL-2.1+
 
@@ -60,6 +60,52 @@
  © 2016-2018 Collabora Ltd.
 License: LGPL-2+
 
+Files:
+ subprojects/libglnx/*
+Copyright:
+ 2022 Alexander Richardson
+ 2023 CaiJingLong
+ 2015 Canonical Limited
+ 2021 Casper Dik
+ 2012-2016 Colin Walters
+ 2019-2024 Collabora Ltd.
+ 2014 Dan Winship
+ 2017 Emmanuele Bassi
+ 2017-2022 Endless OS Foundation LLC
+ 1995-1997 Josh MacDonald
+ 2007-2011 Lennart Poettering
+ 1998 Manish Singh
+ 2020 Matt Rose
+ 2006-2007 Matthias Clasen
+ 2020 Niels De Graef
+ 2006 Padraig O'Briain
+ 1995-1997 Peter Mattis
+ 2018 Peter Wu
+ 2022 Ray Strode
+ 2000-2026 Red Hat, Inc.
+ 2019 Sebastian Schwarz
+ 2023 Sebastian Wilhelmi
+ 2022 Simon McVittie
+ 1995-1997 Spencer Kimball
+ 2022 Thomas Haller
+ 1998 Tim Janik
+ 2019 Ting-Wei Lan
+ 2016 Zbigniew Jędrzejewski-Szmek
+ 2019 Руслан Ижбулатов
+License: LGPL-2+ and LGPL-2.1+
+
+Files:
+ subprojects/libglnx/tests/test-libglnx-backports.c
+Copyright:
+ 2021-2024 Collabora Ltd.
+ 2019 Emmanuel Fleury
+ 2018 Endless OS Foundation, LLC
+ 1995-1997 Josh MacDonald
+ 1995-1997 Peter Mattis
+ 2011 Red Hat, Inc.
+ 1995-1997 Spencer Kimball
+License: LGPL-2.1+ and old-glib-tests
+
 License: CC0-1.0
  On Debian systems, the full text of the Creative Commons Zero 1.0 Universal
  public domain grant can be found in '/usr/share/common-licenses/CC0-1.0'.
@@ -119,3 +165,21 @@
 Comment:
  On Debian systems, the full text of the GNU Lesser General Public License
  version 2.1 can be found in the file '/usr/share/common-licenses/LGPL-2.1'.
+
+License: old-glib-tests
+ This work is provided "as is"; redistribution and modification
+ in whole or in part, in any medium, physical or electronic is
+ permitted without restriction.
+ .
+ This work is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ .
+ In no event shall the authors or contributors be liable for any
+ direct, indirect, incidental, special, exemplary, or consequential
+ damages (including, but not limited to, procurement of substitute
+ goods or services; loss of use, data, or profits; or business
+ interruption) however caused and on any theory of liability, whether
+ in contract, strict liability, or tort (including negligence or
+ otherwise) arising in any way out of the use of this software, even
+ if advised of the possibility of such damage.
diff -Nru xdg-desktop-portal-1.20.3+ds/debian/gbp.conf xdg-desktop-portal-1.20.4+ds/debian/gbp.conf
--- xdg-desktop-portal-1.20.3+ds/debian/gbp.conf	2025-05-21 11:05:04.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/debian/gbp.conf	2026-04-10 23:16:10.000000000 +0100
@@ -1,7 +1,7 @@
 [DEFAULT]
 pristine-tar = True
 compression = xz
-debian-branch = debian/latest
-upstream-branch = upstream/latest
+debian-branch = debian/trixie
+upstream-branch = upstream/1.20.x
 patch-numbers = False
 upstream-vcs-tag = %(version)s
diff -Nru xdg-desktop-portal-1.20.3+ds/debian/patches/series xdg-desktop-portal-1.20.4+ds/debian/patches/series
--- xdg-desktop-portal-1.20.3+ds/debian/patches/series	2025-05-21 11:05:04.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/debian/patches/series	2026-04-10 23:16:10.000000000 +0100
@@ -1 +1,2 @@
+validate-icon-sound-Print-debug-logs-to-stderr-only.patch
 debian/doc-Use-system-copy-of-Inter-Variable-font.patch
diff -Nru xdg-desktop-portal-1.20.3+ds/debian/patches/validate-icon-sound-Print-debug-logs-to-stderr-only.patch xdg-desktop-portal-1.20.4+ds/debian/patches/validate-icon-sound-Print-debug-logs-to-stderr-only.patch
--- xdg-desktop-portal-1.20.3+ds/debian/patches/validate-icon-sound-Print-debug-logs-to-stderr-only.patch	1970-01-01 01:00:00.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/debian/patches/validate-icon-sound-Print-debug-logs-to-stderr-only.patch	2026-04-10 23:16:10.000000000 +0100
@@ -0,0 +1,43 @@
+From: Alessandro Astone <alessandro.astone@canonical.com>
+Date: Mon, 9 Feb 2026 20:54:58 +0100
+Subject: validate-{icon,sound}: Print debug logs to stderr only
+
+stdout is used for communicating results to the parent process, so it cannot
+be used to also print debug logs. If G_MESSAGES_DEBUG=all were to be inherited
+from the parent environment, we could be writing random strings to stdout and
+breaking the parsing on the parent side.
+
+Forwarded: https://github.com/flatpak/xdg-desktop-portal/pull/1901
+Applied-upstream: 1.21.0, commit:68755dffa8e5123aad0a47089ded3aa19e1a891e
+Bug-Debian: https://bugs.debian.org/1129171
+---
+ src/validate-icon.c  | 2 ++
+ src/validate-sound.c | 2 ++
+ 2 files changed, 4 insertions(+)
+
+diff --git a/src/validate-icon.c b/src/validate-icon.c
+index 4440543..3ac7a1a 100644
+--- a/src/validate-icon.c
++++ b/src/validate-icon.c
+@@ -342,6 +342,8 @@ main (int argc, char *argv[])
+   g_autoptr(GError) error = NULL;
+   g_autofd int fd_path = -1;
+ 
++  g_log_writer_default_set_use_stderr (TRUE);
++
+   context = g_option_context_new (NULL);
+   g_option_context_add_main_entries (context, entries, NULL);
+   if (!g_option_context_parse (context, &argc, &argv, &error))
+diff --git a/src/validate-sound.c b/src/validate-sound.c
+index db2b2e3..e922e28 100644
+--- a/src/validate-sound.c
++++ b/src/validate-sound.c
+@@ -328,6 +328,8 @@ main (int argc, char *argv[])
+   g_autoptr(GOptionContext) context = NULL;
+   g_autoptr(GError) error = NULL;
+ 
++  g_log_writer_default_set_use_stderr (TRUE);
++
+   context = g_option_context_new (NULL);
+   g_option_context_add_main_entries (context, entries, NULL);
+   if (!g_option_context_parse (context, &argc, &argv, &error))
diff -Nru xdg-desktop-portal-1.20.3+ds/.github/workflows/build-and-test.yml xdg-desktop-portal-1.20.4+ds/.github/workflows/build-and-test.yml
--- xdg-desktop-portal-1.20.3+ds/.github/workflows/build-and-test.yml	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/.github/workflows/build-and-test.yml	2026-04-08 01:14:11.000000000 +0100
@@ -74,7 +74,7 @@
       - name: Create dist tarball
         run: |
           ls -la
-          timeout --signal=KILL -v ${TESTS_TIMEOUT}m meson dist -C _build
+          timeout --signal=KILL -v ${TESTS_TIMEOUT}m meson dist -C _build --include-subprojects
 
       - name: Upload test logs
         uses: actions/upload-artifact@v4
diff -Nru xdg-desktop-portal-1.20.3+ds/.github/workflows/release.yml xdg-desktop-portal-1.20.4+ds/.github/workflows/release.yml
--- xdg-desktop-portal-1.20.3+ds/.github/workflows/release.yml	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/.github/workflows/release.yml	2026-04-08 01:14:11.000000000 +0100
@@ -36,7 +36,7 @@
       - name: Build xdg-desktop-portal
         run: |
           meson setup . _build
-          meson dist -C _build
+          meson dist -C _build --include-subprojects
 
       - name: Extract release information
         run: |
diff -Nru xdg-desktop-portal-1.20.3+ds/.gitignore xdg-desktop-portal-1.20.4+ds/.gitignore
--- xdg-desktop-portal-1.20.3+ds/.gitignore	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/.gitignore	2026-04-08 01:14:11.000000000 +0100
@@ -1 +1,3 @@
 __pycache__
+.wraplock
+subprojects/libglnx
diff -Nru xdg-desktop-portal-1.20.3+ds/meson.build xdg-desktop-portal-1.20.4+ds/meson.build
--- xdg-desktop-portal-1.20.3+ds/meson.build	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/meson.build	2026-04-08 01:14:11.000000000 +0100
@@ -1,7 +1,7 @@
 project(
   'xdg-desktop-portal',
   'c',
-  version: '1.20.3',
+  version: '1.20.4',
   meson_version: '>= 0.60',
   license: 'LGPL-2.0-or-later',
   default_options: [
@@ -106,6 +106,14 @@
   config_h.set(h.get(1), cc.has_header(h.get(0)))
 endforeach
 
+libglnx = subproject(
+  'libglnx',
+  default_options : [
+    'warning_level=1',
+    'tests=false',
+  ],
+)
+
 glib_dep = dependency('glib-2.0', version: '>= 2.72')
 gio_dep = dependency('gio-2.0')
 gio_unix_dep = dependency('gio-unix-2.0')
@@ -122,6 +130,7 @@
 libsystemd_dep = dependency('libsystemd', required: get_option('systemd'))
 gudev_dep = dependency('gudev-1.0', required: get_option('gudev'))
 umockdev_dep = dependency('umockdev-1.0', required: get_option('tests'))
+libglnx_dep = libglnx.get_variable('libglnx_dep')
 
 gst_inspect = find_program('gst-inspect-1.0', required: false)
 if gst_inspect.found()
diff -Nru xdg-desktop-portal-1.20.3+ds/NEWS.md xdg-desktop-portal-1.20.4+ds/NEWS.md
--- xdg-desktop-portal-1.20.3+ds/NEWS.md	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/NEWS.md	2026-04-08 01:14:11.000000000 +0100
@@ -1,3 +1,9 @@
+Changes in 1.20.4
+=================
+Released: 2026-04-08
+
+- Prevent trashing of arbitrary host files (GHSA-rqr9-jwwf-wxgj)
+
 Changes in 1.20.3
 =================
 Released: 2025-05-20
diff -Nru xdg-desktop-portal-1.20.3+ds/po/xdg-desktop-portal.pot xdg-desktop-portal-1.20.4+ds/po/xdg-desktop-portal.pot
--- xdg-desktop-portal-1.20.3+ds/po/xdg-desktop-portal.pot	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/po/xdg-desktop-portal.pot	2026-04-08 01:14:11.000000000 +0100
@@ -8,7 +8,7 @@
 msgstr ""
 "Project-Id-Version: xdg-desktop-portal\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-04-23 10:29-0300\n"
+"POT-Creation-Date: 2025-04-23 15:29+0200\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
diff -Nru xdg-desktop-portal-1.20.3+ds/src/meson.build xdg-desktop-portal-1.20.4+ds/src/meson.build
--- xdg-desktop-portal-1.20.3+ds/src/meson.build	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/src/meson.build	2026-04-08 01:14:11.000000000 +0100
@@ -151,6 +151,7 @@
   gio_dep,
   gio_unix_dep,
   json_glib_dep,
+  libglnx_dep,
 ]
 
 if gudev_dep.found()
diff -Nru xdg-desktop-portal-1.20.3+ds/src/trash.c xdg-desktop-portal-1.20.4+ds/src/trash.c
--- xdg-desktop-portal-1.20.3+ds/src/trash.c	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/src/trash.c	2026-04-08 01:14:11.000000000 +0100
@@ -1,5 +1,5 @@
 /*
- * Copyright © 2018 Red Hat, Inc
+ * Copyright © 2026 Red Hat, Inc
  *
  * SPDX-License-Identifier: LGPL-2.1-or-later
  *
@@ -15,9 +15,6 @@
  *
  * You should have received a copy of the GNU Lesser General Public
  * License along with this library. If not, see <http://www.gnu.org/licenses/>.
- *
- * Authors:
- *       Matthias Clasen <mclasen@redhat.com>
  */
 
 #include "config.h"
@@ -32,7 +29,9 @@
 #include <fcntl.h>
 
 #include <gio/gio.h>
+#include <gio/gunixmounts.h>
 #include <gio/gunixfdlist.h>
+#include <libglnx.h>
 
 #include "trash.h"
 #include "xdp-call.h"
@@ -64,55 +63,704 @@
                          G_IMPLEMENT_INTERFACE (XDP_DBUS_TYPE_TRASH,
                                                 trash_iface_init));
 
-static guint
-trash_file (XdpAppInfo *app_info,
-            const char *sender,
-            int fd)
-{
-  g_autofree char *path = NULL;
-  gboolean writable;
-  g_autoptr(GFile) file = NULL;
+/* Check whether subsequently deleting the original file from the trash
+ * (in the gvfsd-trash process) will succeed. If we think it won’t, return
+ * an error, as the trash spec says trashing should not be allowed.
+ * https://specifications.freedesktop.org/trash-spec/latest/#implementation-notes
+ *
+ * Check ownership to see if we can delete. gvfsd will automatically chmod
+ * a file to allow it to be deleted, so checking the permissions bitfield isn’t
+ * relevant.
+ */
+static gboolean
+check_removing_recursively (int            fd,
+                            gboolean       user_owned,
+                            uid_t          uid,
+                            GError       **error)
+{
+  g_auto(GLnxDirFdIterator) dfd_iter = {0};
+
+  if (!glnx_dirfd_iterator_init_at (fd, ".", FALSE, &dfd_iter, error))
+    return FALSE;
+
+  while (TRUE)
+    {
+      struct dirent *dent;
+
+      if (!glnx_dirfd_iterator_next_dent_ensure_dtype (&dfd_iter,
+                                                       &dent,
+                                                       NULL,
+                                                       error))
+        return FALSE;
+
+      if (dent == NULL)
+        return TRUE;
+
+      if (!user_owned)
+        {
+          g_set_error (error, G_IO_ERROR, G_IO_ERROR_PERMISSION_DENIED,
+                       "Unable to trash child file %s", dent->d_name);
+          return FALSE;
+        }
+
+      if (dent->d_type == DT_DIR)
+        {
+          g_autofd int child = -1;
+          struct glnx_statx stx;
+
+          child = glnx_chase_and_statxat (fd, dent->d_name,
+                                          GLNX_CHASE_NOFOLLOW |
+                                          GLNX_CHASE_MUST_BE_DIRECTORY,
+                                          GLNX_STATX_UID,
+                                          &stx,
+                                          error);
+          if (child < 0)
+            return FALSE;
+
+          if (!check_removing_recursively (child,
+                                           uid == stx.stx_uid,
+                                           uid,
+                                           error))
+            return FALSE;
+        }
+    }
+}
+
+static gboolean
+ignore_trash_mount_fd (XdpAppInfo *app_info,
+                       int         mnt_fd)
+{
+  g_autofree char *mnt_path = NULL;
+  g_autoptr(GUnixMountEntry) mount = NULL;
+  const char *mount_options = NULL;
+  g_autoptr(GError) local_error = NULL;
+
+  /* If we run the tests, the test directory will be on a tmpfs mount or some
+   * other mount that we would ignore in production, but we need to not ignore
+   * it to test it properly. */
+  if (g_getenv ("XDG_DESKTOP_PORTAL_TEST_APP_INFO_KIND") != NULL)
+    return FALSE;
+
+  mnt_path = xdp_app_info_get_path_for_fd (app_info, mnt_fd,
+                                           0, NULL, NULL,
+                                           &local_error);
+  if (!mnt_path)
+    {
+      g_debug ("Ignoring the trash dir, because the mount fd can't be "
+               "converted to a path: %s",
+               local_error->message);
+      return TRUE;
+    }
+
+#if GLIB_CHECK_VERSION(2,84,0)
+  mount = g_unix_mount_entry_at (mnt_path, NULL);
+#else
+  mount = g_unix_mount_at (mnt_path, NULL);
+#endif
+
+  if (!mount)
+    {
+      g_debug ("Ignoring the trash dir, because not mount entry for the mount "
+               "directory could be found");
+      return TRUE;
+    }
+
+#if GLIB_CHECK_VERSION(2,84,0)
+  mount_options = g_unix_mount_entry_get_options (mount);
+#else
+  mount_options = g_unix_mount_get_options (mount);
+#endif
+
+  if (mount_options == NULL)
+    {
+      g_autoptr(GUnixMountPoint) mount_point = NULL;
+
+      mount_point = g_unix_mount_point_at (mnt_path,  NULL);
+      if (mount_point != NULL)
+        mount_options = g_unix_mount_point_get_options (mount_point);
+    }
+
+  if (mount_options != NULL)
+    {
+      if (strstr (mount_options, "x-gvfs-trash") != NULL)
+        return FALSE;
+
+      if (strstr (mount_options, "x-gvfs-notrash") != NULL)
+        return TRUE;
+    }
+
+#if GLIB_CHECK_VERSION(2,84,0)
+  return g_unix_mount_entry_is_system_internal (mount);
+#else
+  return g_unix_mount_is_system_internal (mount);
+#endif
+}
+
+
+static int
+get_child_mkdir_p_0700 (int                 fd,
+                        const char         *path,
+                        struct glnx_statx  *stx,
+                        GError            **error)
+{
+  g_autofd int child = -1;
+
+  /* Would be nice to use glnx_chase only, but we have to mkdir with 0700 perms
+   * and that's not supported right now. */
+
+  if (!glnx_ensure_dir (fd, path, 0700, error))
+    return -1;
+
+  child = glnx_chase_and_statxat (fd, path,
+                                  GLNX_CHASE_NOFOLLOW |
+                                  GLNX_CHASE_MUST_BE_DIRECTORY,
+                                  GLNX_STATX_MODE | GLNX_STATX_UID,
+                                  stx,
+                                  error);
+
+  if ((stx->stx_mode & ~S_IFMT) != 0700)
+    {
+      g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_PERMISSION_DENIED,
+                           "Directory already exists with bad permissions");
+      return -1;
+    }
+
+  return g_steal_fd (&child);
+}
+
+static gboolean
+stat_mnt (int                 fd,
+          struct glnx_statx  *stx,
+          GError            **error)
+{
+  if (!glnx_statx (fd, "",
+                   AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW,
+                   GLNX_STATX_TYPE | GLNX_STATX_INO |
+                   GLNX_STATX_MNT_ID | GLNX_STATX_MNT_ID_UNIQUE,
+                   stx,
+                   error))
+    return FALSE;
+
+  if ((stx->stx_mask & (GLNX_STATX_TYPE | GLNX_STATX_INO)) !=
+        (GLNX_STATX_TYPE | GLNX_STATX_INO) ||
+      (stx->stx_mask & (GLNX_STATX_MNT_ID | GLNX_STATX_MNT_ID_UNIQUE)) == 0)
+    {
+      g_set_error_literal (error, G_IO_ERROR,
+                           g_io_error_from_errno (EXDEV),
+                           g_strerror (EXDEV));
+      return FALSE;
+    }
+
+  return TRUE;
+}
+
+static gboolean
+get_mnt (int        fd,
+         int        parent_fd,
+         int       *mnt_fd_out,
+         uint64_t  *mnt_id_out,
+         GError   **error)
+{
+  struct glnx_statx target_stx;
+  g_autofd int mnt = -1;
+  struct glnx_statx stx = {0};
+  g_autofd int next_mnt = -1;
+  struct glnx_statx next_stx;
+
+  if (!stat_mnt (fd, &target_stx, error))
+    return FALSE;
+
+  next_mnt = glnx_chaseat (parent_fd, ".", GLNX_CHASE_DEFAULT, error);
+  if (next_mnt < 0)
+    return FALSE;
+
+  if (!stat_mnt (next_mnt, &next_stx, error))
+    return FALSE;
+
+  while (TRUE)
+    {
+      /* If the dir up is on a different mount, we found the mount point */
+      if (target_stx.stx_mnt_id != next_stx.stx_mnt_id)
+        break;
+
+      /* If we hit root, we end up at the same ino+stx_mnt_id, and / is our
+       * mount point */
+      if (stx.stx_mask != 0 &&
+          stx.stx_ino == next_stx.stx_ino &&
+          stx.stx_mnt_id == next_stx.stx_mnt_id)
+        break;
+
+      g_clear_fd (&mnt, NULL);
+      mnt = g_steal_fd (&next_mnt);
+      stx = next_stx;
+
+      next_mnt = glnx_chaseat (mnt, "..", GLNX_CHASE_DEFAULT, error);
+      if (next_mnt < 0)
+        return FALSE;
+
+      if (!stat_mnt (next_mnt, &next_stx, error))
+        return FALSE;
+    }
+
+  if (mnt < 0)
+    {
+      g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_INVAL,
+                           "No parent mount point");
+      return FALSE;
+    }
+
+  if (mnt_fd_out)
+    *mnt_fd_out = g_steal_fd (&mnt);
+
+  if (mnt_id_out)
+    *mnt_id_out = stx.stx_mnt_id;
+
+  return TRUE;
+}
+
+static gboolean
+get_trash_dir_home (uint64_t   mnt_id,
+                    int       *trash_dir_out,
+                    GError   **error)
+{
+  g_autofree char *trash_path = NULL;
+  g_autofd int trash = -1;
+  struct glnx_statx stx;
+
+  /* We use paths here because this must not be in control of an attacker anyway */
+  trash_path = g_build_filename (g_get_user_data_dir (), "Trash", NULL);
+  if (g_mkdir_with_parents (trash_path, 0700) < 0)
+    {
+      int errsv = errno;
+      g_autofree char *display_name = NULL;
+
+      g_set_error (error, G_IO_ERROR,
+                   g_io_error_from_errno (errsv),
+                   "Unable to create trash directory %s: %s",
+                   display_name, g_strerror (errsv));
+      return FALSE;
+    }
+
+  trash = glnx_chaseat (AT_FDCWD, trash_path, GLNX_CHASE_DEFAULT, error);
+  if (trash < 0)
+    return FALSE;
+
+  if (!stat_mnt (trash, &stx, error))
+    return FALSE;
+
+  /* The home trash is on the same mount as the target, so we use it! */
+  if (stx.stx_mnt_id != mnt_id)
+    {
+      g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_INVAL,
+                           "Target file is not on the same mount as home");
+      return FALSE;
+    }
+
+  if (trash_dir_out)
+    *trash_dir_out = g_steal_fd (&trash);
+
+  return TRUE;
+}
+
+static gboolean
+get_trash_dir_topdir_sticky (int      mnt_fd,
+                             int     *trash_fd_out,
+                             GError **error)
+{
+  g_autofd int trash = -1;
+  g_autofd int trash_user = -1;
+  struct glnx_statx stx;
+  uid_t uid;
+  g_autofree char *uid_str = NULL;
+
+  trash = glnx_chase_and_statxat (mnt_fd, ".Trash",
+                                  GLNX_CHASE_NOFOLLOW,
+                                  GLNX_STATX_MODE | GLNX_STATX_TYPE,
+                                  &stx, error);
+  if (trash < 0)
+    return FALSE;
+
+  if (!S_ISDIR (stx.stx_mode) || (stx.stx_mode & S_ISVTX) == 0)
+    {
+      g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_PERMISSION_DENIED,
+                           ".Trash is not a directory or is missing the sticky bit");
+      return FALSE;
+    }
+
+  uid = geteuid ();
+  uid_str = g_strdup_printf ("%lu", (unsigned long) uid);
+
+  trash_user = get_child_mkdir_p_0700 (trash, uid_str, &stx, error);
+  if (trash_user < 0)
+    return FALSE;
+
+  if (stx.stx_uid != uid)
+    {
+      g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_PERMISSION_DENIED,
+                           "User dir not owned by the user");
+      return FALSE;
+    }
+
+  if (trash_fd_out)
+    *trash_fd_out = g_steal_fd (&trash_user);
+
+  return TRUE;
+}
+
+static gboolean
+get_trash_dir_topdir_user (int      mnt_fd,
+                           int     *trash_fd_out,
+                           GError **error)
+{
+  g_autofd int trash = -1;
+  g_autofd int trash_user = -1;
+  struct glnx_statx stx;
+
+  uid_t uid;
+  g_autofree char *trash_name = NULL;
+
+  uid = geteuid ();
+  trash_name = g_strdup_printf (".Trash-%lu", (unsigned long) uid);
+
+  trash_user = get_child_mkdir_p_0700 (mnt_fd, trash_name, &stx, error);
+  if (trash_user < 0)
+    return FALSE;
+
+  if (stx.stx_uid != uid)
+    {
+      g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_PERMISSION_DENIED,
+                           "User dir not owned by the user");
+      return FALSE;
+    }
+
+  if (trash_fd_out)
+    *trash_fd_out = g_steal_fd (&trash_user);
+
+  return TRUE;
+}
+
+gboolean
+get_trash_dir (XdpAppInfo  *app_info,
+               int          target_fd,
+               int          parent_fd,
+               int         *trash_fd_out,
+               int         *topdir_fd_out,
+               GError     **error)
+{
+  g_autofd int mnt_fd = -1;
+  uint64_t mnt_id;
+  g_autofd int trash_dir = -1;
   g_autoptr(GError) local_error = NULL;
 
-  path = xdp_app_info_get_path_for_fd (app_info, fd, 0, NULL, &writable, &local_error);
+  if (trash_fd_out)
+    *trash_fd_out = -1;
+  if (topdir_fd_out)
+    *topdir_fd_out = -1;
+
+  if (!get_mnt (target_fd, parent_fd, &mnt_fd, &mnt_id, error))
+    return FALSE;
 
-  if (path == NULL)
+  if (ignore_trash_mount_fd (app_info, mnt_fd))
     {
-      g_debug ("Cannot trash file with invalid fd: %s", local_error->message);
-      return 0;
+      g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_INVAL,
+                           "No suitable trash directory found");
+      return FALSE;
     }
 
-  if (!writable)
+  /* First choice is always the home trash. */
+  if (get_trash_dir_home (mnt_id, trash_fd_out, &local_error))
+    return TRUE;
+
+  g_debug ("Skipping home dir trash: %s", local_error->message);
+  g_clear_error (&local_error);
+
+  if (get_trash_dir_topdir_sticky (mnt_fd, trash_fd_out, &local_error))
     {
-      g_debug ("Cannot trash file \"%s\": not opened for writing", path);
-      return 0;
+      if (topdir_fd_out)
+        *topdir_fd_out = g_steal_fd (&mnt_fd);
+
+      return TRUE;
     }
 
-  file = g_file_new_for_path (path);
-  if (!g_file_trash (file, NULL, &local_error))
+  g_debug ("Skipping sticky topdir trash: %s", local_error->message);
+  g_clear_error (&local_error);
+
+  if (get_trash_dir_topdir_user (mnt_fd, trash_fd_out, &local_error))
+    {
+      if (topdir_fd_out)
+        *topdir_fd_out = g_steal_fd (&mnt_fd);
+
+      return TRUE;
+    }
+
+  g_debug ("Skipping user topdir trash: %s", local_error->message);
+  g_clear_error (&local_error);
+
+  g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_INVAL,
+                       "No suitable trash directory found");
+  return FALSE;
+}
+
+static char *
+get_unique_trash_name (const char *basename,
+                       int         id)
+{
+  const char *dot;
+
+  if (id == 1)
+    return g_strdup (basename);
+
+  dot = strchr (basename, '.');
+  if (dot)
+    return g_strdup_printf ("%.*s.%d%s", (int)(dot - basename), basename, id, dot);
+  else
+    return g_strdup_printf ("%s.%d", basename, id);
+}
+
+static int
+open_parent (int          fd,
+             const char  *path,
+             GError     **error)
+{
+  g_autofree char *parent_path = NULL;
+  g_autofree char *base_name = NULL;
+  g_autofd int parent_fd = -1;
+  g_autofd int verify_fd = -1;
+  struct glnx_statx stx;
+  struct glnx_statx verify_stx;
+
+  parent_path = g_path_get_dirname (path);
+  parent_fd = glnx_chaseat (AT_FDCWD, parent_path,
+                            GLNX_CHASE_NOFOLLOW | GLNX_CHASE_MUST_BE_DIRECTORY,
+                            error);
+  if (parent_fd < 0)
+    return -1;
+
+  base_name = g_path_get_basename (path);
+  verify_fd = glnx_chaseat (parent_fd, base_name,
+                            GLNX_CHASE_NOFOLLOW,
+                            error);
+  if (verify_fd < 0)
+    return -1;
+
+  if (!stat_mnt (fd, &stx, error))
+    return -1;
+
+  if (!stat_mnt (verify_fd, &verify_stx, error))
+    return -1;
+
+  if (stx.stx_ino != verify_stx.stx_ino ||
+      stx.stx_mnt_id != verify_stx.stx_mnt_id)
     {
-      g_debug ("Cannot trash file \"%s\": %s", path, local_error->message);
-      return 0;
+      g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_FAILED,
+                           "Failed getting the parent fd");
+      return -1;
     }
 
-  return 1;
+  return g_steal_fd (&parent_fd);
 }
 
 static gboolean
-handle_trash_file (XdpDbusTrash *object,
+trash_file (int          target_fd,
+            XdpAppInfo  *app_info,
+            GError     **error)
+{
+  g_autofree char *target_path = NULL;
+  g_autofd int parent_fd = -1;
+  g_autofd int trash_fd = -1;
+  g_autofree char *restore_path = NULL;
+  g_autofree char *restore_data = NULL;
+
+  target_path = xdp_app_info_get_path_for_fd (app_info, target_fd, 0, NULL, NULL, error);
+  if (!target_path)
+    return FALSE;
+
+  parent_fd = open_parent (target_fd, target_path, error);
+  if (parent_fd < 0)
+    return FALSE;
+
+  {
+    g_autofd int topdir_fd = -1;
+    g_autofree char *topdir_path = NULL;
+
+    if (!get_trash_dir (app_info, target_fd, parent_fd, &trash_fd, &topdir_fd, error))
+      return FALSE;
+
+    /* Only the homedir doesn't have a topdir */
+    if (topdir_fd >= 0)
+      {
+        const char *path;
+
+        topdir_path = xdp_app_info_get_path_for_fd (app_info, topdir_fd, 0, NULL, NULL, error);
+        if (!topdir_path)
+          return FALSE;
+
+        if (!g_str_has_prefix (target_path, topdir_path))
+          {
+            g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
+                                 "Cannot determine relative path from trash to file");
+            return FALSE;
+          }
+
+        path = target_path + strlen (topdir_path);
+        while (path[0] == '/')
+          path++;
+
+        restore_path = g_strdup (path);
+      }
+    else
+      {
+        restore_path = g_strdup (target_path);
+      }
+  }
+
+  /* We can verify as much as we want here, the problem is going to be
+   * restoring: if restoring follows symlinks, they end up in attacker control
+   * and can override any file of the same user (for example your
+   * `~/.ssh/authorized_keys`).
+   */
+
+  {
+    g_autofree char *restore_path_escaped = NULL;
+    g_autoptr(GDateTime) now = NULL;
+    g_autofree char *delete_time = NULL;
+
+    restore_path_escaped = g_uri_escape_string (restore_path, "/", FALSE);
+
+    now = g_date_time_new_now_local ();
+    if (now != NULL)
+      delete_time = g_date_time_format (now, "%Y-%m-%dT%H:%M:%S");
+    else
+      delete_time = g_strdup ("9999-12-31T23:59:59");
+
+    restore_data = g_strdup_printf ("[Trash Info]\nPath=%s\nDeletionDate=%s\n",
+                                    restore_path_escaped,
+                                    delete_time);
+  }
+
+  {
+    struct glnx_statx stx;
+
+    if (!glnx_statx (target_fd, "",
+                     AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW,
+                     GLNX_STATX_MODE | GLNX_STATX_UID,
+                     &stx,
+                     error))
+      return FALSE;
+
+    if (S_ISDIR (stx.stx_mode))
+      {
+        uid_t uid = geteuid ();
+
+        if (stx.stx_uid == uid &&
+            !check_removing_recursively (target_fd, TRUE, uid, error))
+          return FALSE;
+      }
+  }
+
+  {
+    g_autofd int info_fd = -1;
+    g_autofd int files_fd = -1;
+    g_autofree char *basename = NULL;
+    char *basename_candidate = NULL;
+    g_autofree char *trashname = NULL;
+    g_autofd int info_file_fd = -1;
+    struct glnx_statx stx;
+    size_t i = 0;
+
+    info_fd = get_child_mkdir_p_0700 (trash_fd, "info", &stx, error);
+    if (info_fd < 0)
+      return FALSE;
+
+    files_fd = get_child_mkdir_p_0700 (trash_fd, "files", &stx, error);
+    if (files_fd < 0)
+      return FALSE;
+
+    basename = g_path_get_basename (restore_path);
+    basename_candidate = basename;
+
+    while (TRUE)
+      {
+        g_autofree char *local_trashname = NULL;
+        g_autofree char *infoname = NULL;
+        g_autofd int local_info_file_fd = -1;
+
+        local_trashname = get_unique_trash_name (basename_candidate, i++);
+        infoname = g_strconcat (local_trashname, ".trashinfo", NULL);
+
+        local_info_file_fd = openat (info_fd, infoname,
+                                     O_CREAT | O_EXCL | O_WRONLY |
+                                     O_NOFOLLOW | O_NONBLOCK | O_NOCTTY | O_CLOEXEC,
+                                     0700);
+
+        if (local_info_file_fd >= 0)
+          {
+            if (glnx_loop_write (local_info_file_fd,
+                                 restore_data,
+                                 strlen (restore_data)) < 0)
+              return glnx_throw_errno (error);
+
+            trashname = g_steal_pointer (&local_trashname);
+            info_file_fd = g_steal_fd (&local_info_file_fd);
+            break;
+          }
+
+        if (errno == ENAMETOOLONG)
+          {
+            size_t len = strlen (basename_candidate);
+
+            if (len <= strlen (".trashinfo"))
+              return glnx_throw_errno (error); /* fail with ENAMETOOLONG */
+
+            len -= strlen (".trashinfo");
+            memmove (basename_candidate,
+                     basename_candidate + strlen (".trashinfo"),
+                     len);
+            basename_candidate[len] = '\0';
+            i = 1;
+            continue;
+          }
+
+        if (errno != EEXIST)
+          return glnx_throw_errno (error);
+      }
+
+    g_clear_pointer (&basename, g_free);
+    basename = g_path_get_basename (restore_path);
+
+    /* This is inherently racy. We can do our best and
+     * statx(parent_fd, basename) again and see that the inode is still the
+     * same, but then we still pass in the path. */
+    if (glnx_renameat2_noreplace (parent_fd, basename, files_fd, trashname) < 0)
+      {
+        int errsv = errno;
+
+        unlinkat (info_fd, trashname, 0);
+
+        errno = errsv;
+        return glnx_throw_errno (error);
+      }
+  }
+
+  return TRUE;
+}
+
+static gboolean
+handle_trash_file (XdpDbusTrash          *object,
                    GDBusMethodInvocation *invocation,
-                   GUnixFDList *fd_list,
-                   GVariant *arg_fd)
+                   GUnixFDList           *fd_list,
+                   GVariant              *arg_fd)
 {
   XdpCall *call = xdp_call_from_invocation (invocation);
   int idx;
   g_autofd int fd = -1;
   guint result;
+  g_autoptr(GError) error = NULL;
 
   g_debug ("Handling TrashFile");
 
   g_variant_get (arg_fd, "h", &idx);
-  if (idx >= g_unix_fd_list_get_length (fd_list))
+  if (idx < 0 || idx >= g_unix_fd_list_get_length (fd_list))
     {
       g_dbus_method_invocation_return_error (invocation,
                                              XDG_DESKTOP_PORTAL_ERROR,
@@ -123,9 +771,18 @@
 
   fd = g_unix_fd_list_get (fd_list, idx, NULL);
 
-  result = trash_file (call->app_info, call->sender, fd);
+  if (!trash_file (fd, call->app_info, &error))
+    {
+      g_debug ("Failed trashing file: %s", error->message);
+      result = 0;
+    }
+  else
+    {
+      result = 1;
+    }
 
-  xdp_dbus_trash_complete_trash_file (object, invocation, NULL, result);
+  xdp_dbus_trash_complete_trash_file (object, g_steal_pointer (&invocation),
+                                      NULL, result);
 
   return G_DBUS_METHOD_INVOCATION_HANDLED;
 }
diff -Nru xdg-desktop-portal-1.20.3+ds/src/validate-icon.c xdg-desktop-portal-1.20.4+ds/src/validate-icon.c
--- xdg-desktop-portal-1.20.3+ds/src/validate-icon.c	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/src/validate-icon.c	2026-04-10 23:33:43.000000000 +0100
@@ -342,6 +342,8 @@
   g_autoptr(GError) error = NULL;
   g_autofd int fd_path = -1;
 
+  g_log_writer_default_set_use_stderr (TRUE);
+
   context = g_option_context_new (NULL);
   g_option_context_add_main_entries (context, entries, NULL);
   if (!g_option_context_parse (context, &argc, &argv, &error))
diff -Nru xdg-desktop-portal-1.20.3+ds/src/validate-sound.c xdg-desktop-portal-1.20.4+ds/src/validate-sound.c
--- xdg-desktop-portal-1.20.3+ds/src/validate-sound.c	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/src/validate-sound.c	2026-04-10 23:33:43.000000000 +0100
@@ -328,6 +328,8 @@
   g_autoptr(GOptionContext) context = NULL;
   g_autoptr(GError) error = NULL;
 
+  g_log_writer_default_set_use_stderr (TRUE);
+
   context = g_option_context_new (NULL);
   g_option_context_add_main_entries (context, entries, NULL);
   if (!g_option_context_parse (context, &argc, &argv, &error))
diff -Nru xdg-desktop-portal-1.20.3+ds/subprojects/libglnx.wrap xdg-desktop-portal-1.20.4+ds/subprojects/libglnx.wrap
--- xdg-desktop-portal-1.20.3+ds/subprojects/libglnx.wrap	1970-01-01 01:00:00.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/subprojects/libglnx.wrap	2026-04-08 01:14:11.000000000 +0100
@@ -0,0 +1,4 @@
+[wrap-git]
+url = https://gitlab.gnome.org/GNOME/libglnx.git
+revision = ccea836b799256420788c463a638ded0636b1632
+depth = 1
\ No newline at end of file
diff -Nru xdg-desktop-portal-1.20.3+ds/tests/asan.suppression xdg-desktop-portal-1.20.4+ds/tests/asan.suppression
--- xdg-desktop-portal-1.20.3+ds/tests/asan.suppression	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/tests/asan.suppression	2026-04-08 01:14:11.000000000 +0100
@@ -1,8 +1,20 @@
-# external (GIO?)
+####
+# External
+####
+
+# pipewire (fixed in latest)
+leak:pw_context_load_module
+
+# Gio? (fixed in latest)
 leak:g_dbus_message_new_from_blob
-# Bugs in our code
+
+# libproxy
+leak:px_manager_add_config_plugin
+leak:g_proxy_resolver_get_default
+leak:px_manager_constructed
+
+####
+# Leaks in our code
 # Take a look at them and try to figure out  what's going on!
-leak:permission_db_entry_set_app_permissions
-leak:test_color_delay
-leak:test_color_basic
-leak:test_color_parallel
+####
+# None, yay!
diff -Nru xdg-desktop-portal-1.20.3+ds/tests/conftest.py xdg-desktop-portal-1.20.4+ds/tests/conftest.py
--- xdg-desktop-portal-1.20.3+ds/tests/conftest.py	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/tests/conftest.py	2026-04-08 01:14:11.000000000 +0100
@@ -413,6 +413,11 @@
     env["G_DEBUG"] = "fatal-criticals"
     env["XDG_CURRENT_DESKTOP"] = "test"
 
+    # Workaround for the backport branch. The portal uses this as a signal
+    # to know that it's running in the tests. Specifically the trash portal
+    # needs this to get tested correctly.
+    env["XDG_DESKTOP_PORTAL_TEST_APP_INFO_KIND"] = "foobar"
+
     if app_id:
         env["XDG_DESKTOP_PORTAL_TEST_APP_ID"] = app_id
 
diff -Nru xdg-desktop-portal-1.20.3+ds/tests/test_trash.py xdg-desktop-portal-1.20.4+ds/tests/test_trash.py
--- xdg-desktop-portal-1.20.3+ds/tests/test_trash.py	2025-05-20 18:07:14.000000000 +0100
+++ xdg-desktop-portal-1.20.4+ds/tests/test_trash.py	2026-04-08 01:14:11.000000000 +0100
@@ -4,9 +4,11 @@
 
 import tests as xdp
 
+import pytest
 import os
 import tempfile
 from pathlib import Path
+from gi.repository import GLib
 
 
 class TestTrash:
@@ -25,7 +27,71 @@
 
         fd, name = tempfile.mkstemp(prefix="trash_portal_mock_", dir=Path.home())
         result = trash_intf.TrashFile(fd)
-        if result != 1:
-            os.unlink(name)
+
         assert result == 1
         assert not Path(name).exists()
+
+        info_dir = Path(os.environ["XDG_DATA_HOME"]) / "Trash/info"
+        assert info_dir.exists()
+
+        files_dir = Path(os.environ["XDG_DATA_HOME"]) / "Trash/files"
+        assert files_dir.exists()
+
+        trashed_files = info_dir.iterdir()
+        trashed_file = next(trashed_files)
+
+        keyfile = GLib.KeyFile.new()
+        content = trashed_file.read_text()
+        assert keyfile.load_from_data(
+            content,
+            len(content),
+            GLib.KeyFileFlags.NONE,
+        )
+        assert keyfile.get_string("Trash Info", "Path") == name
+        assert (files_dir / trashed_file.stem).exists()
+
+        with pytest.raises(StopIteration):
+            next(trashed_files)
+
+    def test_trash_folder(self, portals, dbus_con):
+        trash_intf = xdp.get_portal_iface(dbus_con, "Trash")
+
+        folder = Path(os.environ["HOME"]) / "folder-to-trash"
+        file_in_folder = folder / "foo" / "bar" / "file"
+        file_in_folder.parent.mkdir(parents=True)
+        file_in_folder.write_text("foobar")
+
+        fd = os.open(folder, os.O_RDONLY | os.O_CLOEXEC)
+        try:
+            result = trash_intf.TrashFile(fd)
+        finally:
+            os.close(fd)
+
+        assert result == 1
+        assert not Path(folder).exists()
+
+        info_dir = Path(os.environ["XDG_DATA_HOME"]) / "Trash/info"
+        assert info_dir.exists()
+
+        files_dir = Path(os.environ["XDG_DATA_HOME"]) / "Trash/files"
+        assert files_dir.exists()
+
+        trashed_files = info_dir.iterdir()
+        trashed_file = next(trashed_files)
+
+        keyfile = GLib.KeyFile.new()
+        content = trashed_file.read_text()
+        assert keyfile.load_from_data(
+            content,
+            len(content),
+            GLib.KeyFileFlags.NONE,
+        )
+        assert keyfile.get_string("Trash Info", "Path") == folder.as_posix()
+
+        trashed_folder = files_dir / trashed_file.stem
+        assert trashed_folder.exists()
+        trashed_file = trashed_folder / "foo" / "bar" / "file"
+        assert trashed_file.exists()
+
+        with pytest.raises(StopIteration):
+            next(trashed_files)
