A Tiny `ls` Clone Written in Rust

Matthias Endler

用 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 字串是檔案權限(也稱為 file mode)。如果有 d,就代表是目錄。r 代表讀取(read)、w 代表寫入(write),x 則是執行(execute)。這組 rwx 樣式會重複三次,分別對應目前的使用者、群組,以及其他使用者。
  • 接下來是 hardlink 數量(如果是檔案),或是目錄內包含的項目數量(如果是目錄)。(參考資料)
  • 擁有者名稱
  • 群組名稱
  • 檔案的位元組大小
  • 檔案最後修改日期
  • 最後則是路徑名稱

如果想了解更深入的資訊,建議可以閱讀大多數 Linux 發行版所使用的 GNU coreutils 中的 ls 說明文件(manpage),以及驅動 MacOS 的 Darwin 版本。

呼,這樣一個小工具竟然有這麼多資訊。不過話說回來,把它移植到 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 工具。Pro Tip:你可以用 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 這個 struct,我們就能超輕鬆地定義命令列旗標、輸入參數和 help 輸出。它有非常多設定選項,很值得去專案首頁看看。

另外請注意,我們把路徑變數的型別從 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 衍生系統之間也存在差異。

Wikipedia 列出了幾個你可能會遇到的檔案權限擴充:

這正顯示出,在實際實作時有許多重要的細節需要考量。

實作最基本的 file mode

目前,我們就先專注在最基本的部份,並假設我們是在一個支援 rwx 檔案模式的平台上。

rwx 的背後,其實是八進位數字。這對電腦來說比較好處理,許多重度使用者甚至更喜歡直接輸入數字而非符號。這些八進位數字背後的規則如下,我是從 chmod 的 manpage 節錄來的。

	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。

因此,我們使用提供這些 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

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

留言