これでGNU lsの出力を解析するのは安全ですか?

これでGNU lsの出力を解析するのは安全ですか?

過去数十年間に受け入れられた見解は、解析ls[1][2])。たとえば、ファイルの変更日と名前をシェル変数に保存する場合、これは正しい方法ではありません。

$ ls -l file
-rw-r--r-- 1 terdon terdon 0 Aug 15 19:16 file
$ foo=$(ls -l file | awk '{print $9,$6,$7,$8}')
$ echo "$foo"
file Aug 15 19:16

この方法は、ファイル名がわずかに異なるたびに失敗します。

$ ls -l file*
-rw-r--r-- 1 terdon terdon 0 Aug 15 19:16 'file with spaces'
$ foo=$(ls -l file* | awk '{print $9,$6,$7,$8}')
$ echo "$foo"
file Aug 15 19:16

ファイルの変更日が今日に近づかないと、時間形式が変更される可能性があるため、状況はさらに悪化します。

$ ls -l
total 0
-rw-r--r-- 1 terdon terdon 0 Aug 15 19:21  file
-rw-r--r-- 1 terdon terdon 0 Aug 15  2018 'file with spaces'

ただし、最新バージョンのGNU coreutilsには、ls特定の時間形式を設定し、NULLで区切られた出力を生成するために組み合わせることができる2つのオプションがあります。

      --time-style=TIME_STYLE
              time/date format with -l; see TIME_STYLE below
[...]
     --zero end each output line with NUL, not newline
[...]
       The TIME_STYLE argument can be full-iso,  long-iso,  iso,  locale,  or
       +FORMAT.   FORMAT  is  interpreted like in date(1).  If FORMAT is FOR‐
       MAT1<newline>FORMAT2, then FORMAT1 applies  to  non-recent  files  and
       FORMAT2  to recent files.  TIME_STYLE prefixed with 'posix-' takes ef‐
       fect only outside the POSIX locale.  Also the  TIME_STYLE  environment
       variable sets the default style to use.

以下は、これらのオプションが設定されたファイルです(読みやすくするために、各出力行の末尾にあるゼロが改行で置き換えられます#)。

$ ls -l --zero --time-style=long-iso -- *
-rw-r--r--+ 1 terdon terdon 0 2023-08-16 21:35 a file with a
newline#
-rw-r--r--+ 1 terdon terdon 0 2023-08-15 19:16 file#
-rw-r--r--+ 1 terdon terdon 0 2018-08-15 12:00 file with spaces#

lsこれらのオプションを使用すると、伝統的に有害であった多くの作業を実行できます。たとえば、

  1. 最後に変更されたファイル名を変数に入力します。

    $ touch 'a file with a'$'\n''newline'
    $ last=$(ls -tr --zero | tail -z -n1)
    bash: warning: command substitution: ignored null byte in input
    $ printf -- 'LAST: "%s"\n' "$last"
    LAST: "a file with a 
    newline"
    
  2. この質問を提起する例です。 Ask Ubuntuのもう1つの質問は、OPがファイル名と変更日を印刷しようとすることです。誰かが投稿しました回答andを使用することは、次に追加すると非常に強力に見える巧妙なlsトリックです。awk--zerols

    $ output=$(ls -l --zero --time-style=long-iso -- * | 
               awk 'BEGIN{RS="\0"}{ t=index($0,$7); print substr($0,t+6), $6 }')
    $ printf 'Output: "%s"\n' "$output"
    Output: "a file with a
    newline 2023-08-16"
    

どちらの例も壊す名前が見つかりません。だから私の質問は次のとおりです。

  1. 上記の2つの例のいずれかが失敗する状況はありますか?何か変なことがあるのではないか?
  2. lsそうでなければ、最新バージョンのGNUが実際に任意のファイル名を使用しても安全であることを意味しますか?

答え1

これでGNU lsの出力を解析するのは安全ですか? (そして--zero

--zero多くの助けになりますが、ここで使用されている方法はまだ安全ではありません。出力形式自体lsと質問の出力を解析するために使用されるコマンドの両方に問題があります。--zero実際に言及されたParsingLs Wikiページにありますが、この例では長い形式を使用していません(おそらくここで問題のためです!)。この回答の多くの質問は、Stéphane Chazelasがコメントで質問したものです。


まず、空白をls -l含むユーザー/グループ名をそのまま印刷し、列数を混乱させるので問題になります(--zeroここでは重要ではありません)。

$ ls -l --time-style=long-iso foo.txt
-rw-rw-r-- 1 foo bar users 0 2023-08-16 16:45 foo.txt

少なくともUIDとGIDを数字で印刷するか、完全に無視する--numeric-uid-gid必要があります。どちらも他の長い形式フィールドも含みます。-n-go

ls引数に表示されるすべてのディレクトリの内容もリストされているので、これを望むこともできます-d

他の列には空白やNULを含めることはできないと思います。

ls -dgo --time-style=long-iso --zero -- *

おそらく安全です。おそらく。

複数のファイルがある場合は、フィールド区切り文字として1つを使用する代わりに列を空白で埋めるため、たとえば出力cutで使用できないため、構文解析はまだ簡単ではありません。これは、--zeroUIDとGIDを使用または省略してパイプに出力する場合にも発生します。これは、ファイルサイズとリンク数が幅によって異なる可能性があるためです。

$ ls -dgo --zero --time-style=long-iso -- *.txt |tr '\0' '\n'
-rw-rw-r-- 21    0 2023-08-16 17:24 bar.txt
-rw-rw-r--  1 1234 2023-08-16 17:30  leading space.txt

ファイル名は右側に追加されないので(異常かもしれません)、タイムスタンプとファイル名の間にスペースがあると仮定するのは安全です。

--time-style=long-isoUTCオフセットは含まれていないため、日付があいまいになる可能性があります。最悪の場合、夏時間の終わりに生成された2つのファイルは、日付を間違った順序で表示する可能性があります。 (ls要求はまだ正しくソートされますが、出力は混乱します。)この点では、--full-time/ --time-style=full-iso(またはカスタム形式)がより良いTZ=UTC0でしょう。明示的に設定すると、日付を文字列として比較するのが簡単になります。

$ TZ=Europe/Helsinki ls -dgo --time-style=long-iso -- *
-rw-rw-r-- 1 0 2023-10-29 03:30 first
-rw-rw-r-- 1 0 2023-10-29 03:20 second

$ TZ=UTC0 ls -dgo --full-time -- *
-rw-rw-r-- 1 0 2023-10-29 00:30:00.000000000 +0000 first
-rw-rw-r-- 1 0 2023-10-29 01:20:00.000000000 +0000 second

$ TZ=UTC0 ls -dgo --time-style=+%FT%T.%NZ -- *
-rw-rw-r-- 1 0 2023-10-29T00:30:00.000000000Z first
-rw-rw-r-- 1 0 2023-10-29T01:20:00.000000000Z second

通常のファイル以外に何かがあると、状況はさらに悪化します。多くの場合、問題にならないかもしれませんが、とにかく次のようになります。

デバイスファイルの場合、lsサイズは印刷されませんが、メイン/マイナーデバイス番号は印刷されます。他のファイルと列数を異なる場合は、カンマとスペースで区切ります。コンマを使用して2つのバリアントを区別できますが、これは解析をより困難にします。

$ ls -dgo --zero --time-style=long-iso -- /dev/null somefile.txt |tr '\0' '\n'
crw-rw-rw- 1  1, 3 2023-07-16 15:37 /dev/null
-rw-rw-r-- 1 12345 2023-08-17 06:14 somefile.txt

その後、長い形式で印刷されるシンボリックリンクがありますが、リンクまたはターゲットlink name -> link target名自体に何を含めることができるかについては言うまでもありません->

$ ls -dgo --zero --time-style=long-iso -- how* what* |tr '\0' '\n'
lrwxrwxrwx 1 14 2023-08-17 06:05 how -> about -> this?
lrwxrwxrwx 1  5 2023-08-17 05:54 what -> is -> this?

まあ、技術的にサイズフィールドはリンク名の長さ(文字ではなくバイト単位)を知らせるようです。

この場合、 --quoting-style=shell-escape-always実際には次のものよりも優れています--zero$''

$ ls -dgo --quoting-style=shell-escape-always --time-style=long-iso -- how* what*  |cat
lrwxrwxrwx 1 14 2023-08-17 06:05 'how' -> 'about -> this?'
lrwxrwxrwx 1  5 2023-08-17 05:54 'what -> is' -> 'this?'

シェルを使用しても構文解析はあまり楽しくありません。


必要なフィールドを明示的に選択できる方が良いでしょうが、そのようなオプションは表示されませんls。 GNU findには-printf安全な出力を生成する機能があります。時間で並べ替えるには、lsタイムスタンプを印刷する必要がなく、//のみをls --zero使用できます-t。下記をご覧ください。 (zsh自体はこれを行うことができますが、Bashはあまり良くありません。)-u-c

タイムスタンプとファイル名が必要な場合は、同様の操作を find ./* -printf '%TY-%Tm-%Td %TT %p\0'実行する必要がありますが、デフォルトではサブディレクトリで繰り返されるので、望ましくない場合は対処する必要があります。たぶん-prune最後に追加することもできます。どちらも--役に立ちませんので、プレフィックスがfind必要です./

たぶんstat --printf簡単にすることができます。


上記の2つの例のいずれかが失敗する状況はありますか?何か変なことがあるのではないか?

質問で使用されるコマンドは、last=$(ls -tr --zero | tail -z -n1)コマンド置換が最後のNLを無視した後の末尾の改行を削除するため、基本的にBashでは安全ではありません。そしてエドモートンが指摘した。ls、出力が安全であっても、少なくとも特定のAWKコマンドが破損しています。

私の考えでは、AWKは最後のフィールド自体にフィールド区切り文字を含めることができる固定数のフィールドを持つ入力には適していないと思います。 Perlsplit()には、生成するフィールドの数を制限する追加のパラメータがありますが、一部(すべてではない)フィールド区切り文字が複数のスペースである場合は、使用するのは簡単ではありません。無邪気な人々はsplit/ +/, $_, 6ファイル名の先行スペースを食べます。この問題とデバイスノードの問題を処理するために正規表現を書くことができますが、これは丸い釘を正方形の穴に押し込むことから始まり、シンボリックリンク出力の問題を解決しません。


長い形式の出力がない場合は、ls --zeroNULで終わる生のファイル名のみを指定する必要があり、出力は安全で解析しやすくなります。

最も古いファイルの場合、$nWikiページには次のものがあります。

readarray -t -d '' -n 5 sorted < <(ls --zero -tr)
# check the number of elements you got

read -rd ''ただ 1 つの場合は、コメントで述べたように would do を使用できます。

IFS= read -rd '' newest < <(ls -t --zero)
# check the exit status or make sure "$newest" is not empty

答え2

GNUの出力にのみ依存している場合は、lsこれはGNU Coreutilsパッケージに依存していることを意味します。これはstat、目的の方法でオブジェクトに関する情報を取得するためのフォーマット文字列を持つ他のCoreutilsユーティリティ、つまり.Statを使用できることを意味します。

たとえば、現在のディレクトリの変更時間を次の形式で印刷しますMMM DD HH:MM

$ echo $(date -d @$(stat --format="%Y" .) +"%b %m %H:%M")
Aug 08 07:57

このコマンドは、オブジェクトの修正時間を10進整数としてstat --format=%Y .取得します.。これは、エポック以降のおなじみの秒数を表します。

プレフィックスを引数(GNU Coreutilsの機能)@として補間し、コードを使用して必要な形式で時間を取得します。-ddatedatestrftime

残念ながら、日付形式を指定する組み込み方法はstatありません。strftime複数の呼び出しなしで変更時間を含む複数のフィールドの情報を取得するには、複数フィールドの行をstat印刷してからその行を解析する必要があります。これは、傷のある出力よりもまだ優れた測定ですls。最大効率が重要でない場合(もしそうであれば、なぜBashでコーディングするのですか?)、複数の呼び出しが困難になる可能性がありますstat

stat修正時間が最も古いファイルを検索するために使用できないという説明がコメントに書き込まれました。stat単独ではできないのが事実だがstat実際にはls -1t

$ for x in *.txt ; do stat --format="%Y %n" "$x" ; done | sort -n | head -1
1328379315 readme-mt.txt

この文書はかなり前にさかのぼります。

$ date -d @1328379315
Sat Feb  4 10:15:15 PST 2012

今私たちが持っている問題は、名前に改行文字が含まれているとソートがめちゃくちゃになるということです。我々はそれを使用することができますls

たとえば、名前を Bash 配列として読み込み、名前の代わりに配列インデックスを使用してタイムスタンプを印刷できます。出力では、sort -n | head -12番目のフィールドが最後に変更されたファイル名の配列インデックスを提供するエントリを取得します。

ls私たちは、何らかの方法で解析する必要があるエンコードされたスペースと改行文字で出力を処理する問題を完全に回避できます。

$ array=(*.txt)
$ for x in ${!array[@]}; do 
>   printf "%s %s\n" $(stat --format="%Y" "${array[$x]}") $x 
> done | sort -n | head -1
1328379315 29
$ echo "${array[29]}"
readme-mt.txt

array[29]*.txt名前がどの文字で構成されているかに関係なく、見つかった30番目のファイルが保存されます。私たちのsort仕事は名前を見ることができないので、これは影響を受けません。

したがって、質問に答えるために、GNU lsには出力をより安全に解析する機能がいくつかありますが、シェル言語では出力を安全に解析することはまだ簡単ではありません。

popen("ls ...", "r")GNU lsは、正しいオプションと正しい解析ロジックを使用するCプログラムで安全に使用できますls

「クロールしない」ルールの出力はlsスクリプトコンテキストにあります。

答え3

質問の最後の例のコードを見ると、次のようになります。

ls -l --zero --time-style=long-iso -- * | 
    awk 'BEGIN{RS="\0"}{ t=index($0,$7); print substr($0,t+6), $6 }'

lsコマンドのサンプル出力を公開しました(#<newline>より良い可視性のためにNULを置き換えます)。

$ ls -l --zero --time-style=long-iso -- *
-rw-r--r--+ 1 terdon terdon 0 2023-08-16 21:35 a file with a
newline#
-rw-r--r--+ 1 terdon terdon 0 2023-08-15 19:16 file#
-rw-r--r--+ 1 terdon terdon 0 2018-08-15 12:00 file with spaces#

$7タイムスタンプのように見えるはずです。その場合t=index($0,$7)、1ワードより長いユーザー名/グループについては失敗します。例:

-rw-r--r--+ 1 terdon Domain Users 0 2023-08-15 19:16 file#

その時点から、タイムスタンプは$8代わりに(またはユーザー名および/またはグループに含まれる単語の数に応じてより高い数字)になります$7

ユーザー名/グループを含めることができない場合は、特定のフィールドを見つけるのではなく、行の最初の項目だけを見つけて:問題を解決できます。:

ls -l --zero --time-style=long-iso -- * | 
    awk -v RS='\0' 'p=index($0,":") { print substr($0,p+4), substr($0,p-13,10) }'

または、GNU awk(おそらく使用しているRS='\0')を使用して、3番目の引数を次のように設定しますmatch()

ls -l --zero --time-style=long-iso -- * | 
    awk -v RS='\0' 'match($0,/(.{10}) ..:.. (.*)/,a) { print a[2], a[1] }'

関連情報