はじめに
Linux / Unix を使っていて ls コマンドを使ったことがない人はいないと思います。では ls コマンドの出力の「ファイル名」の部分はどのような形式で表示されているか、ご存知でしょうか? 普通のファイル名であれば単純にその名前が表示されます。しかしファイル名に空白や特殊な文字が含まれている場合はそうではありません。この記事では ls コマンドのファイル名がどのような形式で表示されているのかを解説したいと思います。
基礎知識
コマンドの出力形式は出力先で変化する
ls コマンドに限りませんが、多くのコマンドは出力先が端末(画面)の場合とそれ以外の場合で出力形式を変えています。端末に出力する時は人間が見やすいようにし、それ以外への出力はデータとしてプログラムから扱いやすいようにするためです。例えば端末に出力するときには色がついていたり、画面幅に応じて自動的に出力を整形したり、時にはエディタやビューワーが起動することもあります。端末以外に出力する場合は文字だけが出力されます。
一般的にはどちらへ出力されているかをプログラムで判別して自動的に出力形式の切り替えが行われますが、多くのコマンドはオプションでも制御できるようになっています。端末出力時に端末以外に出力した場合の形式で出力したい場合は、そういったオプションを指定しても良いのですが、汎用的な方法として cat コマンドへパイプで渡すという方法があります。
$ # 端末(画面)への出力は人間が見やすい形式になる(色もついている)
$ ls /
bin dev home lib64 mnt proc run srv tmp var
boot etc lib media opt root sbin sys usr
$ # cat へ出力するとデータとして扱いやすい形式になる
$ ls / | cat
bin
boot
dev
etc
home
︙
別のプログラムに出力した時に、ファイル名が縦並びになるのは、その方がデータとして扱いやすいからです。シェルスクリプトの言語及び Unix コマンドは行指向(一行に一つのデータがある)を前提に設計されています。ls コマンドの -1 オプションを指定すると行指向のデータとして出力されますが、そのオプションを指定せずともパイプで別のコマンドに出力すると自動的に行指向のデータへと切り替わります。
実はこの挙動は POSIX ls でも標準化されており「デフォルトは一行に一エントリだが、出力が端末である場合は実装定義である」と明確にされています。
The default format shall be to list one entry per line to standard output; the exceptions are to terminals or when one of the -C, -m, or -x options is specified. If the output is to a terminal, the format is implementation-defined.
このように出力先によって出力形式を変えるというのは、データとして使用できる形式と人間の読みやすさを両立させることができる優れた仕組みです。昔はこのような設計は Unix 哲学的ではないとして整形用の別のプログラムを用意すべきだという考えもあったようですが、現実を見ればわかるようにその有効性が認知され広く普及しました。まあ普通に考えてコマンドごとに整形用のプログラムを作るなんてありえないですよね?
POSIX で標準化されているファイル名に関するオプション
POSIX で標準化されている、この記事の内容に関係するオプションは -q オプションだけです。これは表示不可能な文字を ? に置き換えて出力するためのオプションです。POSIX では画面に出力する場合に限り -q オプションの動作がデフォルトであってもよいと規定されています。
準備と注意点
手元で検証したい人は次のような感じでテストファイルを作成してください。ミスすると意図しないファイルを削除しかねないので、なるべく安全に /tmp 以下に任意のディレクトリ (例 /tmp/files) を作成してここで作業をします。後片付けをする時はフルパスを指定して rm -r /tmp/files と実行しディレクトリごと削除します。実行する時は rm の頭にスペースを入れて実行すると(シェルの設定によりますが)コマンド実行の履歴に残らないのであとで間違えて実行してしまうことがありません。また再起動すれば自動で消えるはず(WSL は消えないかもしれません)なので、そのまま削除せずに放置しておいても邪魔にはならないでしょう。
さて検証の準備です。下記のコードを実行するとテストファイルが作成されます。三行目の touch 〜 は「あいう」までがつながっているので注意してください。
$ mkdir /tmp/files
$ cd /tmp/files
$ touch -- "$(printf '\001\002')" "'" -A B "C
C
" "D$(printf '\t')D" '?' '"' "'\"" "foo bar" あいう
ls のファイル名の出力形式の概要
まず ls を実行してファイルの一覧を端末に出力してみます。Linux (GNU 版) で実行するとおそらく以下のような出力が得られるのではないかなと思います。
$ ls
''$'\001\002' "'" -A B 'D'$'\t''D' あいう
'"' ''\''"' '?' 'C'$'\n''C'$'\n' 'foo bar'
一方 macOS (BSD 版) で実行すると以下のように表示されます。
$ ls
?? ' -A B D?D あいう
" '" ? C?C? foo bar
さらに Solaris 11 (SystemV 版) では以下のように表示されます。照合順序(ソート順)を同じにするためにロケールを C にして実行しています。
$ LANG=C ls
' -A B D D あいう
" '" ? C
C
foo bar
いずれも出力結果が異なっていることがわかります。また GNU 版と BSD 版はファイル名をそのまま出力していないことに注意してください。GNU 版は特殊な文字をエスケープして出力しており、BSD 版は特殊な文字を ? に置き換えて出力しています。
端末への出力はいずれも異なっていますが、端末以外に出力する場合はすべて同じです。念の為 MD5 サムチェックを行ってみましたが一致しているので間違いありません。
$ ls | cat
"
'
'"
-A
?
B
C
C
D D
foo bar
あいう
$ ls | md5sum
e8ad1395eee5b4eddaccb161873f68e0 -
$ ls | hexdump -C
00000000 01 02 0a 22 0a 27 0a 27 22 0a 2d 41 0a 3f 0a 42 |...".'.'".-A.?.B|
00000010 0a 43 0a 43 0a 0a 44 09 44 0a 66 6f 6f 20 62 61 |.C.C..D.D.foo ba|
00000020 72 0a e3 81 82 e3 81 84 e3 81 86 0a |r...........|
0000002c
hexdump コマンドの出力から、表示不可能な文字であってもそのまま出力されているということがわかります。
各 ls コマンドの実装について
GNU 版 ls
GNU ls は最も高機能と考えられる ls であり、対応しているエスケープの種類も一番豊富です。クォートのスタイルは --quoting-style オプションもしくは環境変数 QUOTING_STYLE で指定することができます。以下のクォートスタイルに対応しています。
-
literal(-N,--literalと同等) shellshell-always-
shell-escape(デフォルト) shell-escape-always-
c(-Q,--quote-nameと同等) c-maybe-
escape(-b,--escapeと同等) localeclocale
端末に出力するときのデフォルトは shell-escape です。またクォート(+エスケープ)した後に表示不可能な文字が残っている場合は ? に置き換えて出力します。そのためのオプションである -q (--hide-control-chars) がデフォルトで有効になっています。無効にする場合は --show-control-chars オプションを指定します。
ちなみに BSD 版にはエスケープを行うための -B オプションがありますが、GNU 版では -B オプションはエスケープとは全く関係ない別の意味のオプション(~ で始まるバックアップファイルを表示しない)なので注意してください。
クォートスタイルはこちらで詳しく説明されています。この記事では種類ごとに簡単に説明します。
literal
literal (-N, --literal) は文字列をクォートせずにそのまま出力します。ただしデフォルトで -q オプションが指定されているので表示不可能な文字は ? で出力されます。
shell スタイルのエスケープ
shell, shell-always, shell-escape, shell-escape-always はシェルスタイルのクォートです。後ろに -always が付いているものはクォートが必要のない普通の文字列であっても強制的にクォートするものなので、実質 shell と shell-escape の二種類です。
GNU 版 ls のデフォルトのエスケープは shell-escape です。shell と shell-escape の違いは shell が現在の POSIX Issue 7 のシェルの仕様の範囲でのエスケープを行うのに対して、shell-escape は bash, ksh, zsh 等が対応している $'...' 形式(POSIX Issue 8 で新たに標準化される予定)でのエスケープを行うという点です。例えば ''$'\001\002' や 'd'$'\n''d'$'\n' が shell-escape 形式の特徴的な部分です。少し読みづらいので説明すると以下のように読みます。
$ ls --quoting-style shell-escape
''$'\001\002' "'" -A B 'D'$'\t''D' あいう
'"' ''\''"' '?' 'C'$'\n''C'$'\n' 'foo bar'
''$'\001'
⇒ '' $'\001\002' (\001 と \002 はそれぞれ 8 進数表記)
(余談 文頭の '' は余計な空文字で本来は必要ないはず。バグな気がする)
'd'$'\n''d'$'\n'
⇒ 'd' $'\n' 'd' $'\n' (\n は改行)
(余談 $'d\nd\n' のようにつなげて表記することも出来る)
これを shell 形式で出力すると以下のようになります。
$ ls --quoting-style shell
?? '"' "'" ''\''"' -b '?' a 'd?d?' あいう
(余談 ここでも謎の空白が文頭にある。shell, shell-escape 共通のバグの気がする)
ここで表示不可能な文字が ? で出力されているのはデフォルトで有効になっている -q オプションの効果です。実は現在の POSIX シェル (Issue 7) の仕様では表示不可能な文字をエスケープする方法が存在しません。したがって表示不可能な文字はそのまま出力することしかできません。改行も改行のまま出力するしかありません。一般的なエスケープのイメージからは不完全なものであり、これが現時点では POSIX 準拠ではないにも関わらず shell-escape ($'...') 形式をデフォルトにしている理由でしょう。
shell-escape 形式で出力されるファイル名は、シェルのクォート表記として正しいものになっています。つまり ls -al の出力されたファイル名をクォート込みでコピペして実行できるということです。例えば ls で 'foo bar' や 'D'$'\t''D' と表示されているのであれば、bash シェルから touch 'foo bar' や touch 'D'$'\t''D' と入力して実行できます。ただし(現時点では)$'...' に対応してない dash や yash では使えません。また tcsh は POSIX シェルではありませんが、2022 年 2 月にリリースされた 6.24 から $'...' 形式に対応したため、そのまま使えるはずです。
C 言語スタイルのエスケープ
c (-Q, --quote-name), c-maybe, escape (-b, --escape) は C 言語スタイルのエスケープです。違いは以下のとおりです。見れば明らかなので特に説明は不要かなと思います。
$ ls --quoting-style c
"\001\002" "\"" "'" "'\"" "-A" "?" "B" "C\nC\n" "D\tD" "foo bar" "あいう"
$ ls --quoting-style c-maybe
"\001\002" "\"" ' "'\"" -A ? B "C\nC\n" "D\tD" foo bar あいう
$ ls --quoting-style escape
\001\002 " ' '" -A ? B C\nC\n D\tD foo\ bar あいう
C 言語のソースコードに転記するのであれば c、プログラムからパースするのであれば escape が使いやすいように思います。c-maybe はあまり使い所がなさそうな気がします。それが理由なのか ls --help にも man ls にも info ls にも c-maybe は書いていないんですよね。どうやって見つけたかというとスタイル名を間違えたら有効な引数として表示されました。
ロケール系スタイル
locale, clocale は(おそらく)人間のためのクォートなのだと思います。表示不可能な文字のエスケープは C 言語スタイルと同じようで、違いはロケールに応じて適切な(読みやすい)クォートが使われるだけのようです。
$ LANG=ja_JP.UTF-8 ls --quoting-style locale
`\001\002' `"' `\'' `\'"' `-A' `?' `B' `C\nC\n' `D\tD' `foo bar' `あいう'
$ LANG=ja_JP.UTF-8 ls --quoting-style clocale
`\001\002' `"' `\'' `\'"' `-A' `?' `B' `C\nC\n' `D\tD' `foo bar' `あいう'
$ LANG=C.UTF-8 ls --quoting-style locale
‘\001\002’ ‘"’ ‘'’ ‘'"’ ‘-A’ ‘?’ ‘B’ ‘C\nC\n’ ‘D\tD’ ‘foo bar’ ‘あいう’
$ LANG=C.UTF-8 ls --quoting-style clocale
‘\001\002’ ‘"’ ‘'’ ‘'"’ ‘-A’ ‘?’ ‘B’ ‘C\nC\n’ ‘D\tD’ ‘foo bar’ ‘あいう’
$ LANG=C ls --quoting-style locale
'\001\002' '"' '\'' '\'"' '-A' '?' 'B' 'C\nC\n' 'D\tD' 'foo bar' '\343\201\202\343\201\204\343\201\206'
$ LANG=C ls --quoting-style clocale
"\001\002" "\"" "'" "'\"" "-A" "?" "B" "C\nC\n" "D\tD" "foo bar" "\343\201\202\343\201\204\343\201\206"
日本語ロケール (ja_JP.UTF-8) では、ファイル名全体を囲うクォートとして ` と ' のペアが使われました。個人的には(フォントによるかと思いますが)バランスが悪くて見やすいとは思わないです。C.UTF-8 だと ‘ と ’ が使われ、これは見やすいと思います。locale と clocale の違いがあるのはどうも C ロケールだけのようで locale は見栄えが良い(?) ' が使われ clocale では " が使われるとのことですが私にはピンときません。
正直(日本人にとっては?)あまり興味がないスタイルだと思います。画面出力にこだわりたい人には良いのではないでしょうか?
BSD 版 ls
BSD 版は -B または -b で \xxx 形式または C 言語形式のエスケープを行うことができますが、シェル形式のエスケープには対応していません。デフォルトはエスケープせずに出力ですが端末に出力する場合は -q がデフォルトで有効なので、表示不可能な文字は ? に置き換わります。
BSD 版としてまとめて書いていますが、実際には -B および -b が実装されているのは macOS、FreeBSD、NetBSD で OpenBSD にはエスケープに関するオプションはありません(-q は使えます)。
$ ls
?? ' -A B D?D あいう
" '" ? C?C? foo bar
$ ls -b
\001\002 ' -A B D\tD あいう
\" '\" ? C\nC\n foo bar
$ ls -B
\001\002 ' -A B D\011D あいう
\042 '\042 ? C\012C\012 foo bar
Solaris 11 版 ls
Solaris 11 版には -b オプションで \xxx 形式のエスケープが行なえます。-B オプションもありますが GNU 版と同じでエスケープとは全く関係ない別の意味なので注意してください。-q オプションはデフォルトでは有効になっていないため表示不可能な文字はそのまま出力されます。
$ ls
' -A B D D あいう
" '" ? C
C
foo bar
$ ls -b
\001\002 ' -A B D\011D あいう
" '" ? C\012C\012 foo bar
AIX 版 ls
検証環境がないのでドキュメントからの情報です。
-b 印刷できない文字を 8 進の (¥nnn) 表記法で表示します。
HP-UX 版 ls
検証環境がないのでドキュメントからの情報です。
−b 表示できない文字を\dddの形式の8進数で表示します。
BusyBox 版 ls
さて、ちょっと困ったのが BusyBox 版 ls です。まずエスケープを行うためのオプションはありません・・・と思いきや --help で表示されていないだけで -Q オプションをサポートしています。そして POSIX で標準化されている -q オプションもありません。ただしオプションがないだけでデフォルトで有効となっており表示不可能な文字は ? で出力されます。
ここまではいいのですが、どうやら UTF-8 に対応していないようなのです。さらに画面ではなく別のコマンドに出力しても -q オプションの効果は無効になりません。つまり表示不可能な文字と日本語などの文字はすべて ? で表示されてしまい、元の文字を取得する方法がありません。-Q オプションをサポートしていますが、表示不可能な文字をエスケープするのではなくダブルクォート文字を \ でエスケープするだけのようです。ちなみにバージョンは現時点での最新版の 1.35.0 です。
$ busybox ls
?? ' -A B D?D ???
" '" ? C?C? foo bar
$ busybox ls -Q
"??" "'" "-A" "B" "D?D" "???"
"\"" "'\"" "?" "C?C?" "foo bar"
$ busybox ls | cat
??
"
'
'"
-A
?
B
C?C?
D?D
foo bar
???
BusyBox 全体が UTF-8 に対応してないのでは?と思うかもしれませんが、シェルや find コマンドは UTF-8 に対応しています。ソースコードを見るとエスケープに関するコメントがあるので未実装という扱いだと思いますがバグ相当です。せめて画面以外に出力した場合はそのまま出力して欲しいものです。これではデータとして使えません。うーん、どうしようもありませんね。
-Q, -B, -b の移植性と POSIX 標準化の可能性について
C 言語風のエスケープを行う -Q, -B, -b オプションにどれくらい移植性があるのかを詳しく調べてみました。検証では以下のような方法でテストファイルを作成し、各オプションを指定した時にどのように出力されるかを調べました。一つ飛ばしている 47 はファイル名に入れることができない / です。
# ファイルの作成方法
touch "$(printf "$(printf '\\%03o' $(seq 1 46) $(seq 48 127))")"
まず -Q オプションですが、これは GNU と BusyBox でしかサポートされていません。
ls -Q # GNU (--quoting-style c)
# "\001\002\003\004\005\006\a\b\t\n\v\f\r\016\017
# \020\021\022\023\024\025\026\027\030\031\032\033\034\035\036\037
# !\"#$%&'()*+,-.0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ
# [\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\177"
busybox ls -Q
# "???????????????????????????????
# !\"#$%&'()*+,-.0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ
# [\\]^_`abcdefghijklmnopqrstuvwxyz{|}~?"
次に -B オプションですが OpenBSD と BusyBox が対応してないませんが、対応してない分に関しては後から実装すれば良いので将来的に対応可能です。しかし macOS と FreeBSD と NetBSD では表示不可能な文字を必ず \xxx 形式(\n のようなものを使わない)でエスケープするオプションですが、GNU と Solaris 11 では「~ で終了するファイル名(バックアップファイル)を一覧に表示しない」という別の意味のオプションです。互換性を考えると変更するのは無理なので、現在移植性がないだけではなく、将来的にも移植性をもたせることはできません。
$ ls -B # macOS, FreeBSD
# \001\002\003\004\005\006\007\010\011\012\013\014\015\016\017
# \020\021\022\023\024\025\026\027\030\031\032\033\034\035\036\037
# !\042#$%&'()*+,-.0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ
# [\134]^_`abcdefghijklmnopqrstuvwxyz{|}~\177
$ ls -B # NetBSD (スペース \040 が異なる)
# \001\002\003\004\005\006\007\010\011\012\013\014\015\016\017
# \020\021\022\023\024\025\026\027\030\031\032\033\034\035\036\037
# \040!\042#$%&'()*+,-.0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ
# [\134]^_`abcdefghijklmnopqrstuvwxyz{|}~\177
最後に -b オプションです。OpenBSD と BusyBox を除く多くの環境ですでに対応しており、すでにある程度の移植性があるように見えます。ただしそれぞれでエスケープの方法が微妙に異なっています。
ls -b # GNU (--quoting-style escape)
# \001\002\003\004\005\006\a\b\t\n\v\f\r\016\017
# \020\021\022\023\024\025\026\027\030\031\032\033\034\035\036\037
# \ !"#$%&'()*+,-.0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ
# [\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\177
$ ls -b # macOS, FreeBSD
# \001\002\003\004\005\006\a\b\t\n\v\f\r\016\017
# \020\021\022\023\024\025\026\027\030\031\032\033\034\035\036\037
# !\"#$%&'()*+,-.0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ
# [\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\177
$ ls -b # NetBSD
# \001\002\003\004\005\006\a\b\t\n\v\f\r\016\017
# \020\021\022\023\024\025\026\027\030\031\032\033\034\035\036\037
# \s!\"#$%&'()*+,-.0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ
# [\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\177
$ ls -b # Solaris 11
# \001\002\003\004\005\006\007\010\011\012\013\014\015\016\017
# \020\021\022\023\024\025\026\027\030\031\032\033\034\035\036\037
# !"#$%&'()*+,-.0123456789:;<=>@ABCDEFGHIJKLMNOPQRSTUVWXYZ
# [\]^_`abcdefghijklmnopqrstuvwxyz{|}~\177
環境による \a と \010 のような表記の揺れは、どちらも C 言語風のエスケープとして正しいものであり、おそらく大きな問題にはなりません。細かい違いを見てみると GNU ではスペースが \ (バックスラッシュ + スペース)、NetBSD では \s となっており、Solaris 11 は \ が \\ にはなっていないという違いがあります。\ や \sはスペースとみなすと定義すれば GNU と NetBSD はカバーできますが、Solaris 11、これはひどいです。\ を \\ にエスケープしないということは 文字コード \010(改行)と \ 0 1 0 という文字列を区別することができません。
POSIX は互換性を重視するため、Solaris に対して仕様を変更しろと強制はしませんし、Solaris の仕様には明らかに問題がありますが仕様を変更する可能性は低い気がします。また POSIX は基本的に発明をしない(POSIX 主導で新しいオプションを策定しない)ので、新しいエスケープオプションの追加もまず考えられません。
いずれも現状は移植性がありません。-b は惜しいですが Solaris 11 がバグです。残る可能性としては -Q オプションがその他の OS でも実装されればよいのですが、現状では POSIX でファイル名のエスケープを標準化する道のりは遠そうです。
まとめ
ls コマンドは大きく二種類のクォートまたはエスケープのスタイルをサポートしています。一つはシェル形式でもう一つは C 言語形式です。シェル形式をサポートしているのは GNU 版のみで、その他は C 言語形式をサポートしています。例外として、どちらもサポートしていない OpenBSD 版と BusyBox 版があるので注意が必要です。
なぜ ls コマンドはこのような出力形式をサポートしているのでしょうか? その理由は、一つは Linux / Unix はファイル名に制御文字を含めることが出来るため、それを視覚化してわかるようにするためです。そしてもう一つの役割はコンピュータから処理できるようにするためです。
端末以外に出力しない場合は表示不可能な文字は ? に変更されることなくそのまま出力されるのでコンピュータで処理可能であるかのように思えますが、改行が問題になります。Linux / Unix では改行もファイル名に使うことができます。一行一データでファイル名の一覧を出力した時、ファイル名に改行文字が含まれていたら区別が付きません。したがって何らかの方法でエスケープしなければ、改行が含まれているファイル名を扱う事ができません。そんなファイル名を付けるなという話ではあるのですが、実際つけられてしまうのが問題で、悪用されると何かしらの脆弱性を生み出してしまう可能性があります。
POSIX ではファイル名に制御文字を使えないように禁止しようという提案が出ています。しかし制御文字すべてが禁止になるか、改行だけが禁止になるか、却下されるか話の決着はまだ付いていません。また POSIX で禁止したとしても各 OS はすぐに追尾するとは限らず、互換性上の理由から禁止しない可能性もあり、そもそも POSIX で禁止しなかったとしても、現在の OS はその気になれば禁止できるのに禁止してないわけで、現状の問題をすぐに解決する手段にはなりません。
要するにファイル名をコンピュータ、特にシェルスクリプトから処理する場合、ファイル名のエスケープ処理は必要不可欠な機能なのです。この記事は、ファイル名の出力形式を解説するという体で書いていますが、実は私にとっては既存の ls コマンドのファイル名をエスケープして正しく扱う移植可能な方法はあるか?の調査です。-b オプションはそれなりに高い移植性がありますが、 BusyBox や OpenBSD では実装されておらず Solaris 11 という問題児が発覚したので、現時点では完全に移植可能方法はありません。ls コマンドの出力を別のコマンドに渡すのはやっぱりダメだなというのが「シェルスクリプトでlsをパイプでつなぐのはなぜ悪いのか ~ ShellCheck: SC2010, SC2011, SC2012 とファイル名改行問題」の記事となります。