double quote 안의 backtick은 문장이 아니었다

PR 본문을 한 줄로 넘겼다.
문장 안에는 code identifier를 표시하려고 backtick을 썼다.
shell 명령 전체는 double quote로 감쌌다.
안전해 보였다.
PR은 만들어질 수 있었다.
본문도 대체로 멀쩡해 보일 수 있었다.
그런데 backtick 안의 단어만 사라질 수 있다.
shell은 그 부분을 문장으로 읽지 않았다.
command substitution으로 읽었다.
double quote는 모든 문자를 literal로 만들지 않는다
현재 zshexpn(1)은 command substitution을 두 형태로 설명한다.
$(...)
그리고 grave accent, 즉 backtick으로 감싼 형태다.
`...`
zsh는 안쪽 command를 실행하고 그 standard output으로 표현식을 바꾼다.
끝의 newline은 제거한다.
이 동작은 backtick이 double quote 안에 있다고 멈추지 않는다.
double quote는 공백과 여러 expansion 결과를 한 argument 안에 유지하는 데 도움을 준다.
command substitution 자체를 문자로 바꾸지는 않는다.
가장 작은 재현은 성공했다
외부 PR을 만들 필요는 없었다.
전달 직전 payload만 출력했다.
payload="Keep `true` literal"
print -r -- "$payload"
결과는 이랬다.
Keep literal
true가 실행됐다.
그 명령은 standard output을 내지 않는다.
그래서 command substitution 결과는 빈 문자열이 됐다.
원래 문장에 있던 `true`가 통째로 사라졌다.
더 불편한 부분은 상태였다.
assignment_status=0
명령이 실패하지 않았다.
shell은 요청받은 일을 정확히 했다.
자동화가 non-zero exit code로 멈출 이유도 없었다.
single quote에서는 글자로 남았다
같은 payload의 바깥 quote만 바꿨다.
payload='Keep `true` literal'
print -r -- "$payload"
이번 출력에는 backtick이 남았다.
Keep `true` literal
상태도 0이었다.
두 실행 모두 성공했다.
차이는 payload가 보존됐는지다.
성공 여부만 보는 gate로는 이 차이를 찾을 수 없다.
실제로 전달될 문자열을 읽어야 한다.
없는 command라면 더 잘 보일까
backtick 안의 단어가 실행 가능한 command가 아니면 shell은 오류를 낼 수 있다.
그러면 문제를 빨리 발견할 가능성이 생긴다.
하지만 code identifier가 우연히 현재 환경의 command 이름과 같으면 상황이 달라진다.
true처럼 성공하고 출력이 없는 command라면 단어만 조용히 지워진다.
출력이 있는 command라면 그 출력이 본문에 삽입된다.
어느 쪽도 작성자가 의도한 inline code가 아니다.
실패한 command만 시험해서 안전하다고 결론 내리면 성공하는 command의 훼손을 놓친다.
PR body는 shell argument이기 전에 문서다
다음처럼 긴 본문을 명령 안에 직접 넣는 방식은 두 parser를 한 줄에 겹친다.
gh pr create --body "Use `true` before release"
먼저 shell이 quote와 expansion을 해석한다.
그 결과로 만들어진 argument를 gh가 받는다.
GitHub에 도착하는 것은 우리가 타이핑한 원문이 아니다.
shell 해석이 끝난 뒤의 문자열이다.
Markdown의 backtick과 shell의 backtick이 같은 문자를 공유하는 순간, 문서 작성이 command 작성으로 바뀐다.
긴 본문은 파일 경계로 넘긴다
현재 gh pr create --help는 두 body 입력을 제공한다.
-b, --body string
-F, --body-file file
--body-file에는 -를 주어 standard input을 읽게 할 수도 있다.
긴 Markdown은 파일로 만든 뒤 전달하는 편이 경계가 명확하다.
gh pr create --body-file PR_BODY.md
이때 shell은 파일 안의 backtick을 command line syntax로 해석하지 않는다.
gh가 파일 내용을 body text로 읽는다.
임시 파일을 만들기 싫다면 이미 검토한 standard input을 쓸 수 있다.
gh pr create --body-file - < PR_BODY.md
핵심은 flag 이름이 아니다.
Markdown 원문을 shell expression에서 분리하는 것이다.
짧은 문자열도 전송 전에 출력한다
한 줄이라 파일이 과해 보인다면 최소한 payload를 변수로 만들고 확인한다.
payload='Use `true` before release'
printf '%s\n' "$payload"
눈으로 읽은 바로 그 변수를 다음 command에 넘긴다.
gh pr create --body "$payload"
여기서 double quote는 이미 만들어진 변수 값을 한 argument로 전달한다.
원문 속 backtick을 새로 해석하지 않는다.
검증해야 할 것은 source literal이 아니라 expansion 뒤의 payload다.
preview도 reader를 확인해야 한다
gh pr create --help에는 --dry-run도 있다.
하지만 help는 이 option이 세부 정보를 출력하면서도 Git 변경을 push할 수 있다고 경고한다.
따라서 외부 영향 없는 문자열 검증을 위해 무심코 실행할 명령은 아니다.
payload 자체는 printf로 확인할 수 있다.
PR 생성이나 수정은 별도의 명시적 단계로 남긴다.
preview라는 이름만 보고 read-only라고 추측하지 않는다.
실전 규칙
double quote 안에서도 backtick command substitution은 실행될 수 있다.
성공하고 출력이 없는 command는 본문에서 조용히 사라질 수 있다.
exit code 0은 문서 원문 보존을 증명하지 않는다.
Markdown body는 --body-file로 shell expression과 분리한다.
짧은 body도 실제 전달 변수를 먼저 printf로 읽는다.
외부 생성이나 수정 command는 문자열 검증과 별도 단계로 실행한다.
마지막으로
quote는 방탄 유리가 아니다.
어떤 문자를 막고 어떤 expansion을 허용하는지 정해 둔 문법이다.
double quote를 썼다는 기억만으로는 부족하다.
shell이 최종적으로 만든 argument를 봐야 한다.
문장은 내가 썼지만, 전송되는 문자열은 shell이 편집한다.
backtick 하나가 editor가 되는 순간이 있다.
다음 글도 받아보세요.
글을 끝까지 읽으셨다면, 다음 글은 받은편지함이나 RSS 리더에서 만나보세요.