【译】读取文件的困难模式 三

到目前为止,我们已经看到了通过不同编程语言读取文件的多种方法,我们学习了系统调用,如何从汇编中进行这些调用, 然后我们学习了内存映射、虚拟地址空间和通常是用户态和内核交互的一些机制。

但在我们的探索中,我们总是或多或少地把内核看作一个“黑匣子”。是时候改变这一点了。

在野兽的肚子里

在第2部分中,我们使用gdb逐步执行我们的用户态程序readfile。这使我们能够在每条指令之后检查CPU寄存器的状态。但我们无法看到内核中发生了什么。

我们还使用strace来跟踪系统调用。它比 gdb 噪音小, 因为它只展示在我们的用户层程序与内核通信时的信息。

现在是时候加强并使用ftrace了。这是一个集成在linux内核中的追踪设施。多亏了它,我们能够追踪各种内核函数和事件。

酷熊的热心贴士
Julia Evans 的博客上有一篇精彩的ftrace简介(https://jvns.ca/blog/2017/03/19/getting-started-with-ftrace/)。

说到 Julia,请查看她的精彩杂志!(https://wizardzines.com/)

我们最简单的入门方法是使用trace-cmd。在 Manjaro/ArchLinux 上, 有一个 AUR 包,所以我们可以抓住它:

Shell session

1
$ yay -S trace-cmd

在 Ubuntu 上,也有一个包:

Shell session

1
$ sudo apt install trace-cmd

该计划是:

  • 启用对所有事件的跟踪ext4 事件(稍后详细介绍)
  • 运行我们的readfile程序(汇编程序)
  • 查看报告并尝试拼凑出发生了什么

我的第一次跟踪没有成功 - 它只包含与 zsh(我的 shell)和 git(由我的 shell 提示调用以显示指示器)。

事实证明,存在多级缓存,并且/etc/hosts 已经缓存到最大,所以读取它甚至没有注册。

幸运的是,我们可以清除所有这些缓存(在系统运行时!) 以 root 身份运行以下命令:

Bash

1
2
3
4
$ sync

$ echo 3 > /proc/sys/vm/drop_caches

然后,我们可以开始跟踪(再次以 root 身份):

Bash

1
$ trace-cmd record -e ext4

然后启动我们的程序,点击Ctrl-C停止trace-cmd,然后再次启动它 trace-cmd 将收集的数据格式化为人类可读的格式:

Bash

1
$ trace-cmd report trace.dat > trace.txt

我们终于得到它了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

readfile-2056669 [002] 112770.475550: ext4_es_lookup_extent_enter: dev 8,3 ino 16252929 lblk 0
readfile-2056669 [002] 112770.475551: ext4_es_lookup_extent_exit: dev 8,3 ino 16252929 found 1 [0/1) 65019936 WR
readfile-2056669 [002] 112770.475553: ext4_es_lookup_extent_enter: dev 8,3 ino 16252929 lblk 1
readfile-2056669 [002] 112770.475553: ext4_es_lookup_extent_exit: dev 8,3 ino 16252929 found 1 [1/2) 65020464 WR
readfile-2056669 [002] 112770.475565: ext4_es_lookup_extent_enter: dev 8,3 ino 16253092 lblk 0
readfile-2056669 [002] 112770.475565: ext4_es_lookup_extent_exit: dev 8,3 ino 16253092 found 0 [0/0) 0
readfile-2056669 [002] 112770.475565: ext4_ext_map_blocks_enter: dev 8,3 ino 16253092 lblk 0 len 1 flags
readfile-2056669 [002] 112770.475566: ext4_es_cache_extent: dev 8,3 ino 16253092 es [0/1) mapped 65050624 status W
readfile-2056669 [002] 112770.475566: ext4_ext_show_extent: dev 8,3 ino 16253092 lblk 0 pblk 65050624 len 1
readfile-2056669 [002] 112770.475566: ext4_ext_map_blocks_exit: dev 8,3 ino 16253092 flags lblk 0 pblk 65050624 len 1 mflags M ret 1
readfile-2056669 [002] 112770.475566: ext4_es_insert_extent: dev 8,3 ino 16253092 es [0/1) mapped 65050624 status W
readfile-2056669 [002] 112770.474893: ext4_es_lookup_extent_enter: dev 8,3 ino 18491454 lblk 0
readfile-2056669 [002] 112770.474893: ext4_es_lookup_extent_exit: dev 8,3 ino 18491454 found 0 [0/0) 0
readfile-2056669 [002] 112770.474893: ext4_ext_map_blocks_enter: dev 8,3 ino 18491454 lblk 0 len 4 flags
readfile-2056669 [002] 112770.474894: ext4_es_cache_extent: dev 8,3 ino 18491454 es [0/5) mapped 93400358 status W
readfile-2056669 [002] 112770.474894: ext4_ext_show_extent: dev 8,3 ino 18491454 lblk 0 pblk 93400358 len 5
readfile-2056669 [002] 112770.474894: ext4_ext_map_blocks_exit: dev 8,3 ino 18491454 flags lblk 0 pblk 93400358 len 4 mflags M ret 4
readfile-2056669 [002] 112770.474894: ext4_es_insert_extent: dev 8,3 ino 18491454 es [0/4) mapped 93400358 status W

这些列的格式如下:

process_name-pid [cpu_core] timestamp: event_name: parameters
我无法找到每个事件的精确文档,但我们可以 发现这里主要做了三件事:

  1. 查找“inode 16252929”

  2. 查找并映射“inode 16253092”

  3. 查找并映射“inode 18491454”
    就是这样。

    我们学到了什么?
    ftrace 允许跟踪内核代码内部的行为。我们无法改变它 但我们可以更好地了解正在发生的事情。

我们的路径都去哪儿了?

而strace向我们展示了/etc/hosts,即我们正在映射的文件的路径ftrace没有追踪到。相反,它向我们显示索引节点号。要了解原因,我们必须考虑一下文件系统的人体工程学。

✨ 让我们谈谈符号链接 ✨

符号链接(简称符号链接)是文件,但它们不是常规文件。 它们指向 另一个 文件,每当打开它们时,另一个文件 应该打开。有..使用符号链接的理由,我不会深入讨论, 相信我这一点。

要点是,例如,几乎所有 Linux 系统中都有一堆符号链接 在/usr/lib中:

Shell session

1
2
3
$ ll /usr/lib/x86_64-linux-gnu/libcairo.so*
lrwxrwxrwx 1 root root 21 Oct 1 21:19 /usr/lib/x86_64-linux-gnu/libcairo.so.2 -> libcairo.so.2.11600.0
-rw-r--r-- 1 root root 1203976 Oct 7 2021 /usr/lib/x86_64-linux-gnu/libcairo.so.2.11600.0

此处,libcairo.so.2 指向 libcairo.so.2.11600.0。 如果我们运行像 wc 这样的命令(它计算单词数,但也计算字节数),我们会看到 它正在访问两种情况下所指向的文件:

Shell session

1
2
3
4
5
6
$ wc /usr/lib/x86_64-linux-gnu/libcairo.so.2
4419 24794 1203976 /usr/lib/x86_64-linux-gnu/libcairo.so.2

$ wc /usr/lib/x86_64-linux-gnu/libcairo.so.2.11600.0
4419 24794 1203976 /usr/lib/x86_64-linux-gnu/libcairo.so.2.11600.0

但是,它们不是副本。它们只是……符号链接。至于实现细节,我们注意到两件事:这些符号链接文件的大小正是大小 它们指向的路径的不同,并且它们的模式略有不同:

所以符号链接是带有特殊模式标志的文件,其内容是他们指向的路径。这意味着它们可以悬垂(指向空路径)!

酷熊的热心贴士
一个悬垂的符号链接是指向不存在的文件的符号链接。

请参阅下面的示例:

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ echo "Hello there" > regular.txt

$ cat regular.txt
Hello there

$ ln -s regular.txt symlink.txt

$ ll
total 144
drwxrwxr-x 2 amos amos 4096 Mar 11 14:11 ./
drwxrwxrwt 76 root root 135168 Mar 11 14:11 ../
-rw-rw-r-- 1 amos amos 12 Mar 11 14:11 regular.txt
lrwxrwxrwx 1 amos amos 11 Mar 11 14:11 symlink.txt -> regular.txt

$ cat symlink.txt
Hello there

$ rm regular.txt

$ cat symlink.txt
cat: symlink.txt: No such file or directory

你可以用它制作一个文件系统(或者你可以吗?)

我们来谈谈文件夹。正如我们所见,符号链接只是文件,带有一种特殊的模式,它们的内容是一个路径。文件夹(目录)不能做同样的事情吗?

让我们构建一个文件系统。什么是文件系统?只是组织文件的一种方式 以及磁盘上的文件夹、其内容和元数据。你可以想象一个磁盘 作为一块大白板,就像这样:

1

酷熊的热心贴士
在本文的其余部分中,我们将使用“磁盘”一词。什么时候 我们真正的意思是“存储设备”。还更短了!

假设,只是为了好玩,我们将此磁盘划分为一系列块。让我们将它们设置为4096字节(或 4KiB)。这样,它们的大小可能和内核内存页相同。或者是后者的倍数。至少它们是二的幂。

2

假设在我们的文件系统中,每个文件夹只是一个名称列表:它们的子节点。我们可以很好地遍历文件系统:

  • / 的子级列表中有 /etc
  • /etc 的子级列表中有 /etc/hosts
  • /etc/hosts是一个普通文件

听起来不错吧?我的意思是,我们仍然需要解决一些问题。

首先,我们需要确定文件夹和文件在磁盘上的位置。但这并不是一个非常困难的问题。我们可以只有一个路径列表和一个偏移量列表,可能是这样的:

3

当我们想要访问/etc/hosts时,我们只需:1) 浏览我们的表,找到正确的条目,然后 2) 阅读它!

4

这看起来并没有那么糟糕。为什么会出现问题呢?

问题 1:查找文件的时间复杂度为O(n),其中n是文件数。

我刚刚统计了我的一个 Linux 系统上的文件,有 其中 1’128’252。听起来像用线性搜索查找文件是会变得昂贵。

问题 2:每个文件的内容需要连续。

在我们当前的布局下,如果文件太大而无法容纳在一个块中, 并且使用了下一个块,那么我们需要移动它:

5

问题 3:元数据与数据混合。

假设我们要实现 tree 命令。它打印出名字 文件系统中每个文件的模式和大小,无论嵌套有多深。

根据我们的磁盘布局,我们必须读取每个块的开头 包含一个文件只是为了了解其元数据。当然,同时还有每个文件夹的内容(但这很正常)。

6

这尤其成问题,因为读取元数据(这种情况发生得很频繁) 通常)需要大量的寻找(Seeking)。寻找是“跳到某个位置”的过程 不同位置”读取存储设备时。

这对于光盘读取器或硬盘驱动器来说非常昂贵。对于磁带驱动器来说极其昂贵。

7

问题 4:重命名操作的开销非常大。

如果我们想将 /etc 重命名为 /config,我们有一堆东西要重写:

8

这意味着在整个磁盘的许多不同位置,阅读一堆条目来找出我们应该重写的位置,然后写入。 还记得搜素(Seeking)昂贵吗?忘掉它吧。

目录中的子文件夹和文件越多,情况就会变得更糟。

问题 5:每个文件操作都需要解析整个路径。

根据我们的方案,如果我们想从/etc/hosts/etc,有 没有直接链接!我们必须将 /etc/hosts 解析为 [“etc”, “hosts”], 删除最后一个以获取 [“etc”] 并从中构建 /etc 路径。

然后我们必须在一开始就使用我们的查找表来查找 /etc 到底是什么。

总而言之,这是一个非常糟糕的磁盘布局。从技术上讲,它适用于一个玩具操作系统,但我不想将其用作日常驱动程序。

我们学到了什么?
文件系统的磁盘布局必须仔细设计。

枚举文件、向下或向上遍历等操作, 重命名文件夹,应该可以通过尽可能少的操作来实现,最小化寻找。

输入索引节点

我们从第一次尝试文件系统磁盘中学到的教训之一:我们应该将元数据和数据分开。我们也应该寻找另一种方式来引用文件而不是完全限定名称。

无论我们的文件是否位于/etc/hosts我们的/config/hosts - 它是同一个文件! 我们的设计应该反映这一点。

因此,让我们首先尽可能早的把所有元数据存储在一起。我们需要保留一大块空间,大到足够描述磁盘上的每个文件。

这将是我们的索引,每个元素都是一个“索引节点”或“inode”。

9

每个 inode 都将包含通用的内容,而且还将包含我们可以在其中找到其数据的块的编号

10

至于文件夹 - 简单!在数据块中,我们存储目录列表 条目。每个条目都引用另一个inode,并为其指定一个名称。

11

让我们回顾一下。我们的问题真的解决了吗?首先,如何我们要在这个新方案中查找文件吗?

假设我们要打开/etc/hosts。首先我们读取 / 的 inode。为了方便起见,让我们决定,因为它是文件系统根(最顶层)节点,它将始终位于 inode 1 中:

12

然后我们读取其目录条目,查找“etc”。然后我们读 “etc”的索引节点。然后我们读取其整个目录,查找“hosts”。 最后,我们读取“hosts”的数据。

我制作了一个快速图表,以便更容易理解:

13

好吧,这个流程看起来并不简单。

但是与我们之前的方案相比,有一个优点。我们多次搜索更小的目录条目集,而不是通过绝对路径在文件系统中的所有文件中进行线性搜索:

14

酷熊的热心贴士
上图并未按比例绘制:我们只是减少了最坏的情况 110 万次字符串比较到 228 次字符串比较。

新的最坏情况是旧情况的 0.02%。

当然,这取决于每个目录有多少个文件!

重命名操作怎么样?。您可能已经注意到,在我们的新方案中,inode不包含路径信息-没有name字段。

这些名称实际上包含在目录条目中:

15

如果我们想重命名/etc,我们只需要更改目录即可 条目 - 我们不需要触及任何子节点的索引节点, 无论子目录层次结构有多深:

16

如果我们想将 /etc/hosts 移动到 /hosts,我们只需删除 /etc的 inode 中的目录条目,并向 /的 inode 加一。

同样,无论有多少个目录,操作次数都是固定的 以及我们目录中的文件。这要好得多。

现在,向下遍历非常高效(比以前更高),但是 向上遍历(例如,从 /etc 到 /)仍然需要 操纵路径,我们不想这样做。

我们有个主意:如果我们向每个目录添加一个条目会怎么样? 给它的父级?我们可以称其为“..”。对于根,我们只需制作 它指向自身:

17

解决剩余问题

我不了解你的情况,但到目前为止我对我们的磁盘布局非常满意。

然而,(至少)有两件事我们可以做得更好。

现在,每个目录条目都以不特定的顺序存储在列表中。 为了找到特定的条目,我们必须进行线性搜索,即。每个条目一个接一个做比较,只有当我们找到我们所找到的内容时才停止查找。

仅当目录中的文件不超过几千个时,这才实用。如果我们遇到几万、几十万的情况,那么会开始变得非常缓慢。

一个潜在的解决方案是散列(hash)所有条目的名称,并使用 自平衡二叉搜索树。

我不会在这里详细介绍,但基本上,它允许插入(添加 一条记录)、删除(删除一条记录)、对数时间搜索, 这意味着它会比线性数据结构快得多。

熊的热心贴士
《数据结构》是一个丰富而复杂的领域,这需要 很多很多文章。

我们今天不去那里。

第二个问题是我们的文件仍然必须连续。如果 它们长大了,我们可能不得不将它们移到另一组块(block)中。我们可能 甚至发现没有足够大的连续块(block)来容纳文件!

为了解决这个问题,我们可以使用范围(extents)。

对于每个文件,我们可以存储一系列(start, length)对:

18

这带来了一个新问题:以前,如果我们想读取只是文件的后半部分,我们可以简单地计算出文件的地址第一个块:

18

但是现在,文件的中间可能位于任何范围内。这是 不再是简单的算术。为了解决这个问题,我们还可以使用树形数据结构。

如果我们想从头开始读取文件,那么我们可以简单地 按顺序走树。如果我们正在寻找其他地方,我们只需 在树中进行搜索,直到找到正确的范围,然后然后 这么简单的算术。

现在来体验一下现实世界

玩够了!是时候看看真正的实时文件系统了。

在我们开始考虑磁盘布局之前,我们正在跟踪 打开和映射时的内核事件/etc/hosts。

我们得到的跟踪包含如下行:

1
2
3
4
ext4_ext_map_blocks_enter: dev 8,3 ino 18491454 lblk 0 len 4 flags 

ext4_ext_map_blocks_enter: dev 8,3 ino 16253092 lblk 0 len 1 flags

现在这更有意义了。

第一行引用 inode“18491454” (我们现在知道这意味着什么)。这 ext 事件名称的一部分可能指的是范围,因为有一个 “镜头4” (长度为4)。

可能是/etc/hosts吗?

酷熊的热心贴士
在 Linux 上,我们可以将 -i 标志传递给 ls 命令,使其显示 inode 编号。

Shell session

1
2
$ ls -i -l /etc/hosts
16253092 -rw-r--r-- 1 root root 220 Oct 1 21:21 /etc/hosts

不! /etc/hosts 是 16253092。这是第二行。

那么第一个是什么?

好吧,还记得我说过二进制文件在执行时会被映射吗?

Shell session

1
2
$ ls -i ./readfile
18491454 ./readfile

答对了。

据我们所知,真实的 Linux 系统使用的文件系统 也在使用inode。还有范围!

我们可以使用 df 命令找出它是什么文件系统(具体来说, -T 标志):

Shell session

1
2
3
4
$ df -Th /
Filesystem Type Size Used Avail Use% Mounted on
/dev/sda3 ext4 548G 96G 425G 19% /

所以,显然是ext4。它似乎是最新的文档 ext4 位于 kernel.org。

不幸的是,这个文档似乎不是由ext4作者编写的,而是由其他人在Linux中阅读ext4的代码时写的。哦,好吧,这就是生活。

但首先:这是什么/dev/sda3

好吧,还记得我说过内核是老大吗?并且它控制了用户态进程所能看到的现实?对于文件来说,这更是如此。

并非所有可以 open()’d 的东西都是传统意义上的文件。

有些只是..资源。

例如,/proc/cpuinfo “包含”有关已安装 CPU 的信息:

Shell session

1
2
3
4
5
6
$ head -5 /proc/cpuinfo
processor : 0
vendor_id : AuthenticAMD
cpu family : 25
model : 33
model name : AMD Ryzen 9 5950X 16-Core Processor

实际上并不在任何存储设备上。这只是内核的一种方式 通过标准系统调用为用户态进程提供一些信息 例如 open、read、write 和 close。

/proc/self/是一个包含当前信息的目录 运行过程。如果我们像这样修改 Go 程序:

Go code

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
"strings"
"io/ioutil"
)

func main() {
payload, _ := ioutil.ReadFile("/proc/self/cmdline")
fmt.Printf("%#v\n", strings.Split(string(payload), "\x00"))
}

…然后像这样运行它,它会打印它的参数:

Shell session

1
2
3
4
5
$ go build ./main.go

$ ./main "foo" "argument 2" "bar"
[]string{"./main", "foo", "argument 2", "bar", ""}

事实上,在《Unix 哲学》中,一切皆文件。

酷熊的热心贴士
这让很多人感到愤怒(https://en.wikipedia.org/wiki/The_UNIX-HATERS_Handbook)。

因此,/dev/sda3 只是一个“文件”。我们的根ext4分区所包含的全部内容。

如果我们想要整个磁盘,我们会打开/dev/sda。那我们就不得不学习分区表了,这个最好留待以后。

让我们读取整个分区

现在我们已经完成了介绍性材料,让我们直接进入正题。

我们将在下一部分中使用 Rust。

Shell session

1
2
3
4
$ cargo new read-raw-ext4
Created binary (application) `read-raw-ext4` package

$ cd read-raw-ext4/

让我们添加我们需要的板条箱之一:

Shell session

1
2
3
4
5
$ cargo add [email protected]
Updating crates.io index
Adding hex-slice v0.1.4 to dependencies.
Updating crates.io index

Rust code
// in src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use hex_slice::AsHex;
use std::{fs::OpenOptions, io::Read};

fn main() -> Result<(), std::io::Error> {
// open our ext4 partition, READ-ONLY.
let mut file = OpenOptions::new().read(true).open("/dev/sda3")?;

// allocate a 128-byte buffer
let mut buf = vec![0u8; 128];

// read the first 128 bytes of the file
file.read_exact(&mut buf)?;

// print it as hexadecimal
println!("{:x}", buf.as_hex());

Ok(())
}

这可以愉快地构建cargo build,但它不会运行:

Shell session

1
2
3
4
5
6
7
8
$ cargo build
Compiling hex-slice v0.1.4
Compiling read-raw-ext4 v0.1.0 (/home/amos/bearcove/read-raw-ext4)
Finished dev [unoptimized + debuginfo] target(s) in 0.37s

$ ./target/debug/read-raw-ext4
Error: Os { code: 13, kind: PermissionDenied, message: "Permission denied" }

看,我们当前正在以普通用户身份运行该程序。如果任何用户都可以随意访问任何分区,然后他们就可以绕过文件权限。

那会很糟糕。

所以,让我们假装我们热衷于这一分钟,并且:

Shell session

1
2
3
$ sudo ./target/debug/read-raw-ext4 
[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

啊。它充满了零。我们可能需要文档(https://www.kernel.org/doc/html/latest/filesystems/ext4/overview.html)。

这是它要说的:

对于块组0的特殊情况,前1024字节未使用,以允许安装x86引导扇区和其他怪事。超级块将从偏移量 1024 字节开始,无论是哪个块(通常是 0)。

好吧,让我们从字节 1024 开始读取。我们将使用 positioned-io crate (https://crates.io/crates/positioned-io)为此。

Shell session

1
2
3
4
5
6
7
$ cargo add [email protected]
Updating crates.io index
Adding positioned-io v0.3 to dependencies.
Features as of v0.3.0:
+ byteorder
Updating crates.io index

Rust code
// in src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use hex_slice::AsHex;
use positioned_io::ReadAt;
use std::fs::OpenOptions;

fn main() -> Result<(), std::io::Error> {
let file = OpenOptions::new().read(true).open("/dev/sda3")?;
let mut buf = vec![0u8; 128];

// read 128 bytes of the file starting at offset 1024
file.read_exact_at(1024, &mut buf)?;

println!("{:x}", buf.as_hex());

Ok(())
}

Shell session

1
2
3
4
5
6
7
8
$ cargo b && sudo ./target/debug/read-raw-ext4 
Compiling libc v0.2.140
Compiling byteorder v1.4.3
Compiling positioned-io v0.3.1
Compiling read-raw-ext4 v0.1.0 (/home/amos/bearcove/read-raw-ext4)
Finished dev [unoptimized + debuginfo] target(s) in 0.84s
[0 e0 2d 2 0 3b b7 8 c0 8f 6f 0 3b 9 d 7 ac 7e 10 2 0 0 0 0 2 0 0 0 2 0 0 0 0 80 0 0 0 80 0 0 0 20 0 0 c5 b4 4 64 c4 b4 4 64 43 0 ff ff 53 ef 1 0 1 0 0 0 98 92 38 63 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 b 0 0 0 0 1 0 0 3c 0 0 0 c6 2 0 0 6b 4 0 0 f7 6a 4c a7 98 2f 4d 8b bb cb ae 71 15 ed d6 90 0 0 0 0 0 0 0 0]

现在我们开始做饭了!文档提到了“超级块”,如果我们阅读 它的结构,它说我们应该找到幻数0xEF53 偏移量 0x38。它还说它是一个小端 16 位整数。

嗯,有一个板条箱可以解决这个问题:byteorder。

Shell session

1
2
3
4
5
6
7
$ cargo add [email protected]
Updating crates.io index
Adding byteorder v1.4 to dependencies.
Features as of v1.4.0:
+ std
- i128

让我们再添加一个用于错误处理:

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
$ cargo add [email protected]
Updating crates.io index
Adding color-eyre v0.6 to dependencies.
Features as of v0.6.0:
+ capture-spantrace
+ color-spantrace
+ tracing-error
+ track-caller
- issue-url
- url
Updating crates.io index

由于我们将阅读很多内容,因此让我们创建一个辅助结构。 我们还需要一些 use 指令:

Rust code
// this will let us read integers of various width

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use byteorder::{LittleEndian, ReadBytesExt};

// a cursor keeps track of our position within any
// resource that implements ReadAt.
use positioned_io::{ReadAt, Cursor};

// this gives us a generic error type that's pretty solid by default
use color_eyre::Result;

struct Reader<IO> {
inner: IO,
}

impl<IO: ReadAt> Reader<IO> {
fn new(inner: IO) -> Self {
Self { inner }
}

fn u16(&self, offset: u64) -> Result<u16> {
let mut cursor = Cursor::new_pos(&self.inner, offset);
Ok(cursor.read_u16::<LittleEndian>()?)
}
}

现在,任何时候我们想从分区中读取某些内容,我们都会首先创建一个 positioned_io::Slice,将其传递给 Reader,并使用它。

酷熊的热心贴士
请注意,File、Slice 或 Reader 都不需要是可变的,因为它们都不在其中维护位置信息。

它们甚至可以同时使用!

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use positioned_io::Slice;

fn main() -> Result<()> {
let file = OpenOptions::new().read(true).open("/dev/sda3")?;

// create a slice that corresponds to the superblock
let r = Reader::new(Slice::new(file, 1024, None));

// as per the docs:
let magic = r.u16(0x38)?;
println!("magic = {magic:x}");

Ok(())
}

Shell session

1
2
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
magic = ef53

嘿,这就是文档给我们的价值!我们在正确的轨道。

块组(Block Group)

在我们的玩具磁盘布局中,我们将磁盘划分为块。ext4确实如此 只是同样的事情!在超级块的偏移量0x18处,我们发现块的大小。

好吧,我们找到n,其中块大小为2 ^ (10 + n)。

我们需要向 Reader 添加一个u32 方法:

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
impl<IO: ReadAt> Reader<IO> {
fn new(inner: IO) -> Self {
Self { inner }
}

fn u16(&self, offset: u64) -> Result<u16> {
let mut cursor = Cursor::new_pos(&self.inner, offset);
Ok(cursor.read_u16::<LittleEndian>()?)
}

// 👇 new!
fn u32(&self, offset: u64) -> Result<u32> {
let mut cursor = Cursor::new_pos(&self.inner, offset);
Ok(cursor.read_u32::<LittleEndian>()?)
}
}

在 main 中,做一些数学运算:

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() -> Result<()> {
let file = OpenOptions::new().read(true).open("/dev/sda3")?;

let r = Reader::new(Slice::new(file, 1024, None));

let magic = r.u16(0x38)?;
println!("magic = {magic:x}");

// 👇 new!
let n = r.u32(0x18)?;
let block_size = 1 << (10 + n);
println!("block_size = {block_size}");

Ok(())
}

Shell session

1
2
3
4
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
magic = ef53
block_size = 4096

多么幸运啊!块大小为 4KB,就像我们的玩具磁盘布局一样。

但是,如果我们查看文档,我们会注意到这些块是分组的 (分成..块组)。

ext4文件系统分为一系列块组。为了减少碎片造成的性能困难,块分配器尽力将每个文件的块保留在同一组中,从而减少寻道时间。块组的大小在`sb.s_blocks_per_group`中指定,也可以`8 * block_size_in_bytes` 来计算。在默认块大小为4kb的情况下,每组包含32768个块。块组的数量是设备的大小,除以块组的大小。   -- 内核文档。

这些在…组描述符中进行了描述(GDT 代表 “组描述符表”):

标准块组的布局大致如下(每个字段将在下面的单独部分中讨论):
Group 0 Padding ext4 Super Block Group Descriptors Reserved GDT Blocks Data Block Bitmap inode Bitmap inode Table Data Blocks
1024 bytes 1 block many blocks many blocks 1 block 1 block many blocks many more blocks

每组的块数存储在超级块中, 在偏移量 0x20 处:

Rust code

1
2
3
// in main
let bpg = r.u32(0x20)?;
println!("blocks per group = {bpg}");

Shell session

1
2
3
4
5
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
magic = ef53
block_size = 4096
blocks per group = 32768

最后,每组的inodes数量存储在偏移量0x28处;

Rust code

1
2
3
4
// in main
let ipg = r.u32(0x28)?;
println!("inodes per group = {ipg}");

Shell session

1
2
3
4
5
6
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
magic = ef53
block_size = 4096
blocks per group = 32768
inodes per group = 8192

好消息是所有这些值看起来都是合法的。坏消息 是我们还远未读取索引节点。

读取ext4中的inode

让我们回顾一下。ext4分区以1024字节的填充开始,然后是一个超级块,其中包含各种设置,例如大小块的数量、每组块的数量、每组inode的数量。

然后是块组描述符。这些包含的偏移量 有几件事,但我们感兴趣的是索引节点表:
20

让我们从头开始 - 查找 “/“ 的inode。

ext4 中有一些特殊的 inode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
inode号	  目的
0 不存在;没有索引节点 0。
1 有缺陷的块列表。
2 根目录。
3 用户配额。
4 团体名额。
5 引导加载程序。
6 取消删除目录。
7 保留的组描述符 inode。 (“调整索引节点大小”)
8 日志索引节点。
9 “排除”是指“排除”。 inode,用于快照(?)
10 副本 inode,用于非上游功能?
11 传统的第一个非保留 inode。通常这是丢失+找到的目录。请参阅超级块中的 s_first_ino。

— 内核文档

文档看起来和我们一样有信心。那些问题 那里有标记..非常棒。

无论如何,根(“/“)的 inode始终是2。 我们如何找到它? 同样,文档有帮助:

4.1.2.寻找索引节点

每个块组包含 sb->s_inodes_per_group inode。由于 inode 0 被定义为不存在,因此可以使用以下公式查找 inode 所在的块组:`bg = (inode_num - 1) / sb->s_inodes_per_group`。可以在块组的索引节点表中找到特定的索引节点,位置为 index = (inode_num - 1) % sb->s_inodes_per_group。要获取 inode 表中的字节地址,请使用 offset = index * sb->s_inode_size。

— 内核文档

这是有道理的!

既然我们使用的是一种好的编程语言,那么让我们开始构建一些抽象。

Shell session

1
2
3
4
$ cargo add [email protected]
Updating crates.io index
Adding custom_debug v0.5 to dependencies.

首先是超级块:

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// this is like derive(Debug), but better.
// see https://lib.rs/crates/custom_debug
use custom_debug::{Debug as CustomDebug};

#[derive(CustomDebug)]
struct Superblock {
#[debug(format = "{:x}")]
magic: u16,
block_size: u64,
blocks_per_group: u64,
inodes_per_group: u64,
inode_size: u64,
}

impl Superblock {
fn new(dev: &dyn ReadAt) -> Result<Self> {
let r = Reader::new(Slice::new(dev, 1024, None));
// note: we're casting a few fields to `u64` now.
// this will save us a bunch of grief later.
Ok(Self {
magic: r.u16(0x38)?,
block_size: (2u32.pow(10 + r.u32(0x18)?)) as u64,
blocks_per_group: r.u32(0x20)? as u64,
inodes_per_group: r.u32(0x28)? as u64,
inode_size: r.u16(0x58)? as u64,
})
}
}

让我们来试一下:

Rust code

1
2
3
4
5
6
7
8
9
10
11
fn main() -> Result<()> {
color_eyre::install()?;

// open our ext4 partition, READ-ONLY.
let file = OpenOptions::new().read(true).open("/dev/sda3")?;

let sb = Superblock::new(&file)?;
println!("{sb:#?}");

Ok(())
}

Shell session

1
2
3
4
5
6
7
8
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
Superblock {
magic: ef53,
block_size: 4096,
blocks_per_group: 32768,
inodes_per_group: 8192,
inode_size: 256,
}

看起来不错!

现在让我们为块组描述符创建一个类型。我们要去 将其用作常量的容器:块组的大小 描述符:

Rust code

1
2
3
4
5
6

struct BlockGroupDescriptor {}

impl BlockGroupDescriptor {
const SIZE: u64 = 64;
}

接下来,让我们为块组编号创建一个类型。当然,这只是 一个数字 - 但我们将在其上添加一个方法来获取描述符的一部分。

酷熊的热心贴士
具有单个字段的元组或结构称为newtype。

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[derive(Debug, Clone, Copy)]
struct BlockGroupNumber(u64);

impl BlockGroupNumber {
fn desc_slice<T>(self, sb: &Superblock, dev: T) -> Slice<T>
where
T: ReadAt,
{
assert!(sb.block_size != 1024, "1024 block size not supported");
// the superblock takes up 1 block
let gdt_start = sb.block_size;
let offset = gdt_start + self.0 * BlockGroupDescriptor::SIZE;
Slice::new(dev, offset, None)
}
}

接下来,让我们为 inode 编号创建一个类型!我们将添加一个方法 它告诉我们特定 inode 位于哪个块组中。

Rust code

1
2
3
4
5
6
7
8
9
#[derive(Debug, Clone, Copy)]
struct InodeNumber(u64);

impl InodeNumber {
fn blockgroup_number(self, sb: &Superblock) -> BlockGroupNumber {
let n = (self.0 - 1) / sb.inodes_per_group;
BlockGroupNumber(n)
}
}

让我们尝试一下这一切。我们正在寻找 inode 2:

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fn main() -> Result<()> {
color_eyre::install()?;

// open our ext4 partition, READ-ONLY.
let file = OpenOptions::new().read(true).open("/dev/sda3")?;

let sb = Superblock::new(&file)?;
println!("{sb:#?}");

let root_bg = InodeNumber(2).blockgroup_number(&sb);
dbg!(&root_bg);

let mut buf = vec![0u8; 64];
root_bg.desc_slice(&sb, &file).read_at(0, &mut buf)?;
println!("{:x}", buf.as_hex());

Ok(())
}

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
Superblock {
magic: ef53,
block_size: 4096,
blocks_per_group: 32768,
inodes_per_group: 8192,
inode_size: 256,
}
[src/main.rs:92] &root_bg = BlockGroupNumber(
0,
)
[47 4 0 0 57 4 0 0 67 4 0 0 a6 4d ee 1f 2 0 4 0 0 0 0 0 2b 7e e7 d ed 1f a4 83 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 a8 e1 c3 9c 0 0 0 0]

正如预期的那样,inode 2 位于块组 0 中,并且描述符好吧,切片并不全为零,所以这是一个开始。

块描述符中我们唯一关心的是 inode 表的偏移量。与大多数其他地点一样, 这是一个块号。

这个有点烦人,因为低 32 位是 存储在0x8,高32位存储在0x28。

我们可以直接在BlockGroupDescriptor中进行一些操作, 但让我们向 Reader 添加一个方便的方法:

Rust code

1
2
3
4
5
6
// in `impl Reader {`

fn u64_lohi(&self, lo: u64, hi: u64) -> Result<u64> {
Ok(self.u32(lo)? as u64 + ((self.u32(hi)? as u64) << 32))
}

让我们填写BlockGroupDescriptor:

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[derive(Debug)]
struct BlockGroupDescriptor {
inode_table: u64,
}

impl BlockGroupDescriptor {
const SIZE: u64 = 64;

fn new(slice: &dyn ReadAt) -> Result<Self> {
let r = Reader::new(slice);
Ok(Self {
inode_table: r.u64_lohi(0x8, 0x28)?,
})
}
}

现在,我们可以添加一个方法来直接抓取块组 BlockGroupNumber 的描述符:

Rust code

1
2
3
4
5
// in impl BlockGroupNumber
fn desc(self, sb: &Superblock, dev: &dyn ReadAt) -> Result<BlockGroupDescriptor> {
let slice = self.desc_slice(sb, dev);
BlockGroupDescriptor::new(&slice)
}

再次,让我们尝试一下:

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() -> Result<()> {
color_eyre::install()?;

// open our ext4 partition, READ-ONLY.
let file = OpenOptions::new().read(true).open("/dev/sda3")?;

let sb = Superblock::new(&file)?;
println!("{sb:#?}");

let bgd = InodeNumber(2).blockgroup_number(&sb).desc(&sb, &file)?;
println!("{bgd:#?}");

Ok(())
}

Shell session

1
2
3
4
5
6
7
8
9
10
11
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
Superblock {
magic: ef53,
block_size: 4096,
blocks_per_group: 32768,
inodes_per_group: 8192,
inode_size: 256,
}
BlockGroupDescriptor {
inode_table: 1127,
}

嘿,1127 看起来是一个相当合理的 inode 数字 第一个块组的表。

✨我们正在进步✨

现在,inode表是一系列固定大小的inode。

第一个字段是一个 16 位(little-endian,仍然)整数,其中包含文件模式。稍后,我们找到索引节点的大小,再次划分为低32位(位于 0x4)和高 32位(位于 0x6C)。

在0x28处,我们找到“块映射或范围树”。它是一个 60 字节的块。我 有一种感觉我们也需要它!

让我们为 reader 添加一个方便的方法,这样我们就可以轻松地获取该块:

Rust code
// in impl Reader

1
2
3
4
5
fn vec(&self, offset: u64, len: usize) -> Result<Vec<u8>> {
let mut v = vec![0u8; len];
self.inner.read_exact_at(offset, &mut v)?;
Ok(v)
}

接下来,我们添加一个方法来获取特定 inode 编号的切片。

Rust code

1
2
3
4
5
6
7
8
9
10
11
12

// in impl InodeNumber
fn inode_slice<T>(self, sb: &Superblock, dev: T) -> Result<Slice<T>>
where
T: ReadAt,
{
let desc = self.blockgroup_number(sb).desc(sb, &dev)?;
let table_off = desc.inode_table * sb.block_size;
let idx_in_table = (self.0 - 1) % sb.inodes_per_group;
let inode_off = table_off + sb.inode_size * idx_in_table;
Ok(Slice::new(dev, inode_off, Some(sb.inode_size)))
}

现在,让我们创建一个 Inode 类型,其中包含我们想要的所有数据:模式、大小、 和 60 字节块。我们将使用我们最近添加的 Reader::vec:

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#[derive(CustomDebug)]
struct Inode {
#[debug(format = "{:o}")]
mode: u16,
size: u64,

#[debug(skip)]
block: Vec<u8>,
}

impl Inode {
fn new(slice: &dyn ReadAt) -> Result<Self> {
let r = Reader::new(slice);
Ok(Self {
mode: r.u16(0x0)?,
size: r.u64_lohi(0x4, 0x6C)?,
block: r.vec(0x28, 60)?,
})
}
}

最后,让我们向 InodeNumber 添加一个方法,直接读取 索引节点:

Rust code

1
2
3
4
fn inode(self, sb: &Superblock, dev: &dyn ReadAt) -> Result<Inode> {
let slice = self.inode_slice(sb, dev)?;
Inode::new(&slice)
}
// in impl InodeNumber

我们准备好了。让我们尝试一下:

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn main() -> Result<()> {
color_eyre::install()?;

// open our ext4 partition, READ-ONLY.
let file = OpenOptions::new().read(true).open("/dev/sda3")?;

let sb = Superblock::new(&file)?;
println!("{sb:#?}");

let root_inode = InodeNumber(2).inode(&sb, &file)?;
println!("{root_inode:#?}");

Ok(())
}

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
Superblock {
magic: ef53,
block_size: 4096,
blocks_per_group: 32768,
inodes_per_group: 8192,
inode_size: 256,
}
Inode {
mode: 40755,
size: 4096,
}

万岁!这些值与 stat 命令报告的内容匹配:

Shell session

1
2
3
4
5
6
7
8
9
$ stat /
File: /
Size: 4096 Blocks: 8 IO Block: 4096 directory
Device: 803h/2051d Inode: 2 Links: 23
Access: (0755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2023-03-11 12:40:08.175908741 +0100
Modify: 2022-12-26 15:42:11.483999585 +0100
Change: 2022-12-26 15:42:11.483999585 +0100
Birth: 2022-10-01 21:18:48.000000000 +0200

我确信有很多目录具有相同的权限和大小,所以我们 可能读取了错误的索引节点,但至少我们可能没有读取, 比如说,一个随机数据块。

让我们快速添加一个 Filetype 枚举,这样我们就可以确保它是一个目录。 文件类型包含在模式中:

这些是互斥的文件类型

1
2
3
4
5
6
7
8
0x1000	S_IFIFO(先进先出)
0x2000 S_IFCHR(字符设备)
0x4000 S_IFDIR(目录)
0x6000 S_IFBLK(块设备)
0x8000 S_IFREG(常规文件)
0xA000 S_IFLNK(符号链接)
0xC000 S_IFSOCK(套接字)
— 内核文档

我们将在此处使用num_enum crate:

Shell session

1
2
3
4
5
6
7
8
9
$ cargo add [email protected]
Updating crates.io index
Adding num_enum v0.5 to dependencies.
Features as of v0.5.0:
+ std
- complex-expressions
- external_doc
Updating crates.io index

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use num_enum::*;
use std::convert::TryFrom;

#[derive(Debug, TryFromPrimitive)]
#[repr(u16)]
enum Filetype {
Fifo = 0x1000,
CharacterDevice = 0x2000,
Directory = 0x4000,
BlockDevice = 0x6000,
Regular = 0x8000,
SymbolicLink = 0xA000,
Socket = 0xC000,
}

并添加 getter Inode:

Rust code

1
2
3
4
// in impl Inode
fn filetype(&self) -> Filetype {
Filetype::try_from(self.mode & 0xF000).unwrap()
}
酷熊的热心贴士
为了保持示例代码简洁,我们在这里使用unwrap()。

如果我们以某种方式从错误的片中读取了一个 inode,或者如果该 inode 如果模式无效,我们的程序就会出现恐慌。

这在实际的文件系统实现中是不可取的,但是 在这里它可以作为一个很好的健全性检查。

让我们试一试:

Rust code

1
2
3
4
// in main
let root_inode = InodeNumber(2).inode(&sb, &file)?;
let root_inode_type = root_inode.filetype();
println!("({root_inode_type:?}) {root_inode:#?}");

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
Superblock {
magic: ef53,
block_size: 4096,
blocks_per_group: 32768,
inodes_per_group: 8192,
inode_size: 256,
}
(Directory) Inode {
mode: 40755,
size: 4096,
}

一切看起来都很好!

现在我们确定我们正在处理一个目录,我们可以开始 阅读其条目。

但首先 - 记得范围吗?这些就在我们刚刚读到的区块中!

内核文档可以为您提供帮助。

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#[derive(Debug)]
struct ExtentHeader {
entries: u64,
depth: u64,
}

impl ExtentHeader {
fn new(slice: &dyn ReadAt) -> Result<Self> {
let r = Reader::new(slice);
let magic = r.u16(0x0)?;
assert_eq!(magic, 0xF30A);

Ok(Self {
entries: r.u16(0x2)? as u64,
depth: r.u16(0x6)? as u64,
})
}
}

我们来读一篇。

Rust code

1
2
3
4
// in main
let ext_header = ExtentHeader::new(&Slice::new(&root_inode.block, 0, Some(12)))?;
println!("{ext_header:#?}");

Shell session

1
2
3
4
5
6
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
(cut)
ExtentHeader {
entries: 1,
depth: 0,
}

嗯,虽然不多,但同样,这些值是有意义的。已被关注 通过单个条目,因此我们知道 / 的数据存储在单个范围中 连续的块。它的深度为0,这意味着它是一个叶节点。

酷熊的热心贴士
在我们的例子中,我们假设 /、/etc 和 /etc/hosts 都只有 一个范围节点。

这是有道理的,因为/只有 24 个孩子,只有 204 个孩子 对于 /etc,并且 /etc/hosts 比块 (4096) 短 (511)。

但是,我们将使用 assert! 验证我们的假设。

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#[derive(Debug)]
struct Extent {
len: u64,
start: u64,
}

impl Extent {
fn new(slice: &dyn ReadAt) -> Result<Self> {
let r = Reader::new(slice);
Ok(Self {
len: r.u16(0x4)? as u64,
// the block number the extent points to is split
// between upper 16-bits and lower 32-bits.
start: ((r.u16(0x6)? as u64) << 32) + r.u32(0x8)? as u64,
})
}
}

我们完整的范围读取代码变为:

Rust code

1
2
3
4
5
6
7
8
9
// in main, after finding root_inode
let ext_header = ExtentHeader::new(&Slice::new(&root_inode.block, 0, Some(12)))?;
println!("{ext_header:#?}");

assert_eq!(ext_header.depth, 0);
assert_eq!(ext_header.entries, 1);

let ext = Extent::new(&Slice::new(&root_inode.block, 12, Some(12)))?;
println!("{:#?}", ext);

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
(Directory) Inode {
mode: 40755,
size: 4096,
}
ExtentHeader {
entries: 1,
depth: 0,
}
Extent {
len: 1,
start: 9319,
}

嘿,这些仍然是合理的值!

据此可以找到/区块9319的数据。

让我们花点时间向 Inode 类型添加一个便捷方法:

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// in impl Inode
fn data<T>(&self, sb: &Superblock, dev: T) -> Result<Slice<T>>
where
T: ReadAt,
{
let ext_header = ExtentHeader::new(&Slice::new(&self.block, 0, Some(12)))?;
assert_eq!(ext_header.depth, 0);
assert_eq!(ext_header.entries, 1);

let ext = Extent::new(&Slice::new(&self.block, 12, Some(12)))?;
assert_eq!(ext.len, 1);

let offset = ext.start * sb.block_size;
let len = ext.len * sb.block_size;
Ok(Slice::new(dev, offset, Some(len)))
}

然后转储/数据的前 128 个字节,就好像它是一个字符串一样:

Rust code

1
2
3
4
5
6
7
8
9
// in main
let root_inode = InodeNumber(2).inode(&sb, &file)?;
let root_inode_type = root_inode.filetype();
println!("({root_inode_type:?}) {root_inode:#?}");

let root_data = root_inode.data(&sb, &file)?;
let data_start = Reader::new(&root_data).vec(0, 128)?;
println!("{}", String::from_utf8_lossy(&data_start));

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ $ cargo b -q && sudo ./target/debug/read-raw-ext4 
(Directory) Inode {
mode: 40755,
size: 4096,
}

.
..

lost+found(
boot
swapfile�
etc�media�
var
bin@


现在,我不了解你,但这看起来非常令我兴奋。

我们可以在其中看到一些顶级目录名称:lost+found、boot、 swapfile — 甚至 . 和 ..! (当前目录和父目录, 分别)。

让我们正确地阅读它们:目录项格式实际上很漂亮 直截了当。

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

#[derive(CustomDebug)]
struct DirectoryEntry {
#[debug(skip)]
len: u64,
inode: InodeNumber,
name: String,
}

impl DirectoryEntry {
fn new(slice: &dyn ReadAt) -> Result<Self> {
let r = Reader::new(slice);
// adding `Reader::u8` is left as an exercise to the reader
let name_len = r.u8(0x6)? as usize;
Ok(Self {
inode: InodeNumber(r.u32(0x0)? as u64),
len: r.u16(0x4)? as u64,
name: String::from_utf8_lossy(&r.vec(0x8, name_len)?).into(),
})
}
}

我们只需向 Inode 添加一个方法,该方法返回 DirectoryEntry 的 vec。 这并不适合所有用例,但它适合我们的用例!

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// in impl Inode
fn dir_entries(&self, sb: &Superblock, dev: &dyn ReadAt) -> Result<Vec<DirectoryEntry>> {
let data = self.data(sb, dev)?;

let mut entries = Vec::new();
let mut offset: u64 = 0;
loop {
let entry = DirectoryEntry::new(&Slice::new(&data, offset, None))?;
if entry.inode.0 == 0 {
break;
}
offset += entry.len;
entries.push(entry);
}
Ok(entries)
}

现在到了关键时刻:

Rust code

1
2
3
4
5
6
7
8
// in main:
let root_inode = InodeNumber(2).inode(&sb, &file)?;
let root_inode_type = root_inode.filetype();
println!("({root_inode_type:?}) {root_inode:#?}");

let root_entries = root_inode.dir_entries(&sb, &file)?;
println!("{root_entries:#?}");

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

$ cargo b -q && sudo ./target/debug/read-raw-ext4
(Directory) Inode {
mode: 40755,
size: 4096,
}
[
DirectoryEntry {
inode: InodeNumber(
2,
),
name: ".",
},
DirectoryEntry {
inode: InodeNumber(
2,
),
name: "..",
},
DirectoryEntry {
inode: InodeNumber(
11,
),
name: "lost+found",
},
DirectoryEntry {
inode: InodeNumber(
19398657,
),
name: "boot",
},
DirectoryEntry {
inode: InodeNumber(
12,
),
name: "swapfile",
},
DirectoryEntry {
inode: InodeNumber(
16252929,
),
name: "etc",
},
(cut)
🎉🎉🎉

一切似乎都井然有序。

让我们添加一个方便的方法来查找特定的孩子:

Rust code

1
2
3
4
5
6
7
8
9
10

// in impl Inode
fn child(&self, name: &str, sb: &Superblock, dev: &dyn ReadAt) -> Result<Option<InodeNumber>> {
let entries = self.dir_entries(sb, dev)?;
Ok(entries
.into_iter()
.filter(|x| x.name == name)
.map(|x| x.inode)
.next())
}
酷熊的热心贴士
为什么返回类型是Result<Option<T>>?

嗯,从分区读取可能会失败 - 并且会返回Err(e)。

从分区读取可能会成功,但可能没有子分区 用这个名字。这将返回 Ok(None)。

最后,阅读可能会成功,我们可能会找到这样一个孩子,并且 这将返回 Ok(Some(n))。

让我们使用它:

Rust code

1
2
3
4
5
6
// in main
let root_inode = InodeNumber(2).inode(&sb, &file)?;
println!("({:?}) {:#?}", root_inode.filetype(), root_inode);

let etc_inode = root_inode.child("etc", &sb, &file)?;
println!("{etc_inode:?}");

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
(Directory) Inode {
mode: 40755,
size: 4096,
}
Some(InodeNumber(16252929))

$ stat /etc
File: /etc
Size: 12288 Blocks: 24 IO Block: 4096 directory
Device: 803h/2051d Inode: 16252929 Links: 146
Access: (0755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2023-03-11 19:51:34.661133912 +0100
Modify: 2023-03-11 19:47:58.858792961 +0100
Change: 2023-03-11 19:47:58.858792961 +0100
Birth: 2022-10-01 21:18:52.867725869 +0200

一切都检查完毕。

让我们更进一步,读取该索引节点:

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
// in main
let root_inode = InodeNumber(2).inode(&sb, &file)?;
let root_inode_type = root_inode.filetype();
println!("({root_inode_type:?}) {root_inode:#?}");

let etc_inode = root_inode
.child("etc", &sb, &file)?
.expect("/etc should exist")
.inode(&sb, &file)?;
let etc_inode_type = etc_inode.filetype();
println!("({etc_inode_type:?}) {etc_inode:#?}");

Shell session

1
2
3
4
5
6
7
8
9
10
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
(Directory) Inode {
mode: 40755,
size: 4096,
}
(Directory) Inode {
mode: 40755,
size: 12288,
}

还是不错的。我们能在那里找到hosts吗?

Rust code

1
2
3
4
5
6
7
8
// in main
let hosts_inode = etc_inode
.child("hosts", &sb, &file)?
.expect("/etc/hosts should exist")
.inode(&sb, &file)?;
let hosts_inode_type = hosts_inode.filetype();
println!("({hosts_inode_type:?}) {hosts_inode:#?}");

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ cargo b -q && sudo ./target/debug/read-raw-ext4 
(Directory) Inode {
mode: 40755,
size: 4096,
}
(Directory) Inode {
mode: 40755,
size: 12288,
}
thread 'main' panicked at 'assertion failed: `(left == right)`
left: `2`,
right: `1`', src/main.rs:110:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

啊!

看,当我最初写这个系列时,早在 2019 年,就在安装在Lenovo X200上的ArchLinux系统(我知道对吧?),这个曾经可以工作。

但现在不再这样了,因为我们的范围标头中有两个条目, 不只是一个。

所以,没关系,我们可以稍微调整一下我们的代码!

首先,我们添加一个 Inode::data_count 返回数据数量的方法 与 inode 关联的块:

Rust code

1
2
3
4
5
6
7
8
// in impl Inode

fn data_count(&self) -> Result<u64> {
let ext_header = ExtentHeader::new(&Slice::new(&self.block, 0, Some(12)))?;
assert_eq!(ext_header.depth, 0);
Ok(ext_header.entries)
}

我们将更改 data 函数以获取 index,这样它就可以检索任何 数据块。

Rust code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// in impl Inode

// new! this method takes an index parameter 👇
fn data<T>(&self, sb: &Superblock, dev: T, index: u64) -> Result<Slice<T>>
where
T: ReadAt,
{
let ext_header = ExtentHeader::new(&Slice::new(&self.block, 0, Some(12)))?;
assert_eq!(ext_header.depth, 0);
// 👇 new: (previously was asserting only 1 entry, now checking index)
assert!(index < ext_header.entries);

// 👇 new: the offset depends of the index
let offset = 12 * (index + 1);
let ext = Extent::new(&Slice::new(&self.block, offset, Some(12)))?;
assert_eq!(ext.len, 1);

let offset = ext.start * sb.block_size;
let len = ext.len * sb.block_size;
Ok(Slice::new(dev, offset, Some(len)))
}

现在,在 dir_entries 中,我们调用 self.data,我们实际上必须传递一个 指数!另外,我们的结束条件(找到 inode 编号为 0 的条目)仅有效 对于最后数据块。对于之前的那些,我们必须在到达时停下来 范围的末尾。

Rust code
// in impl Inode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fn dir_entries(&self, sb: &Superblock, dev: &dyn ReadAt) -> Result<Vec<DirectoryEntry>> {
let mut entries = Vec::new();

for block_index in 0..self.data_count()? {
let data = self.data(sb, dev, block_index)?;
let data_size = data.size()?.unwrap();
let mut offset: u64 = 0;
while offset < data_size {
let entry = DirectoryEntry::new(&Slice::new(&data, offset, None))?;
if entry.inode.0 == 0 {
break;
}
offset += entry.len;
entries.push(entry);
}
}

Ok(entries)
}

现在…

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14

$ cargo b -q && sudo ./target/debug/read-raw-ext4
(Directory) Inode {
mode: 40755,
size: 4096,
}
(Directory) Inode {
mode: 40755,
size: 12288,
}
(Regular) Inode {
mode: 100644,
size: 220,
}

找到/etc/hosts!

兴奋加剧

我们可以将它作为字符串读取吗?

Rust code
// in main

1
2
3
4
5
6

let hosts_data = hosts_inode.data(&sb, &file, 0)?;
let hosts_data = Reader::new(&hosts_data).vec(0, hosts_inode.size as usize)?;
let hosts_data = String::from_utf8_lossy(&hosts_data);
println!("---------------------------------------------");
println!("{hosts_data}");

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

$ cargo b -q && sudo ./target/debug/read-raw-ext4
(Directory) Inode {
mode: 40755,
size: 4096,
}
(Directory) Inode {
mode: 40755,
size: 12288,
}
(Regular) Inode {
mode: 100644,
size: 220,
}
---------------------------------------------
127.0.0.1 localhost
127.0.1.1 sonic

# The following lines are desirable for IPv6 capable hosts
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters

我们做到了!