Unix 的 history 指令是如何運作的?

隨著一天即將結束,我有整整一個小時完全屬於自己。正是聽點 Billie Joel(比利·喬爾)(我最近不是聽比利·喬爾就是聽 Billie Eilish(怪奇比莉))並了解 Unix history 指令如何運作的好時機。人生真美好。
探究 Unix 的運作原理算是我的一種嗜好。
我之前已經寫過 yes、ls 和 cat。別笑我。
history 到底是怎麼運作的?
每個指令都會被記錄下來,所以當我在電腦上執行 history 時,就能看到最近的幾筆指令。
❯❯❯ history
8680 cd endler.dev
8682 cd content/2021
8683 mkdir history
8684 cd history
8685 vim index.md但是,它到底是怎麼辦到的?
我 Mac 上的 manpage 並沒有太大幫助——而且一開始我也找不到什麼資料。
我找到了這篇文章(現在先提醒你這是 Medium 連結也算是種禮貌),它描述了一些背後的運作方式。
每個指令都會被儲存在 $HISTFILE 中,對我來說它指向 ~/.zsh_history。
❯❯❯ tail $HISTFILE
: 1586007759:0;cd endler.dev
: 1586007763:0;cd content/2021
: 1586007771:0;mkdir history
: 1586007772:0;cd history
: 1586007777:0;vim index.md
...來看看吧。我們有一個 :,接著是時間戳記,再接著是 :0,然後是分隔符號(;),最後才是真正的指令。每個新指令都會被附加到檔案結尾。要重現並不難。
等等,那個 0 是怎麼回事!?
原來它是指令執行時間,而整個格式被稱為 extended history format(擴充歷史格式):
: <beginning time>:<elapsed seconds>;<command>(取決於你的設定,你的檔案看起來可能會有所不同。)
掛入 history
但話說回來,history 到底是怎麼運作的。
💥 Swoooooosh 💥
來自未來的 Matthias(馬蒂亞斯)從一道刺眼的光球中踏出:等等!事情才不是那樣運作的!
原來像 bash 和 zsh 這類 shell 根本不會為了 history 去呼叫鉤子。何必呢?既然 history 本身就是 shell 內建指令,它們完全可以在內部直接追蹤指令就好。
幸好我的總編輯兼駐站 Unix 老頑固 Simon Brüggen(西蒙·布呂根)向我解釋了這一點——不過是在我把這篇文章的初稿寄給他之後。😓
因此,接下來的這一節有點像 Lord of the Rings(《魔戒》):一個惹人同情卻天真的傢伙,踏上一場前景堪憂的任務,卻對自己即將面對什麼毫無頭緒。
替自己辯解一下,《魔戒》之所以受人喜愛,主要是因為它的娛樂價值,而不是它的歷史準確性……而就像這部史詩故事一樣,我保證我們最終會把事情弄個水落石出。
我找到了 add-zsh-hook,以及在 atuin 的原始碼中的一個使用範例。
我可能無法完全理解那裡寫的所有內容,但我是個行動派,我可以把一件紮實的作品拆解開來。
東西不多,但這就是我的成果:
# Source this in your ~/.zshrc
autoload -U add-zsh-hook
_past_preexec(){
echo "preexec"
}
_past_precmd(){
echo "precmd"
}
add-zsh-hook preexec _past_preexec
add-zsh-hook precmd _past_precmd這會設定兩個鉤子:第一個在指令執行前馬上被呼叫,第二個則在執行後立刻被呼叫。(我決定把我的小 history 替代品叫做 past。我喜歡簡短的名字。)
好,來讓 zsh 在我們每次執行指令時都完整執行這個檔案:
source src/shell/past.zsh……然後
❯❯❯ date
preexec
Fri May 28 18:53:55 CEST 2021
precmd成功了!✨ 太讓人興奮了! ✨
其實,我現在才想起來,兩年多前我就為了我的小型環境設定管理工具 envy 做過同樣的事,但管他的!
那麼,有了新獲得的能力要做什麼呢?
來跑一些 Rust 程式碼
重點是:只有 preexec 能拿到「真正的」指令。precmd 什麼也拿不到:
_past_preexec(){
echo "preexec $@"
}
_past_precmd(){
echo "precmd $@"
}$@ 的意思是「把你有的都給我看看」,而這就是它得到的結果:
❯❯❯ date
preexec date date date
Fri May 28 19:02:11 CEST 2021
precmd難道一個「date」還不夠嗎?
嗯……讓我們來看看 zsh 關於 preexec 的文件:
如果歷史機制處於啟用狀態 […],使用者輸入的字串會作為第一個引數傳入,否則為空字串。實際將被執行的指令(包含已展開的別名)會以兩種不同形式傳入:第二個引數是該指令的單行、長度受限版本(例如函式主體等內容會被省略);第三個引數則包含正在執行的完整文字。
不知道你怎麼想,但第三個引數應該就是我們需要的一切了吧?🤨
來驗證一下……
❯❯❯ ls -l
preexec ls -l lsd -l lsd -l(向 lsd 致敬,這是下一代的 ls 指令)
好,這樣就夠了。讓我們用一些 Rust 程式碼來解析 $3,並把它寫入我們自己的歷史檔。
use std::env;
use std::error::Error;
use std::fs::OpenOptions;
use std::io::Write;
const HISTORY_FILE: &str = "lol";
fn main() -> Result<(), Box<dyn Error>> {
let mut history = OpenOptions::new()
.create(true)
.append(true)
.open(HISTORY_FILE)?;
if let Some(command) = env::args().nth(3) {
writeln!(history, "{}", command)?;
};
Ok(())
}❯❯❯ cargo run -- dummy dummy hello
❯❯❯ cargo run -- dummy dummy world
❯❯❯ cat lol
hello
world我們快完成了——至少如果你願意稍微作弊一下的話。😏 讓我們把那個格式字串寫死:
use std::env;
use std::error::Error;
use std::fs::OpenOptions;
use std::io::Write;
use std::time::SystemTime;
const HISTORY_FILE: &str = "lol";
fn timestamp() -> Result<u64, Box<dyn Error>> {
let n = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
Ok(n.as_secs())
}
fn main() -> Result<(), Box<dyn Error>> {
let mut history = OpenOptions::new()
.create(true)
.append(true)
.open(HISTORY_FILE)?;
if let Some(command) = env::args().nth(3) {
writeln!(history, ": {}:0;{}", timestamp()?, command)?;
};
Ok(())
}現在,如果我們稍微瞇起眼睛看,它就有點像是把我們的指令寫進了我的歷史格式。(關於 Unix 時間戳記的那部分是直接取自文件的。毫無悔意。)
還記得我說過 precmd 什麼也拿不到嗎?
我騙你的。
實際上,你可以讀取已執行指令的結束代碼(來自 $?)。這非常有用,但我們就當作沒這回事,再也不提它。
解決了這個問題,我們最終的 past.zsh 鉤子檔案看起來像這樣:
autoload -U add-zsh-hook
_past_preexec(){
past $@
}
add-zsh-hook preexec _past_preexec現在要進入危險環節了!退後一點,看我用自己的版本取代原本的 history 指令。千萬別在家嘗試。(其實我有點誇張了。儘管試試看。最糟的情況就是你會遺失一點歷史紀錄,但別告我。)
首先,讓我們把歷史檔的路徑改成我真正的路徑:
// You should read the ${HISTFILE} env var instead ;)
const HISTORY_FILE: &str = "/Users/mendler/.zhistory";然後來安裝 past:
❯❯❯ cargo install --path .
# bleep bloop...之後就可以使用了。讓我們把這個傢伙加到我的 ~/.zshrc 裡:
source "/Users/mendler/Code/private/past/src/shell/past.zsh"而終於,我們可以來測試了。
我們開啟一個新的 shell,執行幾個指令,接著執行 history:
❯❯❯ date
...
❯❯❯ ls
...
❯❯❯ it works
...
❯❯❯ history
1011 date
1012 ls
1013 it works✨ 太棒了。 ✨ past 的原始碼就在 Github 上。
它到底真正是怎麼運作的
我們的實驗大獲成功,但我後來才了解到現實有點不同。
「在早期版本的 Unix 中,history 指令是一個獨立的程式」,但大多數現代的 shell 都有內建的 history。
zsh 在其主要的 執行迴圈中追蹤歷史紀錄。這裡是重要的片段。(假設所有型別皆已在作用域內。)
Eprog prog;
/* Main zsh run loop */
for (;;)
{
/* Init history */
hbegin(1);
if (!(prog = parse_event(ENDINPUT)))
{
/* Couldn't parse command. Stop history */
hend(NULL);
continue;
}
/* Store command in history */
if (hend(prog))
{
LinkList args;
args = newlinklist();
addlinknode(args, hist_ring->node.nam);
addlinknode(args, dupstring(getjobtext(prog, NULL)));
addlinknode(args, cmdstr = getpermtext(prog, NULL, 0));
/* Here's the preexec hook that we used.
* It gets passed all the args we saw earlier.
*/
callhookfunc("preexec", args, 1, NULL);
/* Main routine for executing a command */
execode(prog);
}
}歷史紀錄的行會被保存在 hash 中,同時也會保存在 ring-buffer(環形緩衝區) 中,以防止歷史紀錄變得過大。(請參閱此處。)
這招很聰明!如果沒有 ring-buffer,惡意使用者只要用隨機指令灌爆歷史紀錄,就能觸發 buffer overflow(緩衝區溢位)。我從來沒想過這點。
來說說歷史(看出我玩的文字遊戲了嗎?)
最早的 history 指令是在 1978 年被加入到 Unix C shell(csh)中。這裡有一份 Bill Joy(比爾·喬伊) 的論文連結(嘿,又是個比爾!)。他從 Interlisp 中的 REDO 指令獲得靈感。你可以在原始的 Interlisp 手冊中的 第 8.7 節找到它的規格。
學到的教訓
- 重建你不懂的東西。
- 歷史檔是人類可讀的,而且相當直觀。
history指令是 shell 內建指令,但我們可以用鉤子來寫自己的版本。- 冷知識:你知道在 zsh 中,
history其實只是fc -l的別名嗎? 更多資訊請見此處或 查看原始碼。
「我無法創造的東西,我就無法理解」—— Richard Feynman(理查·費曼)
隨機一篇部落格