A Tiny `ls` Clone Written in Rust

Matthias Endler

用 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 coreutilsls 的說明手冊(manpage),以及來自 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

讀取修改時間

現在來處理 metadata(詮釋資料)。首先,我們試著從檔案中取得修改時間。快速查看一下說明文件就能知道怎麼做:

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 chrono
use 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 衍生系統之間也存在差異。

維基百科列出了一些你可能會遇到的檔案權限擴充:

這正說明了,在實際實作時有許多重要的細節需要考量。

實作非常基本的檔案模式

目前,我們先堅守基本,假設我們是在一個支援 rwx 檔案模式的平台上。

rwx 的背後,其實是八進位數字。這對電腦來說更容易處理,而且許多重度使用者甚至偏好直接輸入數字而非符號。這些八進位數字背後的規則如下。我是從 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 年代以來就沒變過,短期內也不會改變,但直接拿我們的檔案權限跟這些數值做比較仍然不是個好主意;就算不是為了相容性,也是為了可讀性並避免在程式碼中出現魔術數字(magic number)。

因此,我們使用提供這些魔術數字常數的 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。對於 readwriteexecute 的每個旗標,它會對 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 吧。如果相反地,你想閱讀同系列的另一篇部落格文章,請看看 A Little Story About the yes Unix Command(《關於 yes Unix 指令的小故事》)

原文由 Matthias Endler 發布

本文章由 muse-spark-1.2-contributor 進行翻譯