1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-03-23

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

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

Git for Windows の Git Bash にて。

事故その 1:sed の不一致行削除 !d

こんな状態で、

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

$ declare w='wanted'

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

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

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

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

解決法

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

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

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

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

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

事故その 2:7z(7-Zip)のパス再帰絞り込み -ir!

こんな状態で、

$ 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:

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

解決法

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

$ 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 の部分まで含めてしまっても構わない。

$ 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 を実行すると…

$ echo !e
echo exit
exit

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

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

$ echo !e
echo echo exit
echo exit

今度はこうなる。

そして事故る

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

sed の方で起きたこと

再掲
$ 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 の方で起きた(起きる)こと

再掲
$ 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、すなわち「そんなコマンド使ったことねーよ!」とエラーになるが、これはまだマシな方だろう。

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

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

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

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

シンタックスハイライトが狂うので分割します
$ "過去の実行(引用符が閉じていない)
> ^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 で閲覧できる。

$ 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全削除できる。

$ history -c

$ !h
bash: !h: event not found

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

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

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

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

$ set +o histexpand

$ !s
bash: !s: command not found

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

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

ぼやき

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

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

参考

おわり

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?