LoginSignup
176
144

More than 1 year has passed since last update.

シェルスクリプトでlsをパイプでつなぐのはなぜ悪いのか ~ ShellCheck: SC2010, SC2011, SC2012 とファイル名改行問題

Last updated at Posted at 2023-01-05

はじめに

シェルスクリプトで ls コマンドの出力結果(ファイル名一覧)をパイプで他のコマンドに渡して処理するのは推奨されません。ls コマンドを使ったコードを ShellCheck で検査するとおそらく問題があると警告が表示されるでしょう。ls を使うなという指摘自体には賛成なのですが SC2010SC2011SC2012 に書いてある理由については正しい説明がされていないと思っています。この記事ではなぜ ls の出力結果を他のコマンドにパイプで渡すのが悪いのか、ls を使わずに実現するにはどうしたら良いのかを解説したいと思います。一つ補足をしておくと、この問題は CLI コマンドをよく呼び出すシェルスクリプトで多く発生する問題ではありますが、同じように CLI コマンドを呼び出すためのライブラリ・フレームワークである Python の subprocess や JavaScipt の zx などでも発生する言語に依存しない問題です。(私は他の言語を使うのなら CLI コマンドよりもその言語のライブラリを使え派)

この記事に関連する話は以下のページでも詳しく述べられているのでぜひご参照ください。

注意 私は端末上のシェルから手作業しているときまで ls コマンドをパイプでつなげてはいけないとは言っていません。この記事の話はあくまでシェルスクリプトの話です。手作業の場合は、データ(ファイル群)が先に用意されており、その時のデータで動けばそれで十分なので「おかしなファイル名なんてないだろうからヨシ!」が許されます。しかしシェルスクリプトは長く運用される可能性があり、想定してないデータが後から来る場合もあるため、もう少し慎重さが必要です。もっとも「grepを忘れただけなのに」のような事故もあるので、手作業でも ls コマンドをパイプでつなげないほうが良いと思います。普段から ls コマンドをパイプでつなぐという習慣がなければ、このような事故も減るでしょう。パイプは万能の道具ではありません。適材適所で使い分けるものです。何でもパイプを使ってやろうとする発想は、正規表現や SQL を知ったばかりの人がなんでもそれだけでやろうとし始めるのと同じで、使える道具が少なく適材適所で使い分けられない初心者の発想です。

ShellCheck の説明は正しいのか?

まず ShellCheck がどのような説明をしており、なぜ私が正しく説明してないと考えているのか、その理由を述べます。

ShellCheck: SC2010, SC2011, SC2012 の警告例

以下のようなシェルスクリプトを ShellCheck でチェックすると次のような警告が出力されます。なお ShellCheck のバージョンは、現時点で最新の 0.9.0 です。

script.sh
#!/bin/sh

ls | grep abc
ls | xargs
ls | cat
$ shellcheck script.sh

In script.sh line 3:
ls | grep abc
^-- SC2010 (warning): Don't use ls | grep. Use a glob or a for loop 
with a condition to allow non-alphanumeric filenames.


In script.sh line 4:
ls | xargs
^-- SC2011 (warning): Use 'find .. -print0 | xargs -0 ..' or 
'find .. -exec .. +' to allow non-alphanumeric filenames.


In script.sh line 5:
ls | cat
^-- SC2012 (info): Use find instead of ls to better handle non-alphanumeric filenames.

For more information:
  https://www.shellcheck.net/wiki/SC2010 -- Don't use ls | grep. Use a glob o...
  https://www.shellcheck.net/wiki/SC2011 -- Use 'find .. -print0 | xargs -0 ....
  https://www.shellcheck.net/wiki/SC2012 -- Use find instead of ls to better ...

ShellCheck はなんと言っているのか?

ShellCheck は以下のように「英数字以外のファイル名を許可(より適切に処理)するために」findglob を使えと言っています。

to allow non-alphanumeric filenames.
to better handle non-alphanumeric filenames

そして SC2012 で以下のような例を挙げています。

$ ls -l
total 0
-rw-r----- 1 me me 0 Feb  5 20:11 foo?bar
-rw-r----- 1 me me 0 Feb  5  2011 foo?bar
-rw-r----- 1 me me 0 Feb  5 20:11 foo?bar

同じ名前のように見えるファイルが三つありますが、これは別々のファイルです。おそらく制御文字が使われており、出力する際に表示不可能な文字である制御文字を ? に置き換えているのでしょう。これは -q オプションが指定されている場合の動作で、実装によってはデフォルトで有効になっている場合があります。

ShellCheck の主張は ls コマンドの出力は人間が読むためのもので、環境によってその出力は加工される場合があるということなのですが、この理由は少しおかしいと思っています。なぜならこのような加工が行われるのは画面に出力した場合の話であって、パイプで他のコマンドに渡す場合は加工なしで渡されるはずだからです。先程 -q オプションがデフォルトの場合があると書きましたが、これは端末に出力する場合に限った話です。

POSIX ls でも「端末に出力する場合は出力形式は実装定義である」と書かれています。「端末以外に出力する場合は加工なしで出力しなければならない」とは書いていないと言われれば、そう読み取れなくもないのですが、表示不可能な文字を ? に置き換える機能として -q オプションがあるわけで、それを有効にしない場合は、加工なしで出力すると解釈するのが自然だと考えます。

端末以外に出力するときでも常にファイル名を加工する実装が存在する「可能性がある」という話であればそのとおりなのですが、可能性の話をしたらきりがありません。具体的な実装の例が挙げられていないので実際に問題がある話をしているのか可能性の話をしているのかがわかりません。間違った(正確ではない)説明が書いてあると「それは ShellCheck が間違っているのだから無視して良い」と言われかねません。

BusyBox ls は英数字以外(日本語等)を出力できない

前項の理由から「英数字以外のファイル名をより適切に処理するため」に findglob を使えという主張には疑問を感じます。ただしこれが Busybox ls の話をしているというのであれば ShellCheck の主張は正しいです。なぜなら BusyBox ls は現在の最新のバージョン 1.35.0 時点で英数字以外のファイル名の出力が正しく実装されておらず、制御文字だけではなく UTF-8 ロケールであっても日本語文字などが ? に置き換えられて出力されてしまうからです。

さらに POSIX で標準化されているにもかかわらず -q オプションは実装されていません。実装されていませんが端末に出力した場合でも端末以外に出力した場合でも、-q オプションを指定したときのように英数字以外はすべて ? に置き換えられてしまいます。BusyBox 全体が UTF-8 に対応していないというわけではなく BusyBox find などでは正しく日本語を表示できます。これはバグと言っても良いと思うのですが、BusyBox を使うという前提であれば ls コマンドで英数字以外のファイル名を出力することは不可能です。したがって端末以外に出力するときでもファイル名を加工する実装が存在するのは「可能性」ではなく「現実」です。

ShellCheck の Issue などを見るとわかりますが、ShellCheck の開発者は BusyBox を使っており、BusyBox ls のことを考慮していても不思議ではありません。しかし BusyBox は元々 POSIX 準拠の完全なツールチェインを目指しているわけではなく必要最小限の実装を行うことで組み込み向けにサイズを小さくすることを目指しています。したがって特殊な環境向けのツールとして例外として扱うという考えは許容できると思っています。つまり「BusyBox は例外として、一般的な環境なら端末以外に出力した場合は加工なしで出力されるのだから ShellCheck の主張は正しくないのではないか?」というのが私が考えです。実際私が調べた範囲では BusyBox を除き、どれも端末以外に出力した場合は加工なしで出力されていました。ただし商用 UNIX では調べていない(調べられない)のでもし知っている方がいれば教えてください。少なくとも私が問題があると把握しているのは BusyBox だけです。しかし ShellCheck の解説には BusyBox への言及がないため、ほとんどバグのような動作をしている BusyBox ls を考慮した結果の話なのか判断できないわけです。

ちなみに先程「BusyBox を例外として扱うという考えは許容できると思っている」と言いましたが、それは一般論の話であって私自身は例外として扱っていません。私は現実主義であるためバグがある実装が存在し、無視できないレベルで使われているのであれば、可能な限り対応すべきであると考えます。BusyBox は組み込み環境で無視できないレベルで使われています。もちろん、どの環境に対応するかは要件次第なので、必ずしも BusyBox に対応する必要はありません。ただ「私個人としては」POSIX シェルが動作するどの環境でも動くシェルスクリプトを書くことを目指しているため、BusyBox が POSIX に完全に準拠しておらず、BusyBox が悪いというのが事実だとしても、現実に使われている環境を無視することはありません。POSIX に準拠してシェルスクリプトを書いたとしても、どの環境でも動くことにはならないというのが現実です。そもそも POSIX はアプリケーションを移植しやすくするためのガイドラインであって、各 OS の動作の違いの統一を目指ざしている標準規格ではありません。

ファイル名の改行問題と解決方法

BusyBox ls は例外として見なかったことにするとします。それならば ls コマンドの出力結果を他のコマンドに渡していいかといえば、それは間違いです。どうしても回避できない問題があります。それはファイル名に改行が含まれている場合です。ShellCheck はこの問題を強く主張すべきだと私は考えます。実は改行以外であればシェルスクリプトで制御文字を扱うのは難しくありません。普通の文字と同じように扱うことができます。スペースとタブは少し注意が必要ですが IFS 周りの挙動を知っていれば問題なく扱えます。回避できない問題が発生するのは改行だけです。

👎 改行が含まれるファイル名は脆弱性になりえる

改行が含まれているファイル名は扱うのが難しいだけではなく脆弱性を引き起こす可能性があります。以下はファイル名に改行が含まれた「foo<改行>bar」と、普通のファイル名の「baz」の二つのファイルを作成し、それを端末以外(別のコマンド)にパイプで渡した時にどのようなデータが渡されているかを示したものです。

$ touch "foo
bar" baz

$ ls | cat # 三つのファイルがあるように見える
baz
foo
bar

$ ls | hexdump -C # バイナリで見ても同じ
00000000  62 61 7a 0a 66 6f 6f 0a  62 61 72 0a              |baz.foo.bar.|
0000000c

$ ls # 参考 GNU ls で端末に出力した場合
 baz  'foo'$'\n''bar'

二つのファイルのうち一つは改行が含まれていますが、ls コマンドの出力結果からはそれがわからず、foo, bar, baz の三つのファイルがあるように見えてしまいます。バイナリで出力しても区別は付きません。ここから(改行がファイル名に含まれる可能性がある場合は )ls コマンドの出力をパイプで別のコマンドに渡して正しく処理することはできないということがわかります。

「ファイル名に改行を含めるほうが悪い!そんなのが来たら動かなくてもいい!」というのであれば、もちろんそれでもかまわないのですが、ファイル名に改行を含めることで意図しないファイルに対して処理させることが可能になり、脆弱性につながる可能性があることは知っておく必要があります。以下は改行文字が含まれているファイル名を作成することで、意図しないファイルを削除させる例です。

改行が含まれるファイル名による脆弱性の例
$ touch image.jpg "image.jpg
text.txt"

$ ls # jpg ファイルと txt ファイルがそれぞれ一つづつ作成されている
 image.jpg  'image.jpg'$'\n''text.txt'

$ ls ./*.txt | xargs rm # txt ファイルを削除するつもりが・・・
rm: 'text.txt' を削除できません: そのようなファイルやディレクトリはありません

$ ls # 消えているのは jpg ファイル
'image.jpg'$'\n''text.txt'

このような脆弱性が発生する可能性があるため改行が含まれるファイル名の存在を完全に無視することはできません。少なくとも信頼できない第三者によってこのようなファイルが作成できないようになっている必要があります。例えば CGI などから第三者が特殊なクエリーを送信することで、そのようなファイルを作成できてしまえば脆弱性になりえます。(ところでこの脆弱性に名前はあるのでしょうか?)

少し別件になりますが ls | xargs rm のようなコードには別の脆弱性があります。ここでもし - で始まるファイル名が存在したらどうなるでしょうか?

$ touch "./-rf
..
file.txt"

$ ls | cat
-rf
..
file.txt

$ ls | xargs rm # 「rm -rf .. file.txt」を実行する
# => rm: refusing to remove '.' or '..' directory: skipping '..'

# -- を書いておくと -- 以降の引数はオプションとしては扱わなくなる
$ ls | xargs rm -- # 「rm -- -rf .. file.txt」を実行する

-rf というファイルがオプションとして扱われて rm コマンドが実行されてしまいます。運がいいことに rm コマンドは安全のために ... ディレクトリを削除しないようになっていますが、別のコマンドには通常そのような制限はありません。今回はファイル名に改行を含ませることで親ディレクトリ (..) へのアクセスを可能にしましたが、それが不要な場合は単に - で始まるファイルを作るだけで誤作動を誘発することが可能です。

このような問題に対処するために広く使われているのが -- オプションです。-- オプションを指定すると以降の引数は - で始まっていたとしてもオプションとはみなされなくなります。一部のコマンド(echo コマンドなど)を除き POSIX コマンドは -- をサポートしています。自分でプログラムを作る場合も -- オプションをサポートすることを強くおすすめします。ほとんどのオプションパーサーライブラリではサポートされていると思われます。

ちなみに ls の結果を端末に出力した場合はファイル名が横並びに並んでいますが、ls | cat の出力は一行が一ファイルになっています。これは ls -1 を実行したときと同じ形式です。まれに ls の出力を別のコマンドにパイプで渡す時に -1 を付けている人がいますが実は不要です。もちろん書いても無害なのですが端末以外に出力する時は一行一ファイルで出力するのは POSIX でも指定されている動作です。表示不可能な文字を ? に置き換えて表示する機能もそうですが、それなりの数のコマンドが端末(画面)に出力するときと、端末以外に出力するときで出力データを変更しているので注意してください。例えば端末に出力するときだけ出力に色がついていたりします。目に見えるものが真実とは限りません。

👍 パス名展開(glob 展開)を使って解決する

改行が含まれるファイル名の問題を解決する方法の一つは外部コマンドに頼るのではなく、シェルスクリプトの言語(シェルコマンド言語)自身が本来持っている言語機能を使うことです。具体的にはパス名展開(glob 展開)を使います。パス名展開とは *.txt みたいに複数の文字にマッチするパターンをパスに展開する機能のことです。(おなじみの機能ですよね?)

# 実は ls は printf に置き換えることができる
# echo に置き換えることもできるが echo は引数が - で始まっていたり
# バックスラッシュが含まれている場合の動作がシェル依存
lsprintf '%s\n' ./*

# ls | grep はパス名展開に置き換えることができる
# (ディレクトリの中を出力しないよう -d が必要)
ls -l | grep '\.txt$'ls -dl -- *.txt

# 隠しファイルも含めて全て出力したい場合
ls -alls -dl -- .* *
ls -dl -- .[!.]* ..?* * # . と .. を除く場合

# ls | while read は for とパス名展開に置き換えられる
ls | while IFS= read -r file; do
  ...
donefor file in ./*; do
  # ファイルが一つも見つからなかった時は
  # file に glob パターン(この例では *)が代入されるので
  # 実際にファイルが有ることを確認する
  [ -e "$file" ] || continue
  ...
done

つまるところ、単純にファイル名の一覧を取得するだけなら ls コマンドや find コマンドは必要ありません。

補足ですが glob パターンの頭が、パターン記号 (*, ?) で始まる場合は、頭に ./ をつけることをおすすめします。これも - で始まるファイル名にマッチした時にオプションと誤認識させないためのテクニックです。例えば -i というオプションに見えるような名前のファイルあったとしても、./* と書いておけば ./-i に展開されるため安全です。ls コマンドは -- をサポートしているため上記の例では ./ を付けていませんが、glob パターンを書く場合は普段からつけておくと安全だと思います。

ところでシェルスクリプトはインタプリタだから「シェルスクリプトの言語機能(for ループなど)を多用すると遅くなるのでは?」と考えてはいないでしょうか? 確かにシェルスクリプトは他の言語に比べれば遅いのですが、シェルスクリプトの言語機能(制御構文、算術式・変数展開、シェルビルトインコマンド)であれば、問題になるほど極端に遅いということはありません。シェルスクリプトが遅くなる本当の原因はループの中で外部コマンドやサブシェルが生成される機能を何百回、何千回と呼び出しているからです。そういう場合は 100 倍、1000 倍のレベルで遅くなります。例えばループの中で sedcut を使って文字列編集をすると劇的に遅くなりますが、変数展開に置き換えれば実用的な速度で動きます。パイプや xargs コマンドなどを使って速くなるのも外部コマンドの呼び出し回数が 1 回もしくは数回に大幅に減るからであって、パイプでつなげる書き方だから速くなるのではありません。本当の原因を理解しなければ「パイプを使って速くなった」→「パイプは速い」→「なんでもパイプを使うぞ」という初心者の発想につながります。パイプは少なくともサブシェル、多くの場合は外部コマンドを呼び出すので、ループの中でパイプを使うとこれも大幅な速度低下の原因になります。

素直にシェルスクリプトの言語機能を使えばファイル名の改行問題は回避できます。信頼性の高いシェルスクリプトを書くには、なんでもパイプでつなぐという初心者の発想を改め、シェルスクリプト本来の言語機能を使った手続き型のコーディングスタイルを学ぶ必要があります。シェルスクリプトの基本を知らねば信頼性の高いシェルスクリプトを書くことはできません。

パス名展開を使う場合に一つだけ注意点が一つあります。それは多数(数万レベル、環境やファイル名の長さなどによって異なる)のファイルにマッチするような場合、パス名展開自体は問題なく動くのですが、外部コマンドを呼び出す時に最大引数の制限に引っかかることがあるということです(最大引数の制限はシェル自体及びシェルビルトインコマンドは影響を受けません)。その場合は for ループで一ファイルずつ処理する(処理内容が外部コマンド呼び出しの場合は当然ながら劇的に遅くなる)か、引数を分割して N 個ずつ外部コマンドを呼び出す(xargs コマンドが内部やっているような)処理を書くか、次の find -exec を使う必要があります。

👍 find -exec を使って解決する

ファイル名の改行問題は find コマンドの -exec を使っても問題を解決することができます。-exec を使えばファイルの処理にパイプを使って別のコマンドにつなぐ必要がなくなるからです。この方法は最大引数の問題も発生しませんし、-exec {} ; ではなく -exec {} + を使うようにすればパフォーマンスの問題も発生しません。以下に一例を示します。

# ls -l | コマンド は find に置き換えられる
ls -l | awk '$3 == "me"'
  ↓
find . ! -path . -user me -exec ls -l {} +;

# ファイル数を数える方法
# (パイプを使っていますが、ファイル名の処理には使っていません)
ls *.txt | wc -l
↓
find ./*.txt -exec printf %c {} + | wc -c

# ファイル数を数える方法の別解
find . -name "*.txt" -exec printf %c {} + | wc -c
# 下位ディレクトリ検索しない場合
find . ! -path . -type d -prune -o -name "*.txt" -exec printf %c {} + | wc -c

複雑になって前より長くなっているじゃないか。そう思う気持ちはわかります。記事の冒頭でも書きましたが、端末からの手作業でこのようなやり方をする必要はありません。ls *.txt を実行して、変な名前のファイルなんてないということで | wc -l をつけてファイル数を数えるということは私もよくやります。しかしファイル名に改行が含まれていても問題ないようにするには、このようなコードを書かなければいけません。重要なのは手作業時の手抜き手法とシェルスクリプトとでは、目的が違うのだから必要に応じて書き方を変えるという考え方を持つことです。手抜きのやり方をそのままシェルスクリプトにしてよいとは限りません。

余談ですが個人的に find の文法はとても分かりづらいと思っています。いや個人的じゃないですね。公式がバグとして扱うほどひどいものです。

    BUGS
         The syntax is painful.

find コマンドが分かりづらいのは、単に find コマンド自体の問題です。全体を一つの式とするのではなく、検索対象のディレクトリを指定するオプションと、絞り込み条件の式を明確に区別できるような形にすれば、よかったのではないかと思います。柔軟さは落ちるかもしれませんがわかりやすさが手に入ります。探してみると fd という代替コマンドがあるようですが私は使っていないのでよく知りません。Unix コマンド(または POSIX コマンド)は優れたコマンドという考えを持っているのであれば大間違いです。それらは低機能で限られた移植性しかもたない、ただの古臭いコマンドです。find の文法は 40 年以上、下手すりゃ 50 年以上前に当時の低いコンピュータの性能の中でも動くものとして作られたものなので仕方ないのでしょう。このような古い Unix コマンドへ依存しなければならないシェルスクリプトの世界は終わらせるべきです。

問題解決のための基本的な考え方

根本原因は Unix が問題が発生する文字をファイル名に使えること

この記事ではファイル名に制御文字や改行が使われていたら~という話をしていますが、私は別にファイル名に制御文字や改行を使いたいわけではありません。使えてしまうから対処が必要になるというだけです。おそらく誰も使いたいなんて人はいないでしょう。ファイル名に問題が発生する文字が使えてしまうのは UNIX 誕生以来の OS の欠陥です。この欠陥を修正しようと POSIX に提案したりと取り組んでいる方もいます。以下はこの方によるファイル名についての問題とそれをどのように修正するかの記事です。

この方は OS (ファイルシステム)の制限としてファイル名に以下のルールを適用することを提案しています。(優先順位順で最初の二つは特に重要であると主張しています)

  1. 改行、エスケープ、タブを含む ASCII 制御文字(文字コード 1 ~ 31 および 127)を禁止/エスケープする
  2. ファイル名先頭の - を禁止/エスケープする
  3. 有効な UTF-8 エンコーディングではないファイル名を禁止/エスケープする
  4. 先頭/末尾のスペース文字を禁止/エスケープする(少なくとも末尾)
  5. シェル、他の言語(Perl など)、HTML/XML などの特殊文字を禁止/エスケープする
  6. ファイル名先頭の ~(チルダ)を禁止/エスケープする

提案自体はまっとうなもので、もしこれが初期の UNIX から実装されていれば良かったと思います。しかしながら、現在の OS にはこれらの制限はなく特に 2 番目以降はすでにそのようなファイルが存在する可能性があるため、アプリケーションの移植性を重視する POSIX の方針とは矛盾します。制御文字は誰も使ってないから禁止しても問題ないというだけで、改行以外であれば使われていても大きな問題はなく禁止する理由が見当たらないため、私の考えでは POSIX としては改行を禁止する程度にとどまるだろうと予想しています。それに結局のところ POSIX で禁止されたとしても実際の OS がそれに従う必要はなく、すぐに実施されるわけでもない(やる気があるならすでにやっている)ので、現状の問題を解決する方法にはなりません。ということで現状のファイル名を扱うシェルスクリプトの問題を解決するために書かれたのが以下の記事です。

行指向なデータでないものは行指向な処理ができない

ls コマンドをパイプでつなぐのが駄目なんだなと単純に考えて、以下のようなコードなら問題ないと勘違いしないようにしてください。以下のコードはパイプを使っていませんがこれもダメです。このような書き方が許されるのは「データの一部として」改行を出力しないコマンド(例 seq コマンド)を実行する場合のみです。

# IFS (区切り文字)のデフォルト値はスペース、タブ、改行なので
# デフォルトではスペースやタブが含まれたファイル名でも問題が発生する
IFS="
"
for f in $(ls); do
  ...
done

# 一部の POSIX シェルで使えるプロセス置換もダメ
while IFS= read -r line; do
  ...
done < <(ls)

問題の本質は行指向ではないデータを行指向な方法で扱うことにあります。行指向なデータとはデータが改行で区切られているデータのことで、行指向な処理というのはデータが改行で区切られていることを前提とした処理のことです。ファイル名のリストは一見行指向なデータに見えますが、ファイル名に改行が含まれることでその前提が崩壊します。パイプは行指向な処理をしがちだというだけで、パイプを使わなかったとしても行指向な方法で ls コマンドの出力を利用するなら同じことです。反対にパイプを使っても行指向な処理をしない場合もありますが、Unix コマンドの多くは行指向な処理をすることを前提としています。

行指向な処理ができるのはデータ自体が行指向になっている場合だけです。データが行指向でなければ行指向な処理は行えません。データの性質に応じて適材適所で使う技術を変更する必要があります。適材適所。なんでもパイプを使ってやろうとしない。技術を知って適切な方法を自分の頭で考えて見つける。それが重要な考え方です。シェルスクリプトは誰でも簡単に使い始めることはできますが、ちゃんと使うのであれば他の言語と同じようにちゃんと学ぶ必要があります。最初の一歩が簡単だからと、最初の一歩のやり方で突き進めば、すぐに破綻します。

シェルスクリプトの言語機能を使えば解決できる

端末から手作業をしている場合には、数行のコードでも書くのは苦痛でしょう。しかしシェルスクリプトとして書くのであれば数行程度のコードは問題になりません。ファイル処理はシェルスクリプトの言語機能で実装することができます。例えば前項の例のファイル数を数える場合はこのように書くことができます。

# 汎用的な処理は関数にすることで可読性を上げることができ再利用できる
count_files() {
  i=0
  for file in "$@"; do
    [ -e "$file" ] || continue
    i=$((i + 1))
  done
  echo "$i"
}

count_files ./*.txt

find コマンドを使う場合に比べて長くなりました。しかしどちらがぱっと見で理解できるでしょうか? それは人によって異なります。find コマンドに使い方に詳しい人であれば find コマンドの方が良いでしょう。しかしそうでない人は、この程度の行数であればシェルスクリプトで書いた方がわかりやすいと感じるのではないでしょうか。レビューを依頼された時のことを想像してください。文字を読む時間は find コマンドの方が短いかもしれませんが、意味を正しく理解して OK を出せる時間は find コマンドの方が短いとは限りません。また一旦関数にしておけば、それを使うたびにコードを書いたりレビューが必要になることもありません。可読性が高いコードというのは、行数が短いことでもタイプ数が短いことでもなく、コードを理解する時間が短いコードのことです。そういった所は他のプログラミング言語と何も変わりません。

find コマンドはシェルスクリプトの言語とは別の独自の複雑な式(-prune とか -exec とか)を学ぶ必要があります。Unix コマンドには sedawk などといった統一性のない特徴的なミニ言語がたくさんあります。それらは学ばないと使うことはできません。シェルスクリプトの言語も学ばなければ使えないのは同じだと思うかもしれませんが、ループや条件分岐や四則演算はどの言語にもある基本機能なので、プログラマであれば誰もが知っている基本的な知識です。書き方がちょっと違う程度のものです。

Unix コマンドを使うと短い行数で問題を解決することができますが、短いコードは往々にしてパズルになりがちで、後で見返した時に何をやっているのか分からなくなることがよくあります。それぞれ独自のミニ言語は書いたときには理解していたとしてもしばらく使わないとすぐに忘れてしまいます。汎用的な道具はいろんな場面で使うため記憶に定着しやすいですが、専用的な道具は必要ないときには全く使わないことがよくあるため忘れやすいという問題があります。短く書くことができるのは素晴らしいのですが「忘れやすいことを多く覚えないといけない」のが Unix コマンドの欠点です。

ちなみに bash 専用であれば以下のような書き方もできます。

shopt -s nullglob   # パターンが見つからない時に空リストを返すようにする
files=(*.txt)       # パターンを展開し配列に入れる
echo "${#files[@]}" # 配列の要素数を取得する書き方

前より短くなりましたが、今度は nullglob や配列など学ばなければいけない知識が増えました。短さと学ぶ量はトレードオフの関係にあることがわかります。学べば学ぶほど短く書くことができますが学ぶ労力は増えます。しかし学ばなければコードは長くなり、今度は読み書きの労力が増えます。どちらか一方に振り切るのではなくバランスを取ることが重要です。できれば無駄な労力は避けたいものですが、残念ながら正しい道というのは誰にもわからないので各自で判断しなければいけないことです。

findコマンドの文法は find コマンドでしか使えないものですが、シェルスクリプトの言語機能は汎用性が高くさまざまな用途に使うことができます。例えば配列はファイル数を数える以外にも使えるということは言うまでもないでしょう。上記の bash 用のコードで言えば学ばなければいけない特徴的な機能は nullglob だけです。いくら短く書けたとしても使う場面が限られていれば学ぶメリットは小さくなってしまいます。私は使いづらい文法の find コマンドは将来的に別のコマンドに置き換えるべきだと考えています。道具の使い方を学んでも、その道具を使わなくなれば学んだ知識は全く意味がないものになってしまいます。だから私はより汎用的で広く使われていて長く使える技術にこだわります。find コマンドは長く使える知識だったかもしれませんが、もうそろそろ引導を渡したいですね。

それでも ls の出力結果を利用したい

ファイル名をエスケープして出力する

改行が含まれたファイルで問題が発生するのは行指向なデータではないからでした。逆に言えば行指向なデータにしてやれば行指向な処理ができるということです。つまりファイル名に含まれる改行文字をエスケープしてやれば、パイプで他のコマンドに渡すことができるということです。ファイル名のエスケープについては「ls コマンドの「ファイル名」の出力形式(エスケープ)を徹底調査 」での調査結果を踏まえているので興味がある方はそちらも参照してください。

ls コマンドは様々なエスケープのためのオプションを持っています。ただ残念なことに移植性があるオプションはありません。近い機能として -q オプションがありますが、これは表示不可能な文字を ? に置き換えるオプションで、元の文字に戻すことはできないので、画面出力には使えてもデータとして利用するのは困難です。ただし個々のファイルを区別しない用途であれば使うことができます。例えばファイル数を数える場合です。

# 二つのファイルを作成
$ touch "foo
bar" baz

$ ls | wc - # 正しくファイル数を数えられない
3

# 移植性が高く改行を含むファイル名でも正しくファイル数の数え方
$ ls -q | wc -l # 正しくファイル数を数えることができる
2

この記事の中でシェルスクリプトの言語機能や find コマンドの -exec を使ってファイル数を数える方法を紹介しましたが、実はファイル数を数えるだけであればこの方法でうまくいきます。他にもファイルサイズの合計を計算したりといった集計的な用途であれば使うことができるでしょう。ただし -q オプションは POSIX で標準化されていますが、現時点の BusyBox では使うとエラーになるので少し工夫が必要です。表示不可能な文字を ? にする機能自体は実装されているので要望出したら実装してくれそうな気はしますけどね。最悪何もしないオプションとして実装すればよいわけで。

GNU ls は多くのエスケープの種類に対応していますが、他の ls には実装されていません。POSIX では標準化されていませんが現状で最も移植性が高いのは -b オプションです。私が調べた中で -b オプションを実装してないのは OpenBSD と BusyBox だけです。これは表示不可能な文字を C 言語風の \ を使ったエスケープを行いますが各実装で細かい違いあります。重大な問題点として Solaris 11 では \\\ にエスケープしないので例えば \010 が改行なのか \ 0 1 0 という文字列なのか区別が付きません。移植性はそれなりに高いものの、どの環境でも動くとは言い難く安心して使えるものではありません。しかし完全な移植性が不要であればファイル名をエスケープすることで行指向なデータとして使うことができます。

一つのファイルだけに限定して出力する

ls コマンドの出力結果を利用しても問題ない方法があります。それは一つのファイルの情報だけを取得する場合です。つまりこのような使い方です。

# 改行が含まれているファイル名
file='image.jpg
text.txt'

# 通常はファイル名以外の情報を取得するために使う
# LC_ALL=C は日付部分を C ロケールにするため
info=$(LC_ALL=C ls -l -- "$file")

# 厳密にはコマンド置換の仕様によって末尾の連続する改行が
# 消えてしまうため、ファイル名が必要なら以下のようにする必要がある
info=$(LC_ALL=C ls -l -- "$file"; echo _) && info=${info%_}

# <ls -l の出力結果>
# -rw-rw-r-- 1 koichi koichi 0 Jan  1 20:10 image.jpg
# text.txt

# パイプを使って取得することでも可能だが
# このようなことをする必要はないだろう
LC_ALL=C ls -l -- "$file" | {
  read -r info
}

実際のところ ls コマンドの出力結果を利用するのは難しいです。パーミッションや所有者・グループ程度であれば利用することはできるでしょう。日時の利用は面倒で POSIX によると最近(6 ヶ月以内)の書式は date "+%b %e %H:%M" 相当、それを超えたら date "+%b %e %Y" 相当で出力されるというややこしい仕様です。しかもこれは POSIX ロケール(C ロケール)の場合で、それ以外のロケールでは実装によって異なります。また POSIX の範囲では秒の取得はできません。GNU 拡張、BSD 拡張など、多くの実装では日時を利用しやすい形式で取得するオプションが有るのですが移植性はありません。

ちなみに日時の利用が面倒なのを POSIX のせいにしないようにしてください。POSIX が日時部分をこのようなややこしい仕様に決めて、各実装が POSIX に従って実装したのではありません。これが元からあった ls コマンドの仕様で、POSIX が後から移植性がある部分を標準化しただけです。つまり日時が利用しにくい最初にそのように実装した UNIX が原因です。

ls コマンドを使いたい理由の一つに更新日時などによるソートが考えられますが、一つのファイルだけに限定して出力する方法とは組み合わせて使うことはできません(inode で突き合わせてとか考えたのですがハードリンクがあるので...)。結局のところ ls コマンドは主に人間が見るように設計されており、データとして使うには適していないということです。ls コマンドもまた古臭い Unix コマンドの一つであり、将来的に別のコマンドに置き換えるべきなのでしょう。

POSIX 関連の話

POSIX にこだわるのをやめて find -print0 を使う

この記事のタイトルを見た瞬間から、find -print0 のことが頭に浮かんでいた人も多いのではないかと思いますが、ようやくこの話です。はい、find -print0、使っちゃいましょう。find -print0 の移植性は十分高いからです。

使わない理由は find コマンドの -print0xargs コマンドの -0 は「POSIX で標準化されていない」だと思いますが、実際の所これらが実装されていない環境は少ないです。実環境で試したわけではありませんが、おそらく対応していないのは AIX(と終息する HP-UX)ぐらいです。つまりこれらは POSIX で標準化されていないというだけで、移植性が高い機能なわけです。POSIX で標準化されていなくても実際に移植性が高いのであれば何の問題もありません。さらに特定の環境で使えなかったとしても GNU ツールチェーンという移植性が高いコマンドセットがありますから、ほとんどの環境で GNU 版のコマンドをビルドして動作するはずです。しかもパッケージ管理システムから簡単にインストール可能になっているでしょう。どうやっても find -print0xargs -0 が使えないという環境はまずありません。

find -print0 を使えばパイプで別のコマンドにつなぐことができるようになります。パイプでつなぐことができる理由は改行区切りの代わりにファイル名に使うことができない \0 区切りになるからです。ls コマンドでは改行で区切って出力することしかできませんが、find -print0 を使うと以下のように各ファイルが \0 で区切られるようになります。

$ find foo bar baz | hexdump -C # 改行は 0a
00000000  66 6f 6f 0a 62 61 72 0a  62 61 7a 0a              |foo.bar.baz.|

$ find foo bar baz -print0 | hexdump -C # 0a の部分が 00 に変わる
00000000  66 6f 6f 00 62 61 72 00  62 61 7a 00              |foo.bar.baz.|

$ find *.txt | hexdump -C # ファイル名に含まれる改行と行区切りの改行が区別付かない
00000000  69 6d 61 67 65 2e 6a 70  67 0a 74 65 78 74 2e 74  |image.jpg.text.t|
00000010  78 74 0a                                          |xt.|

$ find *.txt -print0 | hexdump -C # 00 で区切りなのでファイル名に含まれる改行と区別できる
00000000  69 6d 61 67 65 2e 6a 70  67 0a 74 65 78 74 2e 74  |image.jpg.text.t|
00000010  78 74 00                                          |xt.|

余談ですが ls コマンドに -print0 相当の機能は実装されないのだろうか?と疑問になりましたが、調べた所、過去に GNU ls に機能の追加が提案されパッチも送られたようですが却下されています。理由は find コマンドがあるから十分で ls コマンドを複雑にすることはないということのようです。

出力を \0 区切りにした場合、読み取る側も \0 に対応していなければいけません。その一つが xargs -0 です。単純にファイルを引数にコマンドを呼び出す場合はこれで十分です。

\0 区切りのデータはシェルスクリプトの言語機能で扱うこともできます。\0 区切りのデータを出力するには printf コマンドを使うことができます。printf コマンドはほとんどのシェルでビルトインコマンドなので最大引数の制限はありません。私が知る限り唯一 mksh がビルトインではないのですが、ビルトインの print コマンドを使うことで代用が可能です。シェルスクリプトの言語機能で \0 区切りのデータを入力するには read コマンドを利用することができます。以下に例を示します。

printf '%s\0' foo bar baz | hexdump -C
print -rN foo bar baz | hexdump -C # mksh の場合
# 00000000  66 6f 6f 00 62 61 72 00  62 61 7a 00              |foo.bar.baz.|

printf '%s\0' foo bar baz | {
  while read -d '' line; do
    echo "$line"
  done
}

ただし read -d '' は現在のところ、bash、ksh93u+m、mksh、zsh でしか使えず、dash、ksh93、yash、FreeBSD sh などは対応していません。(注意 ksh93 は read -d には対応していますが、空文字を指定したときの \0 区切りには対応していません)

はい、だから bash をインストールして使えばよいです。bash は移植性が高い POSIX シェルなのでどこでも動きます。どちらにしろコンピュータには何かしらのソフトウェアをインストールして使うわけで bash をインストールしない理由はありません。

find -print0 は近く POSIX で標準化される可能性が高い

まだ完全に確定したわけではありませんが、2022 年末に完成予定だった(案の定遅れている)POSIX Issue 8 で find -print0xargs -0、そして read -d が標準化される可能性でてきました。事実上高い移植性を持っていたこれらの機能は、とうとう POSIX でも移植性があると認めれられ標準化の内容に組み込んでも良いと判断されたわけです。おそらく次の Draft3 に含まれると予想しています。最新の情報は「0000243: Add -print0 to "find"」やメーリングリストここなどを参照してください。

もちろん POSIX で標準化されたからと言って、現在実装されていない環境 (AIX) でサポートされるとは思いませんし、サポートされたとしてもアップデートが必要です。しかし POSIX で標準化された以上(準拠する気があるのであれば)使えるようになることは約束されています。そして bash や GNU コマンドをインストールすれば今すぐ使えます。移植性が高いシェルスクリプトであれば bash でも動くということなので、新しい POSIX の範囲の機能だけを使って bash で動くようにシェルスクリプトを書いておけば、将来は OS 標準のシェルでも動くでしょう。古い OS にアップデートすることはないでしょうから、現在使えるのであれば使って良い機能です。

念のために繰り返しますが POSIX で標準化されることはまだ確定した話ではありません。しかしすでに多くの環境で普及していることを踏まえると、標準化へと歩を進めたいま、それを撤回する可能性は低いと考えています。

まとめ

ShellCheck は ls コマンドの出力をパイプで別コマンドに渡す代わりにパス名展開 (glob) や find を使えと警告しています。理由は英数字以外のファイル名をより適切に処理するためです。しかしほとんどの環境では英数字以外のファイル名は問題なく扱えるはずなので、この理由には疑問が残ります。しかしながら BusyBox ls というほとんどバグに近い実装が存在するため、英数字以外が正しく扱えないというのは現実の問題となっています。

英数字以外のファイル名よりも問題なのが改行が含まれるファイル名です。結局のところ、改行が含まれるファイル名が存在する可能性があるため ls コマンドの出力を正しく解析することはできません。このようなファイル名は脆弱性を引き起こす可能性すらあります。

改行が含まれるファイル名を正しく扱うには、ls コマンドの出力を別のコマンドにパイプでつなげるのではなく、シェルスクリプトの言語自体が持っているパス名展開や find コマンドの -exec を使う必要があります。また POSIX Issue 8 で標準化されると見られる find -print0xargs -0read -d を使えばファイル名をパイプで処理することも可能です。

限られた条件を満たせば ls コマンドの出力をシェルスクリプトで扱うことは可能ですが、原則としてはシェルスクリプトでは ls コマンドを使わないようにしましょう。

176
144
3

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
176
144