納品用シェルスクリプトを2回壊した|CRLFと実行権限をZIP見本で検査する

ファイルは目の前にあるのに、./setup.shはcannot execute: required file not foundで終了コード127を返しました。中身は1文字も壊していません。変わったのは改行コードだけです。
AIに書かせたスクリプトをZIPにして配る場面を考えていました。チャット画面から貼り付け、Windowsのエディタで直し、ZIPに詰めて渡す。この経路のどこかで、手元では動くのに受け取った側で動かない状態が生まれそうだと思ったのが、試した理由です。ここで使ったのは3行のsetup.shの合成見本で、AIが生成したものではありません。改行の変換もsedで再現しただけです。
手元では動く状態を先に固定する
見本は#!/bin/bashで始まり、echoを1行だけ実行します。権限は755です。
mkdir -p crlf-lab/pack && cd crlf-lab
cat > pack/setup.sh <<'SH'
#!/bin/bash
# 納品用セットアップ(見本)
echo "セットアップ完了"
SH
chmod 755 pack/setup.sh
./pack/setup.sh; echo "exit=$?"; file pack/setup.sh
セットアップ完了
exit=0
pack/setup.sh: Bourne-Again shell script, Unicode text, UTF-8 text executable
ここまでは順調です。問題は、この「動いた」だけを根拠に出荷してしまうこと。今回外した前提はそこでした。
1回目の破損 改行をCRLFにする
sed 's/$/\r/'で全行の末尾にCRを足し、Windows系のエディタで保存した状態を作りました。
mkdir crlf && sed 's/$/\r/' pack/setup.sh > crlf/setup.sh && chmod 755 crlf/setup.sh
file crlf/setup.sh | cut -d: -f2
head -1 crlf/setup.sh | od -c | head -1
./crlf/setup.sh; echo "exit=$?"
Bourne-Again shell script, Unicode text, UTF-8 text executable, with CRLF line terminators
0000000 # ! / b i n / b a s h \r \n
bash: ./crlf/setup.sh: cannot execute: required file not found
exit=127
出力の先頭に付くシェル名と行番号は環境によって変わるので省いています。
1行目が#!/bin/bash\rになっています。LinuxのカーネルはCRまで含めて/bin/bash\rというインタプリタを探し、見つけられません。execve(2)のマニュアルは、スクリプトのインタプリタが存在しない場合にENOENTを返すと書いています。ファイルが無いと言われているように読めますが、見つからないのはスクリプト本体ではなくインタプリタ側。読み違えやすいエラーです。
bash crlf/setup.shのようにbashへ直接渡すと、スクリプトは動きます。ただし出力の末尾は\r \nになりました。画面では見えません。この余分なCRが、ファイル名や値の比較に入り込んだら何が起きるのか。今回は試していません。
2回目の破損 実行権限が消えたZIP
次はZIPです。
私は「PythonのZIP作成は実行権限を落とす」と思い込んでいました。そこで、同じ見本を2通りの方法で詰めました。コードは畳んでおきます。
ZIPを2通りで作ったコードを開く
import zipfile
data = open('pack/setup.sh', 'rb').read()
with zipfile.ZipFile('py.zip', 'w') as z:
z.write('pack/setup.sh', 'setup.sh') # ファイルを渡す
with zipfile.ZipFile('ws.zip', 'w') as z:
z.writestr('setup.sh', data) # 名前と中身だけ渡す
unzip -Z py.zip | sed -n 3p
unzip -Z ws.zip | sed -n 3p
mkdir x2 && unzip -q ws.zip -d x2 && ./x2/setup.sh; echo "exit=$?"
-rwxr-xr-x 2.0 unx 86 b- stor 26-Oct-10 08:02 setup.sh
?rw------- 2.0 unx 86 b- stor 26-Oct-10 08:02 setup.sh
bash: ./x2/setup.sh: Permission denied
exit=126
予想は半分外れました。ZipFile.write()で作ったZIPは755のまま残り、展開しても実行できます。落ちたのはwritestr()に名前と中身だけを渡した方で、権限は600、受け取った側ではPermission deniedです。Python 3.13.5での観察で、他のバージョンは見ていません。公式文書には、ファイルシステム上のファイルからZipInfoを作るZipInfo.from_file()があります。メモリ上でZIPを組み立てる処理はwritestr()に寄りがちです。踏みやすいのはこちらだと感じました。
販売前に回す検査
2つの破損は、ZIPを開けば機械的に判定できます。先頭が#!のファイルについて、1行目のCR、本文のCRLF、実行ビットの3点を見る小さな検査を書きました。
check_pack.pyの全文を開く
import sys, zipfile
bad = 0
for path in sys.argv[1:]:
with zipfile.ZipFile(path) as z:
for info in z.infolist():
data = z.read(info)
if not data.startswith(b'#!'):
continue
mode = (info.external_attr >> 16) & 0o777
problems = []
if b'\r' in data.split(b'\n', 1)[0]:
problems.append('shebang CRLF')
elif b'\r\n' in data:
problems.append('CRLF')
if not mode & 0o111:
problems.append(f'no exec bit (mode {mode:o})')
print(f'{path}:{info.filename}', 'NG ' + ', '.join(problems) if problems else 'OK')
bad += bool(problems)
sys.exit(1 if bad else 0)
CRLF版を755で包んだcrlf.zipも加えて、4つのZIPに掛けました。
python3 check_pack.py py.zip py_ok.zip ws.zip crlf.zip; echo "exit=$?"
py.zip:setup.sh OK
py_ok.zip:setup.sh OK
ws.zip:setup.sh NG no exec bit (mode 600)
crlf.zip:setup.sh NG shebang CRLF
exit=1
py_ok.zipはZipInfoのexternal_attrに0o100755 << 16を入れて作ったものです。2件のNGは、どちらも手元の実行テストでは気づけません。
検査を書いて分かったのは、見るべき場所が手元のファイルではなく、完成したZIPの中だということです。ファイルを直しても、詰め直す時に権限が落ちれば元に戻ります。だから検査は最後の梱包の後に置きます。梱包の前にfileで見るだけでは、2件目の破損は素通りしていました。
直して、展開し直して走らせる
dos2unixでCRを除き、ZipInfoで755を明示して詰め直しました。検査だけで終わらせず、新しい展開先で実行するところまで見ます。
cp crlf/setup.sh fixed_setup.sh && dos2unix fixed_setup.sh
python3 - <<'PY'
import zipfile
i = zipfile.ZipInfo('setup.sh', (2026, 10, 10, 0, 0, 0)); i.external_attr = 0o100755 << 16
with zipfile.ZipFile('fixed.zip', 'w') as z:
z.writestr(i, open('fixed_setup.sh', 'rb').read())
PY
python3 check_pack.py fixed.zip; echo "exit=$?"
mkdir x3 && unzip -q fixed.zip -d x3 && ./x3/setup.sh; echo "exit=$?"
dos2unix: converting file fixed_setup.sh to Unix format...
fixed.zip:setup.sh OK
exit=0
セットアップ完了
exit=0
使ったのはPython 3.13.5、UnZip 6.00、dos2unix 7.5.2、GNU bash 5.2.37です。
まだ納得していないこと
この検査が見るのは、先頭が#!のファイルのCRLFと実行ビットだけです。Windowsで開く購入者は、実行ビットを気にしません。そもそも.shを渡す相手はWindowsなのか、macOSやLinuxなのか。売る形が変わる部分で、見本の検査では答えが出ません。
また、unzipは権限を復元しますが、ブラウザやOS標準の展開機能がどう扱うかは試していません。実際に売るなら、配布先と同じ種類の環境で展開して走らせる一手間が要ります。私は今のところ、この検査を出荷前の最後の1回に置くつもりです。順番を間違えたくありません。
出典
- Linux man-pages: execve(2)(スクリプトの1行目、実行権限、インタプリタが無い場合のENOENT)
- Python公式文書: zipfile(
ZipInfo.from_file()、external_attr)


