Bashの履歴展開 `!何々` による事故2選

　𝑫𝒐𝒏'𝒕 𝑹𝒆𝒑𝒆𝒂𝒕 𝒀𝒐𝒖𝒓𝒔𝒆𝒍𝒇
***二　度　も　事　故　る　な***

<!--
$~\small \textit{Don't Repeat Yourself}~$
$~\Large \textbf{二度も事故るな}~$

```math
\begin{array}{c}
\small \textit{Don't Repeat Yourself} \\
\Large \textbf{二度も事故るな}
\end{array}
```
-->

# 二度も事故ったのでご報告します

Git for Windows の Git Bash にて。


## 事故その 1：`sed` の不一致行削除 `!d`

こんな状態で、

```console
$ cat 'lines.txt'
wanted A
wanted B
removed C
wanted D
removed E

$ declare w='wanted'
```

こうすると :boom: **事故る** :boom:

```console
$ sed "/$w/!d" < 'lines.txt'
sed "/$w/declare w='wanted'" < 'lines.txt'
sed: -e expression #1, char 10: extra characters after command
```

エスケープも無意味。まぁ文字列の中だし…。

```console
$ sed "/$w/\!d" < 'lines.txt'
sed: -e expression #1, char 9: unknown command: `\'
```

### 解決法

`!` の部分を**一重引用符で囲んで他の文字列と並べる**ことで、文字列として**連結**できる。

```console
$ sed "/$w/"'!d' < 'lines.txt'  # '/wanted/!d' になる
wanted A
wanted B
wanted D
```

**引用符の外でエスケープして並べる**という手もある。

```console
$ sed "/$w/"\!d < 'lines.txt'
wanted A
wanted B
wanted D
```

横並びにするだけでくっつくの、シェルコマンドなんて結局は文字列の塊（トークン）のやり取りなんだな感が凄い。


## 事故その 2：`7z`（7-Zip）のパス再帰絞り込み `-ir!`

こんな状態で、

```console
$ zipinfo -1 'archive.zip' | sed '/\/$/d'  # Zip の中身をファイルだけ表示
archive/some/path/wanted A.txt
archive/some/path/wanted B.txt
archive/xxx/some/ignored C.txt
archive/xxx/some/path/wanted D.txt
archive/xxx/some/zzz/path/ignored E.txt

$ w='/some/path/'
```

こうすると :boom: **事故る** :boom:

```console
$ 7z x 'archive.zip' -ir!"*$w*"
bash: !": event not found
```

### 解決法

こいつは普通に**エスケープ**で済む。

```console
$ 7z x 'archive.zip' -ir\!"*$w*" -bso0 && find './archive/' -type f \
> # -ir!*/some/path/* になる。-bso0 は 7-Zip の標準出力をなくす。
./archive/some/path/wanted A.txt
./archive/some/path/wanted B.txt
./archive/xxx/some/path/wanted D.txt
```

もちろん**一重引用符**でも良い。
その場合、`-ir` の部分まで含めてしまっても構わない。

```console
$ 7z x 'archive.zip' '-ir!'"*$w*" -bso0 && find './archive/' -type f
./archive/some/path/wanted A.txt
./archive/some/path/wanted B.txt
./archive/xxx/some/path/wanted D.txt
```


# Bash の履歴展開 `!何々` とは

Bash で実行した内容は履歴として残っている。
ターミナルで上下キーを押して過去のコマンド履歴を呼び出す、というのはお馴染みの使い方のはず。

それと同様のことをコマンドとして記述できるのが Bash の**履歴展開**（ヒストリ展開）機能であり、**`!`** から始まる各種書式で提供されている。
`!!` とすれば直前のコマンドを展開するし、`!-3` とすれば 3 回前のコマンドを展開する（`!!` = `!-1`）。
そして **`!何々`** は、**最も直近に実行した `何々` で始まるコマンド**を展開する。

例えば Bash を一旦 `exit` で終了し、改めて Bash を起動して `echo !e` を実行すると…

```console
$ echo !e
echo exit
exit
```

`!e` には**最も直近に実行した `e` で始まるコマンド**が展開されるため、`echo !e` は `echo exit` になる。
展開が済んだコマンドは親切にも一旦表示されるらしく、`echo exit` が表示される。
そして `echo exit` が実行され、文字列 `exit` が表示される。

当然 `echo` もまた `e` で始まるコマンドなので、全く同じ `echo !e` をもう一度実行すると…

```console
$ echo !e
echo echo exit
echo exit
```

今度はこうなる。


## そして事故る

この履歴展開機能、使いこなせば便利だろうけど（それは万物がそうだが）、意図せず展開されると事故が起こる。

### `sed` の方で起きたこと

```console:再掲
$ cat 'lines.txt'
wanted A
wanted B
removed C
wanted D
removed E

$ declare w='wanted'

$ sed "/$w/!d" < 'lines.txt'
sed "/$w/declare w='wanted'" < 'lines.txt'
sed: -e expression #1, char 10: extra characters after command
```

二重引用符は内部で変数展開が行われる。
`!d` の部分で、**最も直近に実行した `d` で始まるコマンド**、すなわち **`declare w='wanted'`** が展開されてしまう。

これにより、`sed` コマンドのパラメータが `"/$w/declare w='wanted'"` という意味わからん内容になり… :boom: **事故る** :boom:

### `7z` の方で起きた（起きる）こと

```console:再掲
$ zipinfo -1 'archive.zip' | sed '/\/$/d'  # Zip の中身をファイルだけ表示
archive/some/path/wanted A.txt
archive/some/path/wanted B.txt
archive/xxx/some/ignored C.txt
archive/xxx/some/path/wanted D.txt
archive/xxx/some/zzz/path/ignored E.txt

$ w='/some/path/'

$ 7z x 'archive.zip' -ir!"*$w*"
bash: !": event not found
```

`!"` の部分で、**最も直近に実行した `"` で始まるコマンド**を展開しようとしてしまう。

`!"` が具体的にどのように履歴展開されるかは**環境の履歴による**ので、一意にどうなるとは言えないものの、ほとんどの場合には意図しない結果になり… :boom: **事故る** :boom:

デフォルトでは事故例のように `bash: !": event not found`、すなわち「そんなコマンド使ったことねーよ！」とエラーになるが、これはまだマシな方だろう。

不幸にも過去に `"` から始まる何かを実行したことがある場合、**引用符が閉じていないと見なされて続きの入力を催促**されたり…

```console
$ "過去の実行"
bash: 過去の実行: command not found

$ 7z x 'archive.zip' -ir!"*$w*"
7z x 'archive.zip' -ir$w*"
>
```

文字列の組み合わせによっては、**展開が変に成功して処理を続行**してしまい、**別の事故に誘爆**したり…

```console
$ "過去の実行（引用符が閉じていない）
> ^C

$ 7z x 'archive.zip' -ir!" スペース始まりの/パス/*"
7z x 'archive.zip' -ir"過去の実行（引用符が閉じていない） スペース始まりの/パス/*"

7-Zip (a) 19.00 (x86) : Copyright (c) 1999-2018 Igor Pavlov : 2019-02-21



Command Line Error:
Incorrect wildcard type marker
r過去の実行（引用符が閉じていない） スペース始まりの/パス/*
```

といった感じで、もっとえらいことにもなり得る。


## 対策

エスケープや一重引用符で対策できることは既に述べた。
一応、他の方法も書いておく。

### 他の対策（？）その 1：`history -c` で履歴を消す

コマンド実行履歴は `history` で閲覧できる。

```console
$ history
（略）
  486  cat 'lines.txt'
  487  declare w='wanted'
  488  sed "/$w/declare w='wanted'" < 'lines.txt'
  489  sed "/$w/\!d" < 'lines.txt'
  490  sed "/$w/"'!d' < 'lines.txt'  # '/wanted/!d' になる
  491  sed "/$w/"\!d < 'lines.txt'
  492  zipinfo -1 'archive.zip' | sed '/\/$/d'  # Zip の中身をファイルだけ表示
  493  w='/some/path/'
  494  7z x 'archive.zip' -ir\!"*$w*" -bso0 && find './archive/' -type f \
  495  rm -r 'archive'  # 展開実験をする際には必ず前回の展開物を削除してね
  496  7z x 'archive.zip' '-ir!'"*$w*" -bso0 && find './archive/' -type f
  497  exit
  498  echo exit
  499  echo echo exit
  500  history
```

この履歴は **`history -c`** で**全削除**できる。

```console
$ history -c

$ !h
bash: !h: event not found
```

履歴削除が事故対策として活きるかは微妙だが、展開が変に成功して誘爆するやつぐらいは防げそう。

なお、削除されるのはあくまで現在のセッションが保持している履歴であり、実際に履歴が保存されているファイル `$HISTFILE`（`~/.bash_history`）の中身はこのコマンドでは別に消えない。

### 他の対策その 2：`set +o histexpand` で履歴展開を切る

**`set +H`** または **`set +o histexpand`** を設定することで、**履歴展開そのものを無効化**できる。

```console
$ set +o histexpand

$ !s
bash: !s: command not found
```

履歴を消した場合のエラーは "event not found" だったのに対し、こっちは "command not found" になっている。
履歴展開機能自体が存在しないかのようになっていることがわかる。

最も安全な対策はこれだろう。
もちろん、`set -H` または `set -o histexpand` で元に戻せる。


## ぼやき

この機能、事故った時にそもそも**何が起きたのかわかりづらい！！！！**
わかったとしても、原因のコマンドが英数字ではなく**記号**（エクスクラメーションマーク）なので、その後**ググりづらい！！！！！！！！**

まぁ、知らないのが悪い。
仮に知らないとしても、シェルスクリプトの記号類にはその手の特殊な効果があり得る、ということを覚悟していないのが悪い。
精進します。はい。


# 参考

- **Bashコマンドラインで！(びっくりマーク)をエスケープ** - プログラミングの「YUIPRO」 
  https://yuis-programming.com/?p=999
  - `'!'"文字列"` という繋げ方をここで知った。
- **quoting - Can't use exclamation mark (!) in bash?** - Unix & Linux Stack Exchange
  https://unix.stackexchange.com/questions/33339/
  - 同じようなお悩み。[mug896 氏の回答](https://unix.stackexchange.com/a/209723)が今回の話に近い。
- **Bash 履歴展開機能についてのノート** | 徘徊の書
  https://showa-yojyo.github.io/wandering/diary/2019/09/14/bash-history.html
  - 履歴展開について、日本語でわかりやすくまとまっている。
- [JM Project (Japanese)](https://linuxjm.sourceforge.io/) > **Man page of BASH**
  https://linuxjm.sourceforge.io/html/GNU_bash/man1/bash.1.html
  - 大切なことはみんな Man page が教えてくれた。
- [7-Zip Start Page](https://7-zip.opensource.jp/chm/start.htm) > **Command Line Version User's Guide**
  https://7-zip.opensource.jp/chm/cmdline/index.htm
  - 7-Zip のコマンドライン機能のオンラインヘルプ。
    正直、インストーラーで 7-Zip 本体と共にインストールされるヘルプファイルの方が見やすい。

&#x200b;

おわり