Unix `history` 명령어는 어떻게 동작할까?
원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기
가 만든 아늑한 다락방과 [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의 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 같은 셸은 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“date” 한 번이면 충분하지 않나?
음… preexec에 대한 zsh 문서를 한번 보자:
history 메커니즘이 활성화되어 있으면 […], 사용자가 입력한 문자열이 첫 번째 인자로 전달되고, 그렇지 않으면 빈 문자열이 전달된다. 실제로 실행될 명령어(확장된 alias 포함)는 두 가지 형태로 전달된다: 두 번째 인자는 명령어의 한 줄짜리, 길이가 제한된 버전이며(함수 본문 같은 것은 생략됨); 세 번째 인자에는 실행되는 전체 텍스트가 담긴다.
나는 모르겠지만, 세 번째 인자면 우리가 필요한 전부 아닐까? 🤨
확인해 보자…
❯❯❯ ls -l
preexec ls -l lsd -l lsd -l(차세대 ls 명령어인 lsd에게 박수를)
좋아, 충분하다. 이제 Rust 코드로 $3을 파싱해서 우리만의 history 파일에 써보자.
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 명령어를 내 것으로 교체하는 동안 뒤로 물러서라. 집에서 따라 하지 마라. (사실 조금 과장한 거다. 따라 해도 된다. 최악의 경우 history를 조금 잃는 정도니, 나를 고소하지는 말자.)
먼저, 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는 메인 run loop에서 history를 추적한다. 중요한 부분은 다음과 같다. (모든 타입이 scope 안에 있다고 가정하자.)
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 라인은 해시에 보관되고, history가 너무 커지는 것을 방지하기 위해 ring-buffer에도 보관된다. (여기를 참조.)
똑똑하네! ring-buffer가 없으면 악의적인 사용자가 버퍼 오버플로가 발생할 때까지 무작위 명령어로 history를 마구 채워버릴 수도 있다. 나는 그런 생각은 못 했다.
History 시간(썰렁한 말장난인 거 알지?)
원래 history 명령어는 1978년에 Unix C 셸(csh)에 추가되었다. Bill Joy의 논문 링크는 여기 있다(어, 또 다른 Bill이다!). 그는 Interlisp의 REDO 명령어에서 영감을 받았다. 그 사양은 원래 Interlisp 매뉴얼의 8.7절에서 찾을 수 있다.
배운 점
- 이해하지 못한 것은 직접 다시 만들어 보라.
- history 파일은 사람이 읽을 수 있고 꽤 단순하다.
history명령어는 셸 내장 명령어지만, 훅을 이용해 우리만의 버전을 만들 수 있다.- 재미있는 사실: zsh에서
history는 사실 그냥fc -l의 alias라는 걸 알고 있었나? 더 자세한 정보는 여기에서 확인하거나 소스 코드를 확인해 보라.
“내가 만들 수 없는 것은 이해하지 못한 것이다” — Richard Feynman
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기