diff --git a/rust/helpers.c b/rust/helpers.c
index c697c1c4c9d7..c7fe6917251e 100644
--- a/rust/helpers.c
+++ b/rust/helpers.c
@@ -165,6 +165,12 @@ struct file *rust_helper_get_file(struct file *f)
 EXPORT_SYMBOL_GPL(rust_helper_get_file);
 
 
+struct dentry *rust_helper_dget(struct dentry *dentry)
+{
+	return dget(dentry);
+}
+EXPORT_SYMBOL_GPL(rust_helper_dget);
+
 loff_t rust_helper_i_size_read(const struct inode *inode)
 {
 	return i_size_read(inode);
diff --git a/rust/kernel/error.rs b/rust/kernel/error.rs
index f4fa2847e210..bb13bd4a7fa6 100644
--- a/rust/kernel/error.rs
+++ b/rust/kernel/error.rs
@@ -261,8 +261,6 @@ pub fn to_result(err: core::ffi::c_int) -> Result {
 ///     from_err_ptr(unsafe { bindings::devm_platform_ioremap_resource(pdev.to_ptr(), index) })
 /// }
 /// ```
-// TODO: Remove `dead_code` marker once an in-kernel client is available.
-#[allow(dead_code)]
 pub(crate) fn from_err_ptr<T>(ptr: *mut T) -> Result<*mut T> {
     // CAST: Casting a pointer to `*const core::ffi::c_void` is always valid.
     let const_ptr: *const core::ffi::c_void = ptr.cast();
diff --git a/rust/kernel/fs.rs b/rust/kernel/fs.rs
index 89dcd5537830..4f07da71e1ec 100644
--- a/rust/kernel/fs.rs
+++ b/rust/kernel/fs.rs
@@ -13,6 +13,7 @@
 use macros::{pin_data, pinned_drop};
 use sb::SuperBlock;
 
+pub mod dentry;
 pub mod inode;
 pub mod sb;
 
diff --git a/rust/kernel/fs/dentry.rs b/rust/kernel/fs/dentry.rs
new file mode 100644
index 000000000000..6a36a48cd28b
--- /dev/null
+++ b/rust/kernel/fs/dentry.rs
@@ -0,0 +1,137 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! File system directory entries.
+//!
+//! This module allows Rust code to use dentries.
+//!
+//! C headers: [`include/linux/dcache.h`](srctree/include/linux/dcache.h)
+
+use super::{inode::INode, FileSystem, SuperBlock};
+use crate::bindings;
+use crate::error::{code::*, from_err_ptr, Result};
+use crate::types::{ARef, AlwaysRefCounted, Opaque};
+use core::{marker::PhantomData, mem::ManuallyDrop, ops::Deref, ptr};
+
+/// A directory entry.
+///
+/// Wraps the kernel's `struct dentry`.
+///
+/// # Invariants
+///
+/// Instances of this type are always ref-counted, that is, a call to `dget` ensures that the
+/// allocation remains valid at least until the matching call to `dput`.
+#[repr(transparent)]
+pub struct DEntry<T: FileSystem + ?Sized>(pub(crate) Opaque<bindings::dentry>, PhantomData<T>);
+
+// SAFETY: The type invariants guarantee that `DEntry` is always ref-counted.
+unsafe impl<T: FileSystem + ?Sized> AlwaysRefCounted for DEntry<T> {
+    fn inc_ref(&self) {
+        // SAFETY: The existence of a shared reference means that the refcount is nonzero.
+        unsafe { bindings::dget(self.0.get()) };
+    }
+
+    unsafe fn dec_ref(obj: ptr::NonNull<Self>) {
+        // SAFETY: The safety requirements guarantee that the refcount is nonzero.
+        unsafe { bindings::dput(obj.as_ref().0.get()) }
+    }
+}
+
+impl<T: FileSystem + ?Sized> DEntry<T> {
+    /// Creates a new [`DEntry`] from a raw C pointer.
+    ///
+    /// # Safety
+    ///
+    /// * `ptr` must be valid for at least the lifetime of the returned reference.
+    /// * `ptr` has the correct file system type, or `T` is [`super::UnspecifiedFS`].
+    #[allow(dead_code)]
+    pub(crate) unsafe fn from_raw<'a>(ptr: *mut bindings::dentry) -> &'a Self {
+        // SAFETY: The safety requirements guarantee that the reference is and remains valid.
+        unsafe { &*ptr.cast::<Self>() }
+    }
+
+    /// Returns the superblock of the dentry.
+    pub fn super_block(&self) -> &SuperBlock<T> {
+        // `d_sb` is immutable, so it's safe to read it.
+        unsafe { SuperBlock::from_raw((*self.0.get()).d_sb) }
+    }
+}
+
+/// A dentry that is known to be unhashed.
+pub struct Unhashed<'a, T: FileSystem + ?Sized>(pub(crate) &'a DEntry<T>);
+
+impl<T: FileSystem + ?Sized> Unhashed<'_, T> {
+    /// Splices a disconnected dentry into the tree if one exists.
+    pub fn splice_alias(self, inode: Option<ARef<INode<T>>>) -> Result<Option<ARef<DEntry<T>>>> {
+        let inode_ptr = if let Some(i) = inode {
+            // Reject inode if it belongs to a different superblock.
+            if !ptr::eq(i.super_block(), self.0.super_block()) {
+                return Err(EINVAL);
+            }
+
+            ManuallyDrop::new(i).0.get()
+        } else {
+            ptr::null_mut()
+        };
+
+        // SAFETY: Both inode and dentry are known to be valid.
+        let ptr = from_err_ptr(unsafe { bindings::d_splice_alias(inode_ptr, self.0 .0.get()) })?;
+
+        // SAFETY: The C API guarantees that if a dentry is returned, the refcount has been
+        // incremented.
+        Ok(ptr::NonNull::new(ptr).map(|v| unsafe { ARef::from_raw(v.cast::<DEntry<T>>()) }))
+    }
+
+    /// Returns the name of the dentry.
+    ///
+    /// Being unhashed guarantees that the name won't change.
+    pub fn name(&self) -> &[u8] {
+        // SAFETY: The name is immutable, so it is ok to read it.
+        let name = unsafe { &*ptr::addr_of!((*self.0 .0.get()).d_name) };
+
+        // This ensures that a `u32` is representable in `usize`. If it isn't, we'll get a build
+        // break.
+        const _: usize = 0xffffffff;
+
+        // SAFETY: The union is just allow an easy way to get the `hash` and `len` at once. `len`
+        // is always valid.
+        let len = unsafe { name.__bindgen_anon_1.__bindgen_anon_1.len } as usize;
+
+        // SAFETY: The name is immutable, so it is ok to read it.
+        unsafe { core::slice::from_raw_parts(name.name, len) }
+    }
+}
+
+impl<T: FileSystem + ?Sized> Deref for Unhashed<'_, T> {
+    type Target = DEntry<T>;
+
+    fn deref(&self) -> &Self::Target {
+        self.0
+    }
+}
+
+/// A dentry that is meant to be used as the root of a file system.
+pub struct Root<T: FileSystem + ?Sized>(ARef<DEntry<T>>);
+
+impl<T: FileSystem + ?Sized> Root<T> {
+    /// Creates a root dentry.
+    pub fn try_new(inode: ARef<INode<T>>) -> Result<Root<T>> {
+        // SAFETY: `d_make_root` requires that `inode` be valid and referenced, which is the
+        // case for this call.
+        //
+        // It takes over the inode, even on failure, so we don't need to clean it up.
+        let dentry_ptr = unsafe { bindings::d_make_root(ManuallyDrop::new(inode).0.get()) };
+        let dentry = ptr::NonNull::new(dentry_ptr).ok_or(ENOMEM)?;
+
+        // SAFETY: `dentry` is valid and referenced. It reference ownership is transferred to
+        // `ARef`.
+        Ok(Root(unsafe { ARef::from_raw(dentry.cast::<DEntry<T>>()) }))
+    }
+}
+
+impl<T: FileSystem + ?Sized> Deref for Root<T> {
+    type Target = DEntry<T>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
