How Does The Unix `history` Command Work?

Matthias Endler

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

原文由 Matthias Endler 發布,訂閱此部落格

由 vectorpouch 製作的溫馨閣樓與由 catalyststuff 製作的 Tux — freepik.com
來源:由 vectorpouch 製作的溫馨閣樓與由 catalyststuff 製作的 Tux — freepik.com

一天即將結束,我有整整一個小時完全屬於自己。正是聽點 Billie Joel(最近不是聽 Billie Joel 就是 Billie Eilish)的完美時機,順便來研究一下 Unix 的 history 指令是怎麼運作的。人生真美好。

探究 Unix 的運作原理算是我的一個小嗜好。
之前我已經寫過 yeslscat 了。別笑我。

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 是什麼意思!?

原來它是指令執行時間,而整個格式被稱為擴充歷史格式

: <beginning time>:<elapsed seconds>;<command>

(視你的設定而定,你的檔案看起來可能會不太一樣。)

接入 history

但話說回來,history 到底是怎麼真正運作的。

它一定是在我每次執行指令時都會跑一段程式碼——某種 hook(鉤子)吧!

💥 Swoooooosh 💥

來自未來的 Matthias 從一道刺眼的光球中現身:等等!才不是那樣運作的!

事實上,像 bash 和 zsh 這類的 shell 並不會真的為了 history 去呼叫什麼 hook。何必呢?既然 history 本身就是 shell 內建指令,它們大可以直接在內部追蹤指令就好。

幸好我的總編輯兼駐站 Unix 老屁股 Simon Brüggen 跟我解釋了這件事——不過是在我把這篇文章的初稿寄給他之後才說的。😓

因此,接下來的這一節有點像《魔戒》:一個討喜但天真的小夥子,踏上了一場前途未卜的任務,完全不知道自己被捲進了什麼。

幫自己辯解一下,《魔戒》主要也是因為娛樂價值而受到喜愛,而不是因為它的歷史正確性⋯⋯就像這部史詩故事一樣,我保證最後我們一定會把事情搞清楚。

我在 add-zsh-hookatuin 的原始碼中找到了一個使用範例。

我也許沒辦法完全搞懂上面寫的所有東西,但我是個行動派,我可以把一個紮實的作品拆開來研究。

東西不多,但這就是我整理出來的成果:

# 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

這樣就設定了兩個 hook:第一個會在指令即將執行前被呼叫,第二個則在執行完後立刻被呼叫。(我決定把我的小 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 的文件說明

如果 history 機制處於啟用狀態 […],使用者輸入的字串會作為第一個參數傳入,否則會是空字串。實際將被執行的指令(包含展開後的別名)會以兩種不同形式傳入:第二個參數是單行、長度受限的指令版本(例如函式本體等內容會被省略);第三個參數則包含即將被執行的完整文字。

不知道你怎麼想,但第三個參數應該就是我們唯一需要的吧?🤨

來驗證一下⋯⋯

❯❯❯ ls -l
preexec ls -l lsd -l lsd -l

(在此致敬 lsd,也就是次世代的 ls 指令)

好,夠用了。來用 Rust 程式碼解析 $3,然後把它寫進我們自己的 history 檔案吧。

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(())
}

現在,如果瞇著眼睛看一下,它也算是把我們的指令寫成我的 history 格式了。(那段關於 Unix 時間戳記的寫法是直接從文件抄來的。毫無罪惡感。)

還記得我說 precmd 什麼都拿不到嗎?

我騙你的。

實際上,你可以從 $? 讀到剛執行完的指令的結束狀態碼(exit code)。這其實很有用,但我們就當作沒這回事,再也不提它了。

把這件事拋開不談,我們最終的 past.zsh hook 檔案長這樣:

autoload -U add-zsh-hook

_past_preexec(){
    past $@
}

add-zsh-hook preexec _past_preexec

現在要進入危險環節了!先退後一點,看我用自己的版本取代原本的 history 指令。千萬別在家嘗試。(其實我有點誇大了。儘管試試看吧。最糟的情況就是你會遺失一點歷史紀錄,但別來告我。)

首先,把 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 中,同時也會放在一個環形緩衝區裡,以防止歷史紀錄變得過大。(請看這裡。)

這招真聰明!如果沒有環形緩衝區,惡意使用者只要用隨機指令不斷灌爆歷史紀錄,就可能觸發緩衝區溢位。這點我從來沒想到過。

歷史小時間(有看出我在玩諧音梗嗎?)

最初的 history 指令是在 1978 年被加入到 Unix C shell(csh)中的。這裡有一篇 Bill Joy(嘿,又一個 Bill!)的論文連結。他的靈感來自 Interlisp 中的 REDO 指令。你可以在原始的 Interlisp 手冊第 8.7 節中找到它的規格說明。

學到的教訓

  • 不懂的東西,就自己重做一遍。
  • history 檔案是人類可讀的,而且相當直觀。
  • history 指令是 shell 的內建指令,但我們可以用 hook 來寫出自己的版本。
  • 冷知識:你知道在 zsh 中,history 其實只是 fc -l 的別名嗎?更多資訊請看這裡,或直接查看原始碼

「我無法創造的東西,我就無法理解」—— Richard Feynman

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

留言