用 Rust 编写的微型 `ls` 克隆版
在我这个用 Rust 重写无用 Unix 工具的系列中,今天我要介绍的是我一直以来最喜欢的工具之一:ls。
首先要说明的是,你可能并不想在本地机器上用这段代码替代 ls(当然,你也可以这么做!)。正如我们将会看到的,ls 实际上是一个底层功能相当强大的工具。我不会尝试完整重写它,而只介绍在命令行调用 ls -l 时你所期望看到的最基本输出。这个输出是什么?很高兴你这么问。
预期输出
> ls -l
drwxr-xr-x 2 mendler staff 13468 Feb 4 11:19 Top Secret
-rwxr--r-- 1 mendler staff 6323935 Mar 8 21:56 Never Gonna Give You Up - Rick Astley.mp3
-rw-r--r-- 1 mendler staff 0 Feb 18 23:55 Thoughts on Chess Boxing.doc
-rw-r--r-- 1 mendler staff 380434 Dec 24 16:00 nobel-prize-speech.txt你的输出可能有所不同,但通常来说,其中有几件值得注意的事情。从左到右,我们有以下字段:
- 开头的
drwx等内容是文件权限(也称为文件模式)。如果设置了d,表示它是一个目录。r表示读取,w表示写入,x表示执行。这个rwx模式会分别针对当前用户、用户组和其他计算机用户重复三次。 - 接下来,如果对象是文件,这里表示硬链接数量;如果对象是目录,则表示其中包含的目录项数量。(参考资料)
- 所有者名称
- 用户组名称
- 文件中的字节数
- 文件最后修改的日期
- 最后是路径名
如果想了解更深入的信息,我推荐阅读大多数 Linux 发行版所使用的 GNU coreutils 中 ls 的手册页,以及 Darwin(MacOS 的基础系统)中的手册页。
呼,对于这样一个小工具来说,这信息量还真不少。不过话说回来,把它移植到 Rust 上应该没那么难,对吧?我们开始吧!
用 Rust 编写一个非常基础的 ls
下面是最简陋版本的 ls,它只会打印当前目录中的所有文件:
use std::fs;
use std::path::Path;
use std::error::Error;
use std::process;
fn main() {
if let Err(ref e) = run(Path::new(".")) {
println!("{}", e);
process::exit(1);
}
}
fn run(dir: &Path) -> Result<(), Box<Error>> {
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let file_name = entry
.file_name()
.into_string()
.or_else(|f| Err(format!("Invalid entry: {:?}", f)))?;
println!("{}", file_name);
}
}
Ok(())
}我们可以直接从文档中复制这段代码。运行它时,我们会得到预期输出:
> cargo run
Cargo.lock
Cargo.toml
src
target它打印文件,然后退出。足够简单。
我们应该暂时停下来庆祝一下自己的成功:毕竟,我们刚刚从零开始写出了第一个小型 Unix 工具。小贴士:你可以使用 cargo install 安装这个二进制文件,之后就能像调用其他二进制文件一样调用它。
不过我们的目标更高,所以继续吧。
添加用于指定目录的参数
通常,如果我们输入 ls mydir,我们希望得到的只是 mydir 目录的文件列表,而不是其他目录的列表。我们的版本也应该添加同样的功能。
为此,我们需要接受命令行参数。在这种情况下,我很喜欢使用的一个 Rust crate 是 structopt。它让参数解析变得非常简单。
将它添加到你的 Cargo.toml 中。(接下来的命令需要 cargo-edit。)
cargo add structopt现在我们可以导入它并在项目中使用:
#[macro_use]
extern crate structopt;
// use std::...
use structopt::StructOpt;
#[derive(StructOpt, Debug)]
struct Opt {
/// Output file
#[structopt(default_value = ".", parse(from_os_str))]
path: PathBuf,
}
fn main() {
let opt = Opt::from_args();
if let Err(ref e) = run(&opt.path) {
println!("{}", e);
process::exit(1);
}
}
fn run(dir: &PathBuf) -> Result<(), Box<Error>> {
// Same as before
}通过添加 Opt 结构体,我们可以非常轻松地定义命令行标志、输入参数和 help 输出。它有大量配置选项,值得查看项目主页。
还要注意,我们把 path 变量的类型从 Path 改成了 PathBuf。区别在于,PathBuf 拥有内部路径字符串,而 Path 只是提供对它的引用。这两者的关系类似于 String 和 &str。
读取修改时间
现在来处理元数据。首先,我们尝试从文件中获取修改时间。快速查看文档就能知道该怎么做:
use std::fs;
let metadata = fs::metadata("foo.txt")?;
if let Ok(time) = metadata.modified() {
println!("{:?}", time);
}输出可能不是你所期待的样子:我们得到的是一个 SystemTime 对象,它表示对系统时钟的测量结果。例如,这段代码
println!("{:?}", SystemTime::now());
// Prints: SystemTime { tv_sec: 1520554933, tv_nsec: 610406401 }但我们想要的格式大概是这样:
Mar 9 01:24幸好,有一个名为 chrono 的库,可以读取这种格式,并将其转换为我们想要的任何人类可读输出:
let current: DateTime<Local> = DateTime::from(SystemTime::now());
println!("{}", current.format("%_d %b %H:%M").to_string());这会打印:
9 Mar 01:29(是的,我知道已经很晚了。)
掌握这些知识后,我们现在就可以读取文件的修改时间了。
cargo add chronouse chrono::{DateTime, Local};
fn run(dir: &PathBuf) -> Result<(), Box<Error>> {
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let file_name = ...
let metadata = entry.metadata()?;
let size = metadata.len();
let modified: DateTime<Local> = DateTime::from(metadata.modified()?);
println!(
"{:>5} {} {}",
size,
modified.format("%_d %b %H:%M").to_string(),
file_name
);
}
}
Ok(())
}这个 {:>5} 看起来可能有点奇怪。这是 std::fmt 提供的格式化指令。它的意思是“用空格填充 5 个字符,并将此字段右对齐”——就像我们的老大哥 ls -l 所做的那样。
类似地,我们通过 metadata.len() 获取了以字节为单位的大小。
Unix 文件权限是个大杂烩
读取文件权限要稍微复杂一些。虽然 rwx 表示法在 *BSD 或 GNU/Linux 等 Unix 衍生系统中非常常见,但许多其他操作系统都有自己的权限管理方式。即使是不同的 Unix 衍生系统之间也存在差异。
Wikipedia 列出了几种你可能会遇到的文件权限扩展:
- 后缀 +(加号)表示存在一个访问控制列表,它可以控制额外的权限。
- 后缀 .(点号)表示存在 SELinux 上下文。可以使用命令 ls -Z 列出详细信息。
- 后缀 @ 表示存在扩展文件属性。
这足以说明,在实际实现时需要考虑许多重要细节。
实现非常基础的文件模式
现在,我们先坚持基础做法,并假设自己使用的是支持 rwx 文件模式的平台。
r、w 和 x 背后实际上是八进制数字。计算机更容易处理这些数字,许多硬核用户甚至更喜欢输入数字而不是符号。这些八进制数背后的规则如下。我从 chmod 手册页中摘录了这段内容。
Modes may be absolute or symbolic.
An absolute mode is an octal number constructed
from the sum of one or more of the following values
0400 Allow read by owner.
0200 Allow write by owner.
0100 For files, allow execution by owner.
0040 Allow read by group members.
0020 Allow write by group members.
0010 For files, allow execution by group members.
0004 Allow read by others.
0002 Allow write by others.
0001 For files, allow execution by others.例如,要设置一个文件的权限,使所有者可以读取、写入和执行该文件,而其他人什么也不能做,可以使用 700(400 + 200 + 100)。
诚然,这些数字从 70 年代起就没有变化,近期也不会改变,但直接将文件权限与这些数值进行比较仍然不是好主意;即使不考虑兼容性,也应该出于可读性以及避免在代码中使用魔法数字的原因而避免这样做。
因此,我们使用 libc crate,它为这些魔法数字提供了常量。如上所述,这些文件权限是 Unix 特有的,所以我们需要为此导入一个仅限 Unix 的库,名为 std::os::unix::fs::PermissionsExt;
extern crate libc;
// Examples:
// * `S_IRGRP` stands for "read permission for group",
// * `S_IXUSR` stands for "execution permission for user"
use libc::{S_IRGRP, S_IROTH, S_IRUSR, S_IWGRP, S_IWOTH, S_IWUSR, S_IXGRP, S_IXOTH, S_IXUSR};
use std::os::unix::fs::PermissionsExt;现在我们可以这样获取文件权限:
let metadata = entry.metadata()?;
let mode = metadata.permissions().mode();
parse_permissions(mode as u16);parse_permissions() 是一个辅助函数,定义如下:
fn parse_permissions(mode: u16) -> String {
let user = triplet(mode, S_IRUSR, S_IWUSR, S_IXUSR);
let group = triplet(mode, S_IRGRP, S_IWGRP, S_IXGRP);
let other = triplet(mode, S_IROTH, S_IWOTH, S_IXOTH);
[user, group, other].join("")
}它将文件模式作为 u16 传入(单纯因为 libc 常量是 u16),然后对它调用 triplet。对于 read、write 和 execute 每个标志,它都会对 mode 执行二进制 & 运算。运算结果会与所有可能的权限模式进行穷举匹配。
fn triplet(mode: u16, read: u16, write: u16, execute: u16) -> String {
match (mode & read, mode & write, mode & execute) {
(0, 0, 0) => "---",
(_, 0, 0) => "r--",
(0, _, 0) => "-w-",
(0, 0, _) => "--x",
(_, 0, _) => "r-x",
(_, _, 0) => "rw-",
(0, _, _) => "-wx",
(_, _, _) => "rwx",
}.to_string()
}总结
最终输出如下。已经足够接近了。
> cargo run
rw-r--r-- 7 6 Mar 23:10 .gitignore
rw-r--r-- 15618 8 Mar 00:41 Cargo.lock
rw-r--r-- 185 8 Mar 00:41 Cargo.toml
rwxr-xr-x 102 5 Mar 21:31 src
rwxr-xr-x 136 6 Mar 23:07 target就是这样!你可以在 Github 上找到我们的玩具版 ls 的最终版本。距离成为功能完整的 ls 替代品,我们还差得很远,但至少我们对它的内部原理有了一两点了解。
如果你正在寻找一个用 Rust 编写的真正的 ls 替代品,可以去看看 lsd。如果你想阅读这个系列的另一篇博文,可以看看关于 yes Unix 命令的一则小故事。
随机一篇博客