用 Rust 编写的迷你 ls 克隆
原文由 Matthias Endler 于 发布,订阅该博客
在我的用 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组合会分别针对当前用户、所属组和其他用户重复出现三次。 - 接下来是硬链接数(针对文件而言),或包含的目录项数量(针对目录而言)。(参考)
- 属主名
- 属组名
- 文件的字节数
- 文件最后修改时间
- 最后是路径名
想了解更深入的信息,推荐阅读 ls 的手册页,可以看大多数 Linux 发行版所使用的 GNU coreutils 版本,以及 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 改成了 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 系统之间也存在差异。
维基百科列出了你可能会遇到的几种文件权限扩展:
- +(加号)后缀表示存在可控制额外权限的访问控制列表。
- .(点号)后缀表示存在 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 类型的 file mode(之所以用 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 命令的小故事。
随机一篇博客
评论
登录后参与讨论