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

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の下書きは、速く、たくさん出ます。そのぶん、貼り込んだ経路で混ざったものを、目では見分けられません。商品の数が増えるほど、「見た目は合っている」という確認の価値は下がっていきます。

出典

この記事をシェア