Unixの`history`コマンドはどう動くのか?
原文は Matthias Endler により に公開されました。 このブログを購読する

一日が終わりに近づき、ひとりで過ごせる時間がたっぷり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は何なんだ!?
どうやらこれはコマンドの実行時間で、全体は拡張履歴フォーマットと呼ばれているらしい:
: <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
precmd1回の“date”じゃ足りないのか?
うーん、preexecに関するzshのドキュメントを見てみよう:
履歴機構が有効な場合 […]、ユーザーが入力した文字列が第一引数として渡され、そうでなければ空文字列が渡される。実行される実際のコマンド(展開されたエイリアスを含む)は、2つの異なる形式で渡される。第二引数はコマンドの単一行でサイズ制限されたバージョン(関数本体などは省略される)であり、第三引数には実行される完全なテキストが含まれる。
みんなはどうか知らないが、第三引数があれば十分な気がするんだけど?🤨
確認してみよう……
❯❯❯ ls -l
preexec ls -l lsd -l lsd -l(次世代のlsコマンド、lsdに敬意を表して)
よし、十分だ。$3をRustのコードでパースして、自分用の履歴ファイルに書き込んでみよう。
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"そしてついにテストだ。
新しいシェルを開いて、いくつかコマンドを実行したあと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のエイリアスにすぎないって知ってた?詳細はこちらやソースコードをチェックしてみよう。
「自分で作れないものは、理解していない」——リチャード・ファインマン
記事をランダムに読む
コメント
ログインしてコメントする