この記事の狙い
ファイル名に空白・タブ・改行・ワイルドカードが含まれても壊れない“NUL セーフ”な処理系を作る方法をまとめます。
中核は find -print0 ↔ xargs -0、シェル側は read -r -d '' / mapfile -d '' です。
前提と対象
- Bash 4+/
set -Eeuo pipefail推奨 - POSIX ではファイル名に NUL(\0)だけが禁止。つまり改行は合法 → 改行区切りは危険
- GNU/BSD どちらの
find/xargsも-print0/-0をサポート(古環境は要確認)
TL;DR(最小実装・コピペ可)
#!/usr/bin/env bash
set -Eeuo pipefail
# 1) 生成: NUL 区切りで列挙
find . -type f -print0
# 2) 消費: xargs -0 で安全に引き渡す
find . -type f -print0 | xargs -0 -I{} printf '<%s>\n' "{}"
# 3) Bash で読む: read -r -d '' / mapfile -d ''
while IFS= read -r -d '' path; do
printf 'FILE: %q\n' "$path"
done < <(find . -type f -print0)
なぜ NUL 区切りか
- 改行区切りだと、
"foo\nbar"や先頭-、*を含むファイル名で崩壊 \0はファイル名に現れない唯一のバイト → 区切りとして絶対衝突しないfind -print0は各パス末尾に\0、xargs -0は\0を区切りに引数化
主要レシピ
1) find → xargs の王道
# 拡張子 .log を gzip 圧縮(並列、上書き安全)
find logs -type f -name '*.log' -print0 \
| xargs -0 -P4 -n1 -I{} sh -c '
set -Eeuo pipefail
gzip -n -9 -- "{}"
'
-P4で並列、-n1で 1 ファイルずつ-I{}で引数は必ずクォートして受ける("{}")- 可能なら
-exec ... {} +も検討(プロセス数削減)。ただし安全なログや分岐を入れたい時はxargsが書きやすい
2) while で逐次処理(Bash)
while IFS= read -r -d '' p; do
# p を安全にログ
printf '-> %s\n' "${p@Q}" >&2 # Bash 4.4+ は @Q
# 処理本体
cp -- "$p" /backup/
done < <(find /data -type f -print0)
3) 配列 → NUL → 外部コマンド
files=("a b" "c"$'\n'"d" "--weird*name")
printf '%s\0' "${files[@]}" | xargs -0 -I{} sha256sum -- "{}"
4) NUL に対応した GNU ツール群
sort -z/uniq -z/grep -z/sed -z/tr -d '\0'- 例:重複ファイル名(理論上)を NUL 単位でユニーク化
find . -type f -print0 | sort -z | uniq -z | xargs -0 -I{} echo "{}"
BSD 系では
-zがないコマンドもあるため、Bash 側で読む(read -d '')アプローチを優先
5) 先頭 - 対策とワイルドカード保護
常に -- とクォートを併用:
xargs -0 -I{} rm -- "{}"
xargs -0 -I{} grep -H -- 'pattern' "{}"
6) rsync / git などの “from0” 連携
NUL 区切りのリストを直接渡せるツールは中間壊れが起きない:
# rsync: --files-from と組み合わせ
printf '%s\0' ./a ./b$'\n'c | rsync -a --from0 --files-from=- ./ dest/
# git: -z フラグ
git ls-files -z | xargs -0 -I{} git blame -- "{}"
設計指針(原則)
- 「境界は NUL」の前提で統一:生成も消費も
\0 - ログは stderr、パラメータはクォート済み表示(
%q/@Q) --でオプション終端、"{}"で引数安全化- NUL 非対応のコマンドにどうしても渡す時は、Bash ループで 1 件ずつ渡す
- 並列化は
xargs -Pか&+wait。出力先の競合回避を先に設計
ありがちな落とし穴 → 回避
| 症状 | 原因 | 解決 |
|---|---|---|
| スペース・改行入りで壊れる | 改行区切り | -print0 / -0 / -d '' を使う |
-rf と解釈されて削除 | 先頭 - のファイル名 | -- を必ず挟む |
| グロブが展開される | 未クォート | "{}" / "$var" を徹底 |
BSD で grep -z が無い | 実装差 | Bash で読む(read -d '')or 代替ツール |
| 文字化け | ロケール差 | LC_ALL=C で比較/ソートを局所化 |
“コピペ可”テストブロック(最小)
#!/usr/bin/env bash
set -Eeuo pipefail
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
cd "$TMP"
# 問題児ファイルを作成
touch "a b" "--opt" "c"$'\n'"d" "*star?"
# 1) find -print0 | xargs -0 で全件拾えるか(カウント)
n1="$(find . -maxdepth 1 -type f -print0 | xargs -0 -I{} printf 'X\n' | wc -l)"
[[ "$n1" -eq 4 ]] || { echo "xargs-0 fail: $n1"; exit 1; }
# 2) Bash の read -d '' で全件読めるか
cnt=0
while IFS= read -r -d '' p; do ((cnt++)); done < <(find . -maxdepth 1 -type f -print0)
[[ "$cnt" -eq 4 ]] || { echo "read-d0 fail: $cnt"; exit 1; }
# 3) 危険名でも rm できる(-- とクォートで)
find . -maxdepth 1 -type f -print0 | xargs -0 -I{} rm -- "{}"
[[ "$(find . -maxdepth 1 -type f | wc -l)" -eq 0 ]] || { echo "rm fail"; exit 1; }
echo "PASS"
実務のTips
- 長いパイプラインは可観測性を:
set -o pipefail、logger併用、tee >(…)で分岐ログ - 一時ファイルは避けたい → まずは NUL パイプでつなぐ。どうしても必要な時だけ
mktemp - 大量 I/O の最適化:
xargs -P$(nproc)、コマンドが複数引数対応なら-nを増やして起動回数削減 - NFS やリモート FS:
findはローカルでリスト化 → 転送ツールへ--from0で渡す
互換性と移植性
- macOS/BSD でも
find -print0/xargs -0は利用可 - GNU の
sort -zなど-z系は GNU 依存が多い → Bash ループで代替可能 - POSIX 互換シェルでは
read -d ''は不可(Bash 必須)。ポータビリティ優先ならxargs -0に寄せる
セキュリティと安全設計
- 外部入力のパスは再評価しない(
eval禁止) - ログへパスを出す際は マスクやクォート表示(
%q/@Q) - 破壊的操作(
rm等)は**--** とset -o noclobber等で二重安全化。重要処理はドライランを先に
