Unix `history` 명령은 어떻게 작동할까?
가 만든 아늑한 다락방과 [catalyststuff](https://www.freepik.com/vectors/baby)가 만든 턱스 — freepik.com](https://endler.dev/2021/history/hero.jpg)
하루가 저물어 가는 시간, 오롯이 나만을 위한 한 시간이 생겼습니다. 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의 매뉴얼 페이지는 별로 도움이 되지 않았습니다. 애초에 관련 정보를 많이 찾을 수도 없었고요.
이 글을 찾았는데(요즘은 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은 뭐죠!?
알고 보니 이것은 명령 실행 시간이고, 전체 형식은 확장 히스토리 형식이라고 합니다.
: <시작 시간>:<경과한 초>;<명령>(설정에 따라 파일의 모양은 다를 수 있습니다.)
히스토리에 연결하기
그래도 아직 history가 실제로 어떻게 작동하는지는 모르겠습니다.
명령을 실행할 때마다 어떤 코드를 실행하는 게 분명합니다. 일종의 훅이 있겠죠!
💥 슈우우우웅 💥
눈부신 빛의 구체에서 미래의 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이 코드는 훅을 두 개 설정합니다. 첫 번째 훅은 명령이 실행되기 직전에 호출되고, 두 번째 훅은 바로 다음에 호출됩니다. (제가 만든 작은 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데이트 한 번이면 충분한 거 아닌가요?
흠… preexec에 관한 zsh 문서를 살펴봅시다.
히스토리 메커니즘이 활성화되어 있다면 […], 사용자가 입력한 문자열이 첫 번째 인수로 전달됩니다. 그렇지 않으면 빈 문자열이 전달됩니다. 실제로 실행될 명령(확장된 별칭을 포함)은 두 가지 형태로 전달됩니다. 두 번째 인수는 한 줄로 제한된 명령 버전이며(함수 본문처럼 긴 부분은 생략됩니다), 세 번째 인수에는 실행될 전체 텍스트가 들어 있습니다.
여러분은 어떨지 모르겠지만, 우리에게 필요한 건 세 번째 인수 하나면 충분하지 않을까요? 🤨
확인해 보죠.
❯❯❯ 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(())
}이제 눈을 조금 가늘게 뜨고 보면, 제 히스토리 형식으로 명령을 대충 비슷하게 기록합니다. (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의 별칭이라는 걸 알고 있었나요? 자세한 정보는 여기에서 보거나 소스 코드를 확인해 보세요.
“내가 창조할 수 없는 것은 이해하지 못한다.” — Richard Feynman
글을 무작위로 읽기