diff --git a/kernel/driver/fs/ext2/src/dir.rs b/kernel/driver/fs/ext2/src/dir.rs
index a59282ec..dc7aa0da 100644
--- a/kernel/driver/fs/ext2/src/dir.rs
+++ b/kernel/driver/fs/ext2/src/dir.rs
@@ -3,6 +3,7 @@ use core::{
     any::Any,
     mem::{self, MaybeUninit},
     str::FromStr,
+    sync::atomic::{AtomicBool, Ordering},
 };
 
 use alloc::sync::Arc;
@@ -21,6 +22,7 @@ use crate::{data::FsRequiredFeatures, file::RegularNode, inode::InodeAccess, Dir
 
 pub struct DirectoryNode {
     fs: Arc<Ext2Fs>,
+    locked: AtomicBool,
     pub(crate) inode: InodeAccess,
 }
 
@@ -260,6 +262,7 @@ impl DirectoryNode {
         Node::directory(
             Self {
                 fs: fs.clone(),
+                locked: AtomicBool::new(false),
                 inode,
             },
             NodeFlags::empty(),
@@ -276,6 +279,7 @@ impl DirectoryNode {
         let ino = inode.ino();
         let this = Self {
             fs: fs.clone(),
+            locked: AtomicBool::new(false),
             inode,
         };
 
@@ -291,6 +295,11 @@ impl DirectoryNode {
     async fn create_entry(&self, name: &str, ino: u32) -> Result<(), Error> {
         assert!(name.len() < 255);
 
+        // This directory is in the process of being unlinked and cannot be written to
+        if self.locked.load(Ordering::Acquire) {
+            return Err(Error::DoesNotExist);
+        }
+
         {
             let mut holder = self.inode.cache().get_mut(ino).await?;
             {
@@ -376,9 +385,55 @@ impl DirectoryNode {
         Ok(())
     }
 
-    async fn remove_entry(&self, name: &str) -> Result<(), Error> {
-        // TODO if unlinking a directory, check that it only contains . and ..
-        assert!(name.len() < 255);
+    async fn prepare_for_removal(&self) -> Result<(), Error> {
+        // Check that the directory doesn't have any children besides . and .. and set the locked
+        // flag to prevent anyone from adding entries while the directory is being unlinked
+        // TODO find a better way to do this
+        let mut holder = self.inode.get_mut().await?;
+        {
+            let inode = holder.write();
+            let n = inode.blocks(&self.fs);
+
+            for i in 0..n {
+                self.fs
+                    .with_inode_block(&inode, i as _, |block| {
+                        let iter = DirentIter::new(&self.fs, block, 0);
+
+                        for (_, name, _) in iter {
+                            if name != b"." && name != b".." {
+                                return Err(Error::IsADirectory);
+                            }
+                        }
+
+                        Ok(())
+                    })
+                    .await?;
+            }
+
+            self.locked.store(true, Ordering::Release);
+        }
+        holder.put().await?;
+        Ok(())
+    }
+
+    async fn remove_entry(&self, child: &Node, name: &str) -> Result<(), Error> {
+        if name.len() >= 255 || name.contains('/') || name.contains('\0') {
+            return Err(Error::InvalidArgument);
+        }
+        assert_eq!(
+            Arc::as_ptr(&child.filesystem().unwrap()).addr(),
+            Arc::as_ptr(&self.fs).addr()
+        );
+
+        let child_ino = match child.data_as_any() {
+            data if let Some(dir) = data.downcast_ref::<DirectoryNode>() => {
+                // Check that the directory is not empty
+                dir.prepare_for_removal().await?;
+                dir.inode.ino()
+            }
+            data if let Some(file) = data.downcast_ref::<RegularNode>() => file.inode.ino(),
+            _ => return Err(Error::InvalidOperation),
+        };
 
         let ino = {
             let mut holder = self.inode.get_mut().await?;
@@ -409,6 +464,7 @@ impl DirectoryNode {
         };
 
         let ino = ino.ok_or(Error::DoesNotExist)?;
+        assert_eq!(ino, child_ino);
         log::info!("ext2: unlinked ino #{ino}");
 
         // Decrement the refcount on the inode
@@ -582,11 +638,11 @@ impl DirectoryImpl for DirectoryNode {
         Ok(())
     }
 
-    fn unlink_node(&self, _parent: &NodeRef, name: &str) -> Result<(), Error> {
+    fn unlink_node(&self, _parent: &NodeRef, child: &NodeRef, name: &str) -> Result<(), Error> {
         if self.fs.force_readonly {
             return Err(Error::ReadOnly);
         }
-        block!(self.remove_entry(name).await)?
+        block!(self.remove_entry(child, name).await)?
     }
 
     fn read_entries(
diff --git a/kernel/libk/src/vfs/node/ops.rs b/kernel/libk/src/vfs/node/ops.rs
index 0f2083fc..b089e8dc 100644
--- a/kernel/libk/src/vfs/node/ops.rs
+++ b/kernel/libk/src/vfs/node/ops.rs
@@ -139,23 +139,27 @@ impl Node {
         let directory = self.as_directory()?;
         let child = self.lookup_or_load(name, check)?;
 
-        if child.is_directory() {
-            return Err(Error::IsADirectory);
-        }
-
         // Detach the node in the real filesystem
-        match directory.imp.unlink_node(self, name) {
-            Ok(_) | Err(Error::NotImplemented) => (),
+        // If the filesystem implements unlink_node(), then checking if it's okay to
+        // remove a directory node is done by the filesystem itself, if not, check the
+        // cache.
+        let need_nonempty_check = match directory.imp.unlink_node(self, &child, name) {
+            Ok(_) => false,
+            Err(Error::NotImplemented) => true,
             Err(err) => return Err(err),
+        };
+
+        if need_nonempty_check && let Ok(child_dir) = child.as_directory() {
+            let child_children = child_dir.children.lock();
+            if !child_children.is_empty() {
+                return Err(Error::IsADirectory);
+            }
+            directory.remove_child(name);
+        } else {
+            // Just unlink the node from the cache tree
+            directory.remove_child(name);
         }
 
-        // Detach the node in the tree cache
-        {
-            let mut children = directory.children.lock();
-            children.retain(|(name_, _)| name != name_);
-        }
-
-        // TODO child.destroy() or something?
         Ok(())
     }
 
@@ -177,7 +181,7 @@ impl Node {
 
         let node = source.lookup_or_load(source_name, access)?;
 
-        match source_dir.imp.unlink_node(source, source_name) {
+        match source_dir.imp.unlink_node(source, &node, source_name) {
             Ok(()) | Err(Error::NotImplemented) => (),
             Err(error) => {
                 log::warn!("Rename: unlink {source_name}: {error:?}");
diff --git a/kernel/libk/src/vfs/node/traits.rs b/kernel/libk/src/vfs/node/traits.rs
index 33e31dc4..f58546c3 100644
--- a/kernel/libk/src/vfs/node/traits.rs
+++ b/kernel/libk/src/vfs/node/traits.rs
@@ -37,7 +37,6 @@ pub trait CommonImpl: Send + Sync {
 }
 
 /// Regular file interface
-#[allow(unused)]
 pub trait RegularImpl: CommonImpl {
     /// Opens the file for reading/writing and returns a `(start position, instance data)` tuple
     fn open(
@@ -82,7 +81,6 @@ pub trait RegularImpl: CommonImpl {
 }
 
 /// Directory implementation
-#[allow(unused)]
 pub trait DirectoryImpl: CommonImpl {
     /// Opens a directory for reading its entries. Returns [DirectoryOpenPosition] to specify the
     /// starting position.
@@ -111,7 +109,7 @@ pub trait DirectoryImpl: CommonImpl {
     }
 
     /// Removes an entry of the directory with given name
-    fn unlink_node(&self, parent: &NodeRef, name: &str) -> Result<(), Error> {
+    fn unlink_node(&self, parent: &NodeRef, child: &NodeRef, name: &str) -> Result<(), Error> {
         Err(Error::NotImplemented)
     }
 
@@ -127,7 +125,6 @@ pub trait DirectoryImpl: CommonImpl {
 }
 
 /// Symbolic link interface
-#[allow(unused)]
 pub trait SymlinkImpl: CommonImpl {
     // /// Returns the target node (if such is available directly) of the link
     // fn target(&self, node: &NodeRef) -> Result<NodeRef, Error> {