ext4 debugfsの "ls -d" で削除されたファイルが表示されない

削除されたファイルの状態について、CentOS 7.9 と Oracle Linux 7.9 (WSL2) で挙動が異なっていたので調べました。

## ファイル削除時のディレクトリエントリの状態

### CentOS 7.9の場合

ext4ファイルシステムになっているパーティションをマウントして、サンプルファイルをいくつか作成します。
```
[root@localhost ~]# mkfs.ext4 /dev/sdb1
（略）
[root@localhost ~]# mount /dev/sdb1 /mnt/test/
[root@localhost ~]# date > /mnt/test/a.txt
[root@localhost ~]# date > /mnt/test/b.txt
[root@localhost ~]# date > /mnt/test/c.txt
```
debugfsで該当ディレクトリ（/）のブロック番号を確認し、`block_dump`でブロックデータをダンプします。
```
[root@localhost ~]# debugfs /dev/sdb1
debugfs 1.42.9 (28-Dec-2013)
debugfs:  ls
 2  (12) .    2  (12) ..    11  (20) lost+found    12  (16) a.txt
 13  (16) b.txt    14  (4020) c.txt
debugfs:  stat /
Inode: 2   Type: directory    Mode:  0755   Flags: 0x80000
Generation: 0    Version: 0x00000000:00000003
User:     0   Group:     0   Size: 4096
File ACL: 0    Directory ACL: 0
Links: 3   Blockcount: 8
Fragment:  Address: 0    Number: 0    Size: 0
 ctime: 0x6519f801:5bf0c4bc -- Mon Oct  2 07:51:45 2023
 atime: 0x6519f7e8:00000000 -- Mon Oct  2 07:51:20 2023
 mtime: 0x6519f801:5bf0c4bc -- Mon Oct  2 07:51:45 2023
crtime: 0x6519f7e8:00000000 -- Mon Oct  2 07:51:20 2023
Size of extra inode fields: 28
EXTENTS:
(0):8737
debugfs:  block_dump 8737
0000  0200 0000 0c00 0102 2e00 0000 0200 0000  ................
0020  0c00 0202 2e2e 0000 0b00 0000 1400 0a02  ................
0040  6c6f 7374 2b66 6f75 6e64 0000 0c00 0000  lost+found......
0060  1000 0501 612e 7478 7400 0000 0d00 0000  ....a.txt.......
0100  1000 0501 622e 7478 7400 0000 0e00 0000  ....b.txt.......
0120  b40f 0501 632e 7478 7400 0000 0000 0000  ....c.txt.......
0140  0000 0000 0000 0000 0000 0000 0000 0000  ................
*

debugfs:  quit
```
0075～0114（8進数）の16バイトがb.txtのエントリーとなります。
その後b.txtのみを削除し、再度同じブロックの状態を確認します。
```
[root@localhost ~]# rm /mnt/test/b.txt
rm: 通常ファイル `/mnt/test/b.txt' を削除しますか? y
[root@localhost ~]# debugfs /dev/sdb1
debugfs 1.42.9 (28-Dec-2013)
debugfs:  ls -d
 2  (12) .    2  (12) ..    11  (20) lost+found    12  (32) a.txt
<13> (16) b.txt    14  (4020) c.txt
debugfs:  block_dump 8737
0000  0200 0000 0c00 0102 2e00 0000 0200 0000  ................
0020  0c00 0202 2e2e 0000 0b00 0000 1400 0a02  ................
0040  6c6f 7374 2b66 6f75 6e64 0000 0c00 0000  lost+found......
0060  2000 0501 612e 7478 7400 0000 0d00 0000   ...a.txt.......
0100  1000 0501 622e 7478 7400 0000 0e00 0000  ....b.txt.......
0120  b40f 0501 632e 7478 7400 0000 0000 0000  ....c.txt.......
0140  0000 0000 0000 0000 0000 0000 0000 0000  ................
*
```
`ls -d`の結果として削除された`<13> (16) b.txt`が表示されています。13が削除されたiノード番号を表し、16が該当ファイルのエントリー長を表しています。

ダンプデータにもb.txtエントリーのデータがそのまま残っていますが、ひとつ前のエントリa.txtのデータ長（0060～0061の2バイト、リトルエンディアン）が16バイトから32バイトに変わっており（0x0010→0x0020）、b.txtの領域だったところがa.txtの一部として扱われています。`ls`の結果でも`12  (16) a.txt`から`12  (32) a.txt`に変わっています。

### Oracle Linux 7.9(WSL2)の場合

こちらでも同じ操作を実行し、ファイル削除前の状態は以下の通りです。
```
[root@DESKTOP-HE7J8K0 ~]# debugfs /dev/sdd1
debugfs 1.42.9 (28-Dec-2013)
debugfs:  ls -d
 2  (12) .    2  (12) ..    11  (20) lost+found    12  (16) a.txt
 13  (16) b.txt    14  (4020) c.txt
debugfs:  stat /
Inode: 2   Type: directory    Mode:  0755   Flags: 0x80000
（略）
EXTENTS:
(0):8737
debugfs:  block_dump 8737
0000  0200 0000 0c00 0102 2e00 0000 0200 0000  ................
0020  0c00 0202 2e2e 0000 0b00 0000 1400 0a02  ................
0040  6c6f 7374 2b66 6f75 6e64 0000 0c00 0000  lost+found......
0060  1000 0501 612e 7478 7400 0000 0d00 0000  ....a.txt.......
0100  1000 0501 622e 7478 7400 0000 0e00 0000  ....b.txt.......
0120  b40f 0501 632e 7478 7400 0000 0000 0000  ....c.txt.......
0140  0000 0000 0000 0000 0000 0000 0000 0000  ................
*
```
ここまでの状況は同じで、このあとb.txtを削除します。
```
[root@DESKTOP-HE7J8K0 ~]# rm /mnt/test/b.txt
rm: remove regular file '/mnt/test/b.txt'? y
[root@DESKTOP-HE7J8K0 ~]# debugfs /dev/sdd1
debugfs 1.42.9 (28-Dec-2013)
debugfs:  ls -d
 2  (12) .    2  (12) ..    11  (20) lost+found    12  (32) a.txt
 14  (4020) c.txt
debugfs:  block_dump 8737
0000  0200 0000 0c00 0102 2e00 0000 0200 0000  ................
0020  0c00 0202 2e2e 0000 0b00 0000 1400 0a02  ................
0040  6c6f 7374 2b66 6f75 6e64 0000 0c00 0000  lost+found......
0060  2000 0501 612e 7478 7400 0000 0000 0000   ...a.txt.......
0100  0000 0000 0000 0000 0000 0000 0e00 0000  ................
0120  b40f 0501 632e 7478 7400 0000 0000 0000  ....c.txt.......
0140  0000 0000 0000 0000 0000 0000 0000 0000  ................
*
```
こちらでは`ls -d`の結果にb.txtは表示されず、ダンプデータのb.txtがあった個所はゼロ埋めさえてきれいに消されてしまっています。このブロックデータから消されたファイル名を元にiノード番号を調べるようなことはできません。

## ソースコード確認

Linux Kernelの該当処理を確認します。それぞれのディストリビューションで使用しているカーネルバージョンは以下の通りです。

CentOS 7.9の場合：
```
[root@localhost ~]# uname -a
Linux localhost.localdomain 3.10.0-1160.el7.x86_64 #1 SMP Mon Oct 19 16:18:59 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
```
Oracle Linux 7.9(WSL2)の場合：
```
[root@DESKTOP-HE7J8K0 ~]# uname -a
Linux DESKTOP-HE7J8K0 5.15.90.1-microsoft-standard-WSL2 #1 SMP Fri Jan 27 02:56:13 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
```
Oracle Linuxは使用できるカーネルは Red Hat Compatible Kernel（RHCK）と Unbreakable Enterprise Kernel（UEK）の2種類あり、以下を見ると7.9のUEKは5.4.17のようなので、今回試した Windows WSL2 はさらに異なっています。

https://docs.oracle.com/en/operating-systems/oracle-linux/7/relnotes7.9/ol7-kernels.html#Chapter-3-Shipped-Kernels

https://github.com/oracle/linux-uek

### カーネルの処理

構造体ext4_dir_entry_2への削除操作は fs/ext4/namei.c の ext4_generic_delete_entry() で行われています。

Oracle Linux 7.9（kernel 5.15.90.1）の場合は以下のようになっています。
https://elixir.bootlin.com/linux/v5.15.90/source/fs/ext4/namei.c#L2640

```c
/*
 * ext4_generic_delete_entry deletes a directory entry by merging it
 * with the previous entry
 */
int ext4_generic_delete_entry(struct inode *dir,
			      struct ext4_dir_entry_2 *de_del,
			      struct buffer_head *bh,
			      void *entry_buf,
			      int buf_size,
			      int csum_size)
{
	struct ext4_dir_entry_2 *de, *pde;
	unsigned int blocksize = dir->i_sb->s_blocksize;
	int i;

	i = 0;
	pde = NULL;
	de = (struct ext4_dir_entry_2 *)entry_buf;
	while (i < buf_size - csum_size) {
		if (ext4_check_dir_entry(dir, NULL, de, bh,
					 entry_buf, buf_size, i))
			return -EFSCORRUPTED;
		if (de == de_del)  {
			if (pde) {
				pde->rec_len = ext4_rec_len_to_disk(
					ext4_rec_len_from_disk(pde->rec_len,
							       blocksize) +
					ext4_rec_len_from_disk(de->rec_len,
							       blocksize),
					blocksize);

				/* wipe entire dir_entry */
				memset(de, 0, ext4_rec_len_from_disk(de->rec_len,
								blocksize));
			} else {
				/* wipe dir_entry excluding the rec_len field */
				de->inode = 0;
				memset(&de->name_len, 0,
					ext4_rec_len_from_disk(de->rec_len,
								blocksize) -
					offsetof(struct ext4_dir_entry_2,
								name_len));
			}

			inode_inc_iversion(dir);
			return 0;
		}
		i += ext4_rec_len_from_disk(de->rec_len, blocksize);
		pde = de;
		de = ext4_next_entry(de, blocksize);
	}
	return -ENOENT;
}
```
ループ処理でディレクトリエントリを順に処理していますが、途中で "wipe entire dir_entry" とコメントがあるところで該当領域を0で埋めていることがわかります。

### いつ変更されたのか
[ChangeLog-5.13](https://cdn.kernel.org/pub/linux/kernel/v5.x/ChangeLog-5.13)によると、commit id 6c0912739699d8e4b6a87086401bf3ad3c59502d としてkernel 5.13.0 以降でmemsetでゼロ埋めするような変更が fs/ext4/namei.c に加えられています。

```diff
$ git show 6c0912739699d8e4b6a87086401bf3ad3c59502d
commit 6c0912739699d8e4b6a87086401bf3ad3c59502d
Author: Leah Rumancik <leah.rumancik@gmail.com>
Date:   Thu Apr 22 18:08:34 2021 +0000

    ext4: wipe ext4_dir_entry2 upon file deletion

    Upon file deletion, zero out all fields in ext4_dir_entry2 besides rec_len.
    In case sensitive data is stored in filenames, this ensures no potentially
    sensitive data is left in the directory entry upon deletion. Also, wipe
    these fields upon moving a directory entry during the conversion to an
    htree and when splitting htree nodes.

    The data wiped may still exist in the journal, but there are future
    commits planned to address this.

    Signed-off-by: Leah Rumancik <leah.rumancik@gmail.com>
    Link: https://lore.kernel.org/r/20210422180834.2242353-1-leah.rumancik@gmail.com
    Signed-off-by: Theodore Ts'o <tytso@mit.edu>

diff --git a/fs/ext4/namei.c b/fs/ext4/namei.c
index 680fc3211cbf..8b46a17a85c1 100644
--- a/fs/ext4/namei.c
+++ b/fs/ext4/namei.c
（略）
@@ -2577,15 +2585,27 @@ int ext4_generic_delete_entry(struct inode *dir,
                                         entry_buf, buf_size, i))
                        return -EFSCORRUPTED;
                if (de == de_del)  {
-                       if (pde)
+                       if (pde) {
                                pde->rec_len = ext4_rec_len_to_disk(
                                        ext4_rec_len_from_disk(pde->rec_len,
                                                               blocksize) +
                                        ext4_rec_len_from_disk(de->rec_len,
                                                               blocksize),
                                        blocksize);
-                       else
+
+                               /* wipe entire dir_entry */
+                               memset(de, 0, ext4_rec_len_from_disk(de->rec_len,
+                                                               blocksize));
+                       } else {
+                               /* wipe dir_entry excluding the rec_len field */
                                de->inode = 0;
+                               memset(&de->name_len, 0,
+                                       ext4_rec_len_from_disk(de->rec_len,
+                                                               blocksize) -
+                                       offsetof(struct ext4_dir_entry_2,
+                                                               name_len));
+                       }
+
                        inode_inc_iversion(dir);
                        return 0;
                }
```

## まとめ

変更されたのがkernel 5.13.0なので、CentOS 7.9のkernel 3.10.0ではまだゼロ埋めされない処理になっています。

Oracle LinuxはいわゆるRHELクローンで、RHELおよびCentOSと100%アプリケーション・バイナリ互換性があると謳っていますが、当然カーネルが違えば内部の処理も異なってくるので注意が必要です。

この5.13.0で適用された変更はファイルを適切に削除する（削除されたファイルの痕跡を残さない）という意味では正しいと思われますが、誤削除したファイルをなんとか復旧させたい人にとっては手がかりがひとつなくなったことになります。

## 参考

* ファイル削除時のディレクトリブロックをダンプしての挙動確認：
[Understanding EXT4 (Part 6): Directories](https://www.sans.org/blog/understanding-ext4-part-6-directories/)
* 構造体ext4_dir_entry_2の詳細：
[Ext4 Disk Layout - Directory Entries](https://ext4.wiki.kernel.org/index.php/Ext4_Disk_Layout#Directory_Entries)