READMEから貼ったコマンドが command not found になる。見えない文字を探す検査を2回作り直した

見た目はecho helloなのに、bashはcommand not foundを返しました。しかも2行とも、別の理由でです。
bash: line 1: echoM-BM- hello: command not found
bash: line 1: echoM-bM-^@M-^K: command not found
この出力はcat -vで見えない文字を可視化したものです。M-BM-はNBSP(改行しない空白)、M-bM-^@M-^Kはゼロ幅スペースの、UTF-8での姿です。購入者がREADMEからコマンドをコピーして、この2つのどちらかが混ざっていたら、同じ画面を見ているのに動きません。サポート窓口は「見た目は合っています」から始まる会話になります。
AIの出力でこれが起きた実例は、この記事には入れていません。今回のサンプルは、混入を手で作った合成データです。それでも試したのは、AIの下書きをチャット画面やWebページ経由で商品のREADMEへ貼る流れが、珍しくないからです。経路のどこかで見えない文字が入っても、不思議ではありません。
まず壊れたREADMEを作る
見本は2つです。sample_goodは正常なREADMEで、sample_badには5種類の見えない文字を入れました。$TMPDIRは、その場で消してよい一時ディレクトリです。
- ゼロ幅スペース(U+200B): 商品名の途中と、コマンドの直後
- NBSP(U+00A0):
echoとhelloの間 - 右から左への上書き(U+202E)と、その終了(U+202C): 請求書の行
- ハングル・フィラー(U+3164): 見出しのように見える空行
5種類に分けたのは、起こる事故が別々だからです。ゼロ幅スペースは、検索や照合を外します。NBSPは、コマンドを壊します。右から左への上書きは、並びを入れ替えます。請求書の行で言えば、文字列の実体はfdp.書求請なのに、対応した画面では「請求書.pdf」のように見えます。拡張子の見え方まで変わるので、添付ファイルの名前に混ざると厄介です。ハングル・フィラーは、空白のふりをした文字です。見出しの下に余白があるつもりで、実際には1文字入っています。
sample_goodには、家族の絵文字を1つ入れてあります。この絵文字は、人の絵文字3つをゼロ幅結合子(U+200D)でつないだ1文字です。見えない文字ですが、壊れたものではありません。これが後で効いてきます。
見本を作るスクリプト(make_samples.py)
from pathlib import Path
ZWSP = "\u200b" # ZERO WIDTH SPACE
NBSP = "\u00a0" # NO-BREAK SPACE
RLO = "\u202e" # RIGHT-TO-LEFT OVERRIDE
PDF = "\u202c" # POP DIRECTIONAL FORMATTING
HANGUL = "\u3164" # HANGUL FILLER
good = Path("sample_good")
bad = Path("sample_bad")
for d in (good, bad):
d.mkdir(exist_ok=True)
(good / "README.md").write_text(
"# 副業チェックシート\n"
"\n"
"セットアップ:\n"
"\n"
"```bash\n"
"echo hello\n"
"```\n"
"\n"
"家族向けの例: 👨\u200d👩\u200d👧 の絵文字は部品の間に不可視の結合文字が入ります。\n"
"全角スペースの段落 は日本語では普通です。\n",
encoding="utf-8",
)
(bad / "README.md").write_text(
"# 副業チェックシート\n"
"\n"
f"商品名: 副業チェック{ZWSP}シート\n"
"\n"
"セットアップ:\n"
"\n"
"```bash\n"
f"echo{NBSP}hello\n"
f"echo{ZWSP} hello\n"
"```\n"
"\n"
f"請求書: {RLO}fdp.書求請{PDF}\n"
f"{HANGUL}\n",
encoding="utf-8",
)
print("made", sorted(str(p) for p in Path(".").glob("sample_*/*")))
cd "$TMPDIR/invisible-lab"
python3 --version
python3 make_samples.py
grep -c '商品名: 副業チェックシート' sample_bad/README.md
Python 3.13.5
made ['sample_bad/README.md', 'sample_good/README.md']
0
最後のgrepの結果が0件なのは、まず気持ち悪いところです。画面では「商品名: 副業チェックシート」と読めます。でも商品名の途中にゼロ幅スペースがあるので、検索には当たりません。READMEの商品名で売上CSVや注文メモを照合する運用なら、ここで一致しなくなります。
1回目の検査は、Unicodeの「Cf」だけを見た
Pythonのunicodedata.category()は、文字の一般カテゴリを返します。ゼロ幅スペースも双方向制御もCf(書式制御文字)に属するので、Cfを全部報告すれば足りると考えました。
検査の核は、各文字にunicodedata.category(ch) == "Cf"を問う1行です。これをcheck_v1.pyにして、2つのREADMEへかけました。
python3 check_v1.py sample_good/README.md sample_bad/README.md
echo "exit=$?"
sample_good/README.md:9: U+200D ZERO WIDTH JOINER
sample_good/README.md:9: U+200D ZERO WIDTH JOINER
sample_bad/README.md:3: U+200B ZERO WIDTH SPACE
sample_bad/README.md:9: U+200B ZERO WIDTH SPACE
sample_bad/README.md:12: U+202E RIGHT-TO-LEFT OVERRIDE
sample_bad/README.md:12: U+202C POP DIRECTIONAL FORMATTING
found=6
exit=1
6件出ました。でも中身を数えると、検査は2種類の間違いをしています。
- 誤検出が2件: 正常な
sample_goodの家族絵文字を、ゼロ幅結合子として止めました - 見落としが2件:
sample_badの8行目(NBSP)と13行目(ハングル・フィラー)が出ていません
見落としの原因は、どちらもCfではないことです。NBSPは空白区切り文字のZs、ハングル・フィラーは文字のLoに分類されます。しかもNBSPは、冒頭でcommand not foundを起こした文字の1つです。事故につながる文字を、1回目の検査は通していたことになります。
全部ASCIIに寄せる案もあります。でも日本語の商品説明では無理があります。次に、正規化で空白を直せないかと、NFKCを試しました。NBSP、全角数字、㈱を含む1行の文字列をunicodedata.normalize("NFKC", ...)に通して、変換前後のascii()表示を見比べています。
NBSPは通常の空白になりました。でもゼロ幅スペース(\u200b)は残り、価格の全角数字は半角の「1,980」に、「㈱」は「(株)」に書き換わりました。商品説明を勝手に書き換える道具は、検査としては強すぎます。正規化で直すのはやめて、見つけて人が判断する形にしました。
2回目: 空白もどきを足し、絵文字の結合は通す
直し方は3つです。
Cfに加えて、Zsのうち普通の半角スペース(U+0020)と全角スペース(U+3000)以外を報告する- 空白に見える文字の短い一覧(ハングル・フィラー、点字の空白など)を足す
- ゼロ幅結合子は、前後が絵文字側の文字のときだけ通す
3つ目の条件は雑です。前後の文字のコードがU+1F000以上か、絵文字の表示指定(U+FE0F)なら、絵文字の一部とみなしています。ほかの言語でゼロ幅非結合子が正当に使われる文章は、このままでは誤検出します。日本語の商品説明だけを対象にする、と割り切りました。
もう1つ、入力が悪いときも止めます。パスが存在しない、検査するファイルが0件、UTF-8として読めない、のどれでも終了コード2で止めます。ここを通すと「0件だから合格」と読める出力になってしまうからです。
検査スクリプト全文(check_invisible.py)
from __future__ import annotations
import sys
import unicodedata
from pathlib import Path
SUFFIXES = {".md", ".txt", ".csv", ".json", ".html", ".sh", ".py"}
BLANK_LOOKALIKES = {0x3164, 0x2800, 0x115F, 0x1160, 0xFFA0}
ZWJ = 0x200D
VS16 = 0xFE0F
def is_emoji_side(ch: str) -> bool:
return ord(ch) >= 0x1F000 or ord(ch) == VS16
def problems(line: str) -> list[tuple[int, str]]:
out = []
for i, ch in enumerate(line):
cp = ord(ch)
cat = unicodedata.category(ch)
if cp == ZWJ and 0 < i < len(line) - 1:
if is_emoji_side(line[i - 1]) and is_emoji_side(line[i + 1]):
continue
if cat == "Cf" or cp in BLANK_LOOKALIKES or (cat == "Zs" and cp not in (0x20, 0x3000)):
out.append((i + 1, f"U+{cp:04X} {unicodedata.name(ch, '?')}"))
return out
def files_from(args: list[str]) -> list[Path]:
paths: list[Path] = []
for arg in args:
p = Path(arg)
if not p.exists():
print(f"ERROR: {arg}: not found", file=sys.stderr)
raise SystemExit(2)
if p.is_dir():
paths += sorted(q for q in p.rglob("*") if q.is_file() and q.suffix in SUFFIXES)
else:
paths.append(p)
return paths
def main(args: list[str]) -> int:
paths = files_from(args)
if not paths:
print("ERROR: no text files to scan", file=sys.stderr)
return 2
found = 0
for path in paths:
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError as err:
print(f"ERROR: {path}: not valid UTF-8 ({err.reason})", file=sys.stderr)
return 2
for lineno, line in enumerate(text.splitlines(), 1):
for col, what in problems(line):
found += 1
print(f"{path}:{lineno}:{col}: {what}")
print(f"scanned={len(paths)} found={found}")
return 1 if found else 0
raise SystemExit(main(sys.argv[1:]))
python3 check_invisible.py sample_good; echo "exit=$?"
python3 check_invisible.py sample_bad; echo "exit=$?"
scanned=1 found=0
exit=0
sample_bad/README.md:3:12: U+200B ZERO WIDTH SPACE
sample_bad/README.md:8:5: U+00A0 NO-BREAK SPACE
sample_bad/README.md:9:5: U+200B ZERO WIDTH SPACE
sample_bad/README.md:12:6: U+202E RIGHT-TO-LEFT OVERRIDE
sample_bad/README.md:12:14: U+202C POP DIRECTIONAL FORMATTING
sample_bad/README.md:13:1: U+3164 HANGUL FILLER
scanned=1 found=6
exit=1
正常なREADMEは0件。壊れたREADMEは、仕込んだ6か所が全部、行と桁つきで出ました。最初に動かなかった8行目のNBSPも入っています。
悪い入力でも止まるか
検査が通ったときの0件は、「見つからなかった」と「見ていなかった」の両方で起こります。次の4通りで、後者を確かめました。
python3 check_invisible.py nothing_here; echo "exit=$?"
mkdir -p empty_dir
python3 check_invisible.py empty_dir; echo "exit=$?"
python3 check_invisible.py; echo "exit=$?"
mkdir -p sjis_dir
printf '\x93\xfa\x96\x7b\x8c\xea\n' > sjis_dir/README.md
python3 check_invisible.py sjis_dir; echo "exit=$?"
ERROR: nothing_here: not found
exit=2
ERROR: no text files to scan
exit=2
ERROR: no text files to scan
exit=2
ERROR: sjis_dir/README.md: not valid UTF-8 (invalid start byte)
exit=2
存在しないパス、空のディレクトリ、引数なし、Shift_JISで書かれたファイル。どれも合格になりません。
終了コードを1と2に分けたのは、呼ぶ側の判断が違うからです。1は「READMEに直すべき文字がある」、2は「検査そのものが成立していない」。後者を1と同じ扱いにすると、パスの打ち間違いで止まったときに、READMEが悪いのだと思い込んで時間を使います。
直したあと、本当にコマンドが動くか
検査が通るだけでは、READMEの価値は変わりません。壊れたREADMEから、問題の文字だけを取り除いたsample_fixedを作り、検査とコードブロックの実行を続けて確認しました。
python3 check_invisible.py sample_fixed; echo "exit=$?"
sed -n '/^```bash/,/^```/p' sample_fixed/README.md | grep -v '^```' | bash
echo "exit=$?"
scanned=1 found=0
exit=0
hello
hello
exit=0
2行ともhelloと表示されました。検査の合格だけでなく、コードブロックの実行まで確かめます。そうすれば、検査が実際の事故に対応しているか分かります。
この記事の原稿にも、同じ検査をかけました。コードブロックの中に、壊れた文字そのものを書いていないか。結果はscanned=1 found=0、終了コード0でした。
販売前に回すなら
運用案は小さなものです。1回目の失敗が教えてくれたのは、「見えない」という性質で文字を括らないほうがよい、ということでした。見えない文字には、害のあるものと、絵文字のように必要なものが混ざります。カテゴリだけで線を引くと、必ずどちらかを取りこぼします。
2回目の検査は、まだ穴だらけです。それでも、誤検出と見落としを実行結果で数えられる形にしたことで、次に直す場所が決まりました。直した検査は、次に試すときも、必ず正常なREADMEにかけます。止めすぎる検査は、そのうち誰も回さなくなります。
商品フォルダをcheck_invisible.pyに渡し、終了コードが0以外なら、ZIPへ入れる手前で止めます。見つかった文字は自動で消さず、行と桁を見て判断します。出力に行と桁を付けたのは、そのためです。エディタで「3行目の12桁目」へ移動すれば、問題の文字の手前まで行けます。文字が見えなくても、場所が分かれば直せます。
もう1つ、この検査が見ない範囲も書いておきます。ZIPの中のファイルは開きません。PDFやWord、画像に埋め込まれた文字も対象外です。今回の対象は、READMEや商品説明のように、人がコピーして使う素のテキストだけに絞りました。範囲を絞ると、出力の1行ごとに「直すべき文字」だと言えます。広げるなら、ファイル形式ごとに別の検査を足すほうが、意味がぼやけません。商品名の途中のゼロ幅スペースなら削る、NBSPなら半角スペースに戻す、請求書の行の双方向制御なら意図したものか確認する、という順番です。
検査の穴が2つ残っています。1つは、絵文字の判定が雑なことです。もう1つは、見た目が似た別の文字(キリル文字のНなど)を、この検査が見ないことです。Trojan Sourceの解説サイトは、見えない制御文字とは別に、似た文字を使う変種も挙げています。次に試すなら、そちらを別の検査として足せます。
AIの下書きは、速く、たくさん出ます。そのぶん、貼り込んだ経路で混ざったものを、目では見分けられません。商品の数が増えるほど、「見た目は合っている」という確認の価値は下がっていきます。
出典
- Python公式文書: unicodedata(文字の一般カテゴリと名前の取得)
- Trojan Source: Invisible Source Code Vulnerabilities(双方向の制御文字による表示順の入れ替えと、似た文字を使う変種。CVE-2021-42574)
- GitHub Changelog: Warning about bidirectional Unicode text(双方向Unicodeを含むファイルへの警告表示)


