How Does The Unix `history` Command Work?

Matthias Endler

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

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

隨著一天即將結束,我有整整一個小時完全屬於自己。正是聽點 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 是怎麼回事!?

原來它是指令執行時間,而整個格式被稱為 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(理查·費曼)

原文由 Matthias Endler 發布

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