Unixのhistoryコマンドはどう動くのか

一日が終わりに近づき、ひとりで過ごせる時間が1時間ほどできました。Billie Joelでも聴きながら(最近の私は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の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と呼ばれています。
: <beginning time>:<elapsed seconds>;<command>(設定によっては、ファイルの見た目は異なる場合があります。)
historyにフックする
でも、historyは本当のところどう動いているのでしょうか。
コマンドを実行するたびに何らかのコードが動いているはずです——何らかのフックが!
💥 Swoooooosh 💥
眩い光の玉から未来のMatthiasが現れます。待って!実際はそんな仕組みじゃないよ!
実はbashやzshのようなシェルは、historyのためにフックを呼び出したりはしません。考えてみれば当然です。history自体がシェルのビルトインコマンドなのですから、内部でコマンドを記録すれば済むのです。
幸い、私の編集長であり在住の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これは2つのフックを設定します。1つ目はコマンドが実行される直前に、2つ目は実行された直後に呼び出されます。(自作のhistory代替はpastと名付けることにしました。短い名前が好きなのです。)
さあ、このファイルをコマンド実行のたびに読み込むようzshに指示してみましょう。
source src/shell/past.zsh……そして
❯❯❯ date
preexec
Fri May 28 18:53:55 CEST 2021
precmd動きました!✨ わくわくします! ✨
実は、2年以上前に自作の環境設定マネージャー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」は1回あれば十分じゃない?
うーん、preexecに関するzshのドキュメントを見てみましょう。
history機構が有効な場合 […]、ユーザーが入力した文字列が第1引数として渡されます。そうでない場合は空文字列になります。実際に実行されるコマンド(展開されたエイリアスを含む)は2つの異なる形式で渡されます。第2引数はコマンドの1行版でサイズが制限されたもの(関数本体などは省略されます)、第3引数には実行される完全なテキストが入ります。
皆さんはどうかわかりませんが、第3引数があれば十分な気がしますよね? 🤨
確認してみます……
❯❯❯ ls -l
preexec ls -l lsd -l lsd -l(次世代のlsコマンド、lsdに敬意を表して)
よし、十分でしょう。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(())
}目を細めて見れば、なんとなく自分のhistory形式でコマンドを書き込めているように見えます。(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"そしていよいよテストです。
新しいシェルを開いて、いくつかコマンドを実行したあとhistoryを叩いてみます。
❯❯❯ date
...
❯❯❯ ls
...
❯❯❯ it works
...
❯❯❯ history
1011 date
1012 ls
1013 it works✨ やった! ✨ pastのソースコードはGithubで公開しています。
本当の本当の仕組み
実験は大成功でしたが、その後、現実は少し違うことを知りました。
「初期のUnixではhistoryコマンドは独立したプログラムでした」が、最近のほとんどのシェルでは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);
}
}履歴の各行はハッシュに保持され、さらに履歴が肥大化するのを防ぐためにリングバッファにも格納されます。(こちらを参照。)
賢い仕組みですね!リングバッファがなければ、悪意のあるユーザーがランダムなコマンドで履歴を埋め尽くし、バッファオーバーフローを引き起こすこともできてしまいます。そんなこと考えたこともありませんでした。
歴史の時間です(うまいこと言ったつもりです)
オリジナルのhistoryコマンドは1978年にUnixのCシェル(csh)に追加されました。論文へのリンクはこちらで、作者はBill Joyです(おや、またBillですね!)。彼はInterlispのREDOコマンドから着想を得ました。その仕様はオリジナルのInterlispマニュアルのセクション8.7で確認できます。
学んだこと
- 理解できないものは、作り直してみることです。
- 履歴ファイルは人間が読める形式で、とてもシンプルです。
historyコマンドはシェルのビルトインですが、フックを使えば自作することもできます。- 豆知識:zshでは
historyは実はfc -lのエイリアスだって知っていましたか? 詳細はこちら、またはソースコードをご覧ください。
「自分で作れないものは、理解していない」——リチャード・ファインマン
記事をランダムに読む