How Does The Unix `history` Command Work?

Matthias Endler

Unix 的 history 命令是如何工作的?

温馨的阁楼由 [vectorpouch](https://www.freepik.com/vectors/poster) 创作,tux 由 [catalyststuff](https://www.freepik.com/vectors/baby) 创作 — freepik.com
来源:温馨的阁楼由 vectorpouch 创作,tux 由 catalyststuff 创作 — 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 上的 man 手册页帮不上什么忙——而且一开始我也没找到多少资料。

我找到了这篇文章(如今提醒你这是 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)

: <开始时间>:<经过的秒数>;<命令>

(根据你的设置,你的文件可能看起来不太一样。)

挂钩历史记录

但话说回来,history 究竟是怎么工作的呢。

它一定是在我每次执行命令时运行某些代码——某种钩子(hook)

💥 嗖———— 💥

来自未来的 Matthias 从一团耀眼的光球中走出:等一等等一等等一下!事情根本不是这样运作的!

事实证明,像 bash 和 zsh 这样的 shell 其实并不会为 history 调用钩子。为什么要调用呢?既然 history 是一个 shell 内建命令(builtin),它们直接在内部跟踪命令就行了。

幸好我的主编、常驻 Unix 大胡子 Simon Brüggen 向我解释了这一点——但那是在我把本文初稿发给他之后。😓

因此,下一节有点像《魔戒》:一个善良却天真的家伙,踏上了一场可疑的任务,对自己会陷入什么境地毫无头绪。

为我辩护一句:《魔戒》主要也是因其娱乐价值而非其历史准确性而受人喜爱……而且正如这部史诗一样,我保证我们最终会把事情的真相弄个水落石出。

我在 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 什么都拿不到吗?

我撒谎了。

实际上,你可以读取所执行命令的退出码(exit code)(从 $?)。这非常有用,但我们还是假装忽略它,并且永远不要再提这件事。

把这个撇开之后,我们最终的 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 在它的主运行循环(run loop)中跟踪历史记录。以下是重要的部分。(假设所有类型都在作用域内。)

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

历史行保存在一个哈希表中,同时也保存在一个环形缓冲区(ring-buffer)中,以防历史记录变得过大。(见这里。)

这很聪明!如果没有环形缓冲区,恶意用户可以用随机命令不断冲击历史记录,直到触发缓冲区溢出(buffer overflow)。我从没想到过这一点。

历史时刻(看出我玩的双关了吗?)

最初的 history 命令于 1978 年被加入 Unix C shell(csh)。这里是 Bill Joy(比尔·乔伊)论文链接(嘿,又是一个 Bill!)。他的灵感来自 Interlisp 中的 REDO 命令。你可以在原始 Interlisp 手册的第 8.7 节找到它的规范。

学到的经验

  • 重建你不理解的东西。
  • 历史文件是人类可读的,而且相当直观。
  • history 命令是 shell 内建命令,但我们可以利用钩子编写自己的版本。
  • 有趣的事实:你知道吗,在 zsh 中,history 实际上只是 fc -l 的别名?更多信息见这里,或者查看源代码

“凡我不能创造的,我就不能理解。”——Richard Feynman(理查德·费曼)

原文由 Matthias Endler 发布

本文章由 stealth/ox-alpha 进行翻译