Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Encoding văn bản: BOM, UTF-8 nghiêm ngặt, CP932 và độ dài tính bằng byte

Câu hỏi bài này trả lời: nhận một chuỗi byte không ghi sẵn encoding thì nhận diện thế nào cho đúng, CP932 khác Shift_JIS ở đâu, byte 0x5C nằm trong ký tự đa byte làm hỏng những việc gì, và vì sao “dài bao nhiêu ký tự” khác “dài bao nhiêu byte”?

Cần biết trước: Python cơ bản (bytes và str). Lab dùng thư viện chuẩn của Python 3.14.4 trên macOS arm64 với chuỗi giả tự sinh, không có tệp thật. Bài lấy tiếng Nhật làm ví dụ vì CP932 là trường hợp khó điển hình; kết luận về byte và ký tự áp dụng cho mọi encoding đa byte.

Byte không tự mang tên encoding

Một str là dãy ký tự (điểm mã Unicode); thứ nằm trong tệp hay trên đường truyền là bytes, và encoding là phép ánh xạ giữa hai thứ đó. Khi người gửi không nói encoding, nhận diện chỉ là đoán, trừ khi có chữ ký ở đầu dữ liệu: BOM (byte order mark). Chuẩn Encoding của WHATWG dùng ba chữ ký khi dò: EF BB BF là UTF-8, FE FF là UTF-16 big-endian, FF FE là UTF-16 little-endian; RFC 3629 ghi rằng với UTF-8, BOM luôn là đúng dãy EF BB BF và chỉ còn tác dụng làm chữ ký (vì UTF-8 không có vấn đề thứ tự byte); tài liệu codecs của Python gọi nó là chữ ký Unicode và có codec utf-8-sig để bỏ nó khi đọc.

Không có BOM thì phải dựa vào cấu trúc byte. Bài dùng thứ tự sau, dựng lại cho dữ liệu tiếng Nhật có thể là UTF-8 hoặc CP932 (bản Shift_JIS của Windows):

  1. Có BOM thì tin BOM.
  2. Chỉ có byte 0x00–0x7F (ASCII thuần) thì hợp lệ ở mọi encoding trong bài, nên không nhận diện được: dùng encoding mặc định đã thỏa thuận.
  3. Giải mã UTF-8 nghiêm ngặt; qua được thì coi là UTF-8.
  4. Không qua UTF-8 mà giải mã được bằng CP932 thì coi là CP932; không qua cả hai thì báo không xác định.

Hai lab dưới đây cho thấy từng bước chạy ra sao và vì sao UTF-8 phải được thử trước CP932. Tạo thư mục trống rồi lưu các tệp.

Lab: nhận diện và UTF-8 nghiêm ngặt

RFC 3629 mô tả UTF-8 dùng 1 đến 4 byte cho dải U+0000..U+10FFFF, cấm mã thay thế U+D800..U+DFFF, nêu mối nguy của dạng dài thừa (ví dụ C0 80 bị giải mã ngây thơ thành U+0000) và liệt kê các byte C0, C1, F5 đến FF không bao giờ xuất hiện. Bộ giải mã UTF-8 mặc định của Python (errors="strict") từ chối các dạng đó; lab kiểm bảy dãy sai và chín ca nhận diện.

import codecs


def valid(data: bytes, encoding: str) -> bool:
    try:
        data.decode(encoding)
    except UnicodeDecodeError:
        return False
    return True


def detect(data: bytes) -> tuple[str, str]:
    """Thứ tự: BOM, ASCII thuần, UTF-8 nghiêm ngặt, CP932, cuối cùng là không xác định."""
    if data.startswith(codecs.BOM_UTF8):
        return "utf-8-sig", "BOM EF BB BF"
    if data.startswith(codecs.BOM_UTF16_LE):
        return "utf-16-le", "BOM FF FE"
    if data.startswith(codecs.BOM_UTF16_BE):
        return "utf-16-be", "BOM FE FF"
    if all(byte < 0x80 for byte in data):
        return "ascii", "chỉ có byte 0x00-0x7F, hợp lệ ở mọi encoding trong bài"
    utf8, cp932 = valid(data, "utf-8"), valid(data, "cp932")
    if utf8 and cp932:
        return "utf-8", "mơ hồ: hợp lệ cả UTF-8 lẫn CP932, chọn UTF-8 theo chính sách"
    if utf8:
        return "utf-8", "hợp lệ UTF-8 nghiêm ngặt, không hợp lệ CP932"
    if cp932:
        return "cp932", "không hợp lệ UTF-8, hợp lệ CP932"
    return "không xác định", "không hợp lệ cả UTF-8 lẫn CP932"


CASES = [
    ("BOM UTF-8 rồi ABC", codecs.BOM_UTF8 + b"ABC", "utf-8-sig"),
    ("BOM UTF-16 LE rồi あ", codecs.BOM_UTF16_LE + "あ".encode("utf-16-le"), "utf-16-le"),
    ("UTF-8 日本語のテキスト", "日本語のテキスト".encode("utf-8"), "utf-8"),
    ("CP932 日本語のテキスト", "日本語のテキスト".encode("cp932"), "cp932"),
    ("ASCII abc", b"abc", "ascii"),
    ("CP932 ① (NEC mở rộng)", "①".encode("cp932"), "cp932"),
    ("UTF-8 あい", "あい".encode("utf-8"), "utf-8"),
    ("CP932 あい", "あい".encode("cp932"), "cp932"),
    ("byte rác 81 20", b"\x81\x20", "không xác định"),
]

INVALID_UTF8 = [
    (b"\xc0\x80", "C0 80: dạng dài thừa của U+0000, byte C0 bị cấm"),
    (b"\xe0\x80\x80", "E0 80 80: dạng dài thừa 3 byte của U+0000"),
    (b"\xed\xa0\x80", "ED A0 80: mã thay thế U+D800 bị cấm"),
    (b"\xf4\x90\x80\x80", "F4 90 80 80: vượt quá U+10FFFF"),
    (b"\xf5\x80\x80\x80", "F5 80 80 80: byte F5 không bao giờ xuất hiện"),
    (b"\xe3\x81", "E3 81: bị cắt giữa ký tự"),
    (b"\x80", "80: byte tiếp nối đứng một mình"),
]

for label, data, expected in CASES:
    encoding, reason = detect(data)
    print(f"{label:24} {data.hex(' '):28} -> {encoding} ({reason})")
    assert encoding == expected, (label, encoding)
assert "mơ hồ" in detect("あい".encode("utf-8"))[1]
assert not valid("①".encode("cp932"), "shift_jis")
for data, why in INVALID_UTF8:
    assert not valid(data, "utf-8"), why
    print(f"UTF-8 nghiêm ngặt từ chối {why}")
assert valid("©".encode("utf-8"), "utf-8") and valid("😀".encode("utf-8"), "utf-8")
assert codecs.BOM_UTF32_LE.startswith(codecs.BOM_UTF16_LE)
utf32 = detect(codecs.BOM_UTF32_LE + "あ".encode("utf-32-le"))[0]
print("giới hạn: dữ liệu UTF-32 LE có BOM bắt đầu bằng FF FE nên bị nhận là", utf32)
assert utf32 == "utf-16-le"
print("nhận diện đúng chín ca, UTF-8 nghiêm ngặt từ chối bảy dãy byte sai")
python3 -B detect.py
BOM UTF-8 rồi ABC        ef bb bf 41 42 43            -> utf-8-sig (BOM EF BB BF)
BOM UTF-16 LE rồi あ      ff fe 42 30                  -> utf-16-le (BOM FF FE)
UTF-8 日本語のテキスト           e6 97 a5 e6 9c ac e8 aa 9e e3 81 ae e3 83 86 e3 82 ad e3 82 b9 e3 83 88 -> utf-8 (hợp lệ UTF-8 nghiêm ngặt, không hợp lệ CP932)
CP932 日本語のテキスト           93 fa 96 7b 8c ea 82 cc 83 65 83 4c 83 58 83 67 -> cp932 (không hợp lệ UTF-8, hợp lệ CP932)
ASCII abc                61 62 63                     -> ascii (chỉ có byte 0x00-0x7F, hợp lệ ở mọi encoding trong bài)
CP932 ① (NEC mở rộng)    87 40                        -> cp932 (không hợp lệ UTF-8, hợp lệ CP932)
UTF-8 あい                 e3 81 82 e3 81 84            -> utf-8 (mơ hồ: hợp lệ cả UTF-8 lẫn CP932, chọn UTF-8 theo chính sách)
CP932 あい                 82 a0 82 a2                  -> cp932 (không hợp lệ UTF-8, hợp lệ CP932)
byte rác 81 20           81 20                        -> không xác định (không hợp lệ cả UTF-8 lẫn CP932)
UTF-8 nghiêm ngặt từ chối C0 80: dạng dài thừa của U+0000, byte C0 bị cấm
UTF-8 nghiêm ngặt từ chối E0 80 80: dạng dài thừa 3 byte của U+0000
UTF-8 nghiêm ngặt từ chối ED A0 80: mã thay thế U+D800 bị cấm
UTF-8 nghiêm ngặt từ chối F4 90 80 80: vượt quá U+10FFFF
UTF-8 nghiêm ngặt từ chối F5 80 80 80: byte F5 không bao giờ xuất hiện
UTF-8 nghiêm ngặt từ chối E3 81: bị cắt giữa ký tự
UTF-8 nghiêm ngặt từ chối 80: byte tiếp nối đứng một mình
giới hạn: dữ liệu UTF-32 LE có BOM bắt đầu bằng FF FE nên bị nhận là utf-16-le
nhận diện đúng chín ca, UTF-8 nghiêm ngặt từ chối bảy dãy byte sai

Đọc kết quả:

  • BOM là tín hiệu duy nhất chắc chắn. Hai ca BOM được nhận ngay ở bước đầu, không cần đoán. Giới hạn: BOM UTF-32 LE bắt đầu bằng FF FE nên cách dò ba chữ ký của WHATWG (và hàm trên) nhận nhầm thành UTF-16 LE.
  • ASCII thuần không nhận diện được. abc hợp lệ ở mọi encoding trong bài, nên hàm chỉ trả ascii; chọn encoding mặc định cho nó là thỏa thuận của hệ thống, không phải kết quả đo.
  • Có ca mơ hồ thật. あい bằng UTF-8 (E3 81 82 E3 81 84) cũng giải mã không lỗi bằng CP932, ra chữ vô nghĩa. Hàm chọn UTF-8 theo chính sách và đánh dấu “mơ hồ”; với chuỗi ngắn không thuật toán nào chắc chắn, cần metadata (header, đặc tả tệp) khi có.
  • UTF-8 nghiêm ngặt từ chối bảy dạng sai gồm dạng dài thừa, mã thay thế, vượt giới hạn, byte không bao giờ xuất hiện, ký tự bị cắt và byte tiếp nối đứng một mình. Đây là lý do “hợp lệ UTF-8” mang nhiều thông tin.
  • Một ca CP932 hợp lệ không phải là bằng chứng. ① (ký tự mở rộng NEC) chỉ giải mã được bằng CP932, không bằng shift_jis chuẩn của Python (assert trong lab); phần tiếp theo nói rõ hơn.

Lab: vì sao phải thử UTF-8 trước CP932

Mức “tín hiệu mạnh hay yếu” của hai phép kiểm đo được. Lab sinh chuỗi ngẫu nhiên từ hai bộ ký tự, mã hóa bằng một encoding và hỏi encoding kia có giải mã được không. Bộ “thường dùng” gồm hiragana, katakana và kanji ở các hàng đầu của bảng (byte đầu 0x88–0x9F) theo tỉ lệ 2:1:1; bộ “toàn bảng” lấy đều 9.604 ký tự hai byte mà cp932 của Python giải mã được. Đây là chuỗi ngẫu nhiên, không phải tiếng Nhật thật: tần suất của câu chữ thật làm tỉ lệ khác đi.

import random

TRIALS = 20_000
LENGTHS = (1, 2, 4, 8, 16, 32)


def double_byte_chars(leads: list[int]) -> list[str]:
    chars = []
    for lead in leads:
        for trail in range(0x40, 0xFD):
            if trail == 0x7F:
                continue
            try:
                chars.append(bytes([lead, trail]).decode("cp932"))
            except UnicodeDecodeError:
                pass
    return chars


HIRAGANA = [chr(code) for code in range(0x3041, 0x3094)]
KATAKANA = [chr(code) for code in range(0x30A1, 0x30F7)]
KANJI = double_byte_chars(list(range(0x88, 0xA0)))
EVERYTHING = double_byte_chars(list(range(0x81, 0xA0)) + list(range(0xE0, 0xFD)))


def common_text(rng: random.Random, length: int) -> str:
    pools = rng.choices([HIRAGANA, KATAKANA, KANJI], weights=[2, 1, 1], k=length)
    return "".join(rng.choice(pool) for pool in pools)


def any_text(rng: random.Random, length: int) -> str:
    return "".join(rng.choice(EVERYTHING) for _ in range(length))


def valid(data: bytes, encoding: str) -> bool:
    try:
        data.decode(encoding)
    except UnicodeDecodeError:
        return False
    return True


def rates(make, length: int, seed: int) -> tuple[float, float]:
    rng = random.Random(seed)
    cp932_ok_as_utf8 = utf8_ok_as_cp932 = 0
    for _ in range(TRIALS):
        text = make(rng, length)
        cp932_ok_as_utf8 += valid(text.encode("cp932"), "utf-8")
        utf8_ok_as_cp932 += valid(text.encode("utf-8"), "cp932")
    return cp932_ok_as_utf8 / TRIALS, utf8_ok_as_cp932 / TRIALS


print(f"hiragana {len(HIRAGANA)}, katakana {len(KATAKANA)}, kanji cấp thấp {len(KANJI)}, toàn bảng hai byte {len(EVERYTHING)}")
table = {}
for name, make in (("thường dùng", common_text), ("toàn bảng", any_text)):
    for length in LENGTHS:
        a, b = rates(make, length, seed=length)
        table[name, length] = (a, b)
        print(f"{name:11} n={length:2}: văn bản CP932 vẫn hợp lệ UTF-8 {a:7.3%} | văn bản UTF-8 vẫn hợp lệ CP932 {b:7.3%}")
assert all(table["thường dùng", n][0] == 0 for n in LENGTHS)
assert max(a for (name, _), (a, _) in table.items() if name == "toàn bảng") < 0.10
assert all(table["thường dùng", n][1] > 0.2 for n in (1, 2, 4, 8))
assert table["thường dùng", 32][1] < table["thường dùng", 8][1]
print("hợp lệ UTF-8 là tín hiệu mạnh, hợp lệ CP932 là tín hiệu yếu")
python3 -B rates.py
hiragana 83, katakana 86, kanji cấp thấp 4375, toàn bảng hai byte 9604
thường dùng n= 1: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 48.385%
thường dùng n= 2: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 58.265%
thường dùng n= 4: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 43.565%
thường dùng n= 8: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 28.945%
thường dùng n=16: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 11.500%
thường dùng n=32: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932  2.090%
toàn bảng   n= 1: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 50.180%
toàn bảng   n= 2: văn bản CP932 vẫn hợp lệ UTF-8  2.055% | văn bản UTF-8 vẫn hợp lệ CP932 53.330%
toàn bảng   n= 4: văn bản CP932 vẫn hợp lệ UTF-8  0.060% | văn bản UTF-8 vẫn hợp lệ CP932 40.740%
toàn bảng   n= 8: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 25.585%
toàn bảng   n=16: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 10.125%
toàn bảng   n=32: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932  1.455%
hợp lệ UTF-8 là tín hiệu mạnh, hợp lệ CP932 là tín hiệu yếu

Đọc kết quả:

  • Văn bản CP932 gần như không bao giờ qua phép kiểm UTF-8. Với bộ thường dùng là 0% ở mọi độ dài, vì byte đầu 0x82, 0x83 và 0x88–0x9F là byte tiếp nối trong UTF-8 và không thể mở đầu một ký tự. Với bộ toàn bảng tối đa khoảng 2% (ở 2 ký tự) rồi về 0 khi dài hơn. Nên “hợp lệ UTF-8” là tín hiệu mạnh.
  • Văn bản UTF-8 hay qua được phép kiểm CP932. Ở 1 đến 8 ký tự có từ khoảng 26% đến 58% chuỗi giải mã không lỗi bằng CP932 (ra mojibake), còn khoảng 10 đến 11,5% ở 16 ký tự và 1,5 đến 2,1% ở 32 ký tự. Nên “hợp lệ CP932” là tín hiệu yếu, nhất là với chuỗi ngắn. Ngoài ra, decoder cp932 của Python còn nhận cả byte đơn 0x80 (xem lab sau), nên còn lỏng hơn mức cần thiết.
  • Hệ quả cho thứ tự. Thử CP932 trước sẽ nhận nhầm một phần lớn văn bản UTF-8 ngắn là CP932; thử UTF-8 trước thì chuỗi CP932 thật gần như luôn bị loại, đúng như thứ tự bốn bước ở đầu bài. Cái giá là ca mơ hồ ở lab trước: chuỗi UTF-8 và CP932 đều hợp lệ thì phải có chính sách rõ ràng.

CP932 không phải Shift_JIS

Tài liệu MySQL nói cp932 khác sjis ở chỗ cp932 hỗ trợ ký tự đặc biệt của NEC, ký tự mở rộng NEC chọn từ IBM và ký tự IBM chọn lọc; một số ký tự cp932 có hai điểm mã cùng đổi sang một điểm mã Unicode, nên khi đổi ngược phải chọn một theo quy tắc Microsoft khuyến nghị. Chuẩn Encoding của WHATWG gom các nhãn shift_jis, sjis, windows-31j, ms_kanji, ms932 và vài nhãn khác vào cùng một encoding, và nêu Windows-31J (CP932) trong mô tả của encoding đó. Tài liệu codecs của Python lại tách hai codec: cp932 (bí danh 932, ms932, mskanji, ms-kanji, windows-31j) và shift_jis (bí danh csshiftjis, shiftjis, sjis, s_jis). Vậy chữ “Shift_JIS” trong một nhãn có thể trỏ tới một trong hai bảng, và lab cho thấy hậu quả.

Lab: ký tự mở rộng, byte 0x5C và nơi nó làm hỏng

Phần đầu so cp932 và shift_jis của Python. Phần hai quét mọi cặp byte để kiểm cấu trúc hai byte và đếm ký tự có byte sau là 0x5C (dấu gạch chéo ngược trong ASCII), rồi cho hai tác hại: tách đường dẫn theo byte, và thoát ký tự theo byte. Phần thoát ký tự dùng một bộ phân tích chuỗi đồ chơi (không có database): nó chỉ phân tích dấu nháy và dấu gạch chéo ngược để cho thấy điều gì xảy ra khi bộ thoát và bộ đọc hiểu encoding khác nhau. Mục đích là hiểu vì sao phải dùng truy vấn tham số hóa và charset đúng, không phải để tấn công.

LEADS = list(range(0x81, 0xA0)) + list(range(0xE0, 0xFD))


def show_hex(data: bytes) -> str:
    return data.hex(" ")


def try_encode(text: str, encoding: str) -> str:
    try:
        return show_hex(text.encode(encoding))
    except UnicodeEncodeError:
        return "không mã hóa được"


def try_decode(data: bytes, encoding: str) -> str:
    try:
        return ", ".join(f"U+{ord(c):04X}" for c in data.decode(encoding))
    except UnicodeDecodeError:
        return "không giải mã được"


def decodes(data: bytes, encoding: str) -> bool:
    try:
        data.decode(encoding)
    except UnicodeDecodeError:
        return False
    return True


print("== CP932 khác Shift_JIS")
print("① (U+2460): cp932", try_encode("①", "cp932"), "| shift_jis", try_encode("①", "shift_jis"))
print("byte 81 60: cp932", try_decode(b"\x81\x60", "cp932"), "| shift_jis", try_decode(b"\x81\x60", "shift_jis"))
roundtrip = "〜".encode("cp932").decode("cp932")
print(f"U+301C qua cp932 một vòng thành U+{ord(roundtrip):04X}")
assert try_encode("①", "shift_jis") == "không mã hóa được" and try_encode("①", "cp932") == "87 40"
assert b"\x81\x60".decode("cp932") != b"\x81\x60".decode("shift_jis")
assert roundtrip == "~"
assert b"\x80".decode("cp932") == "\x80"
print("decoder cp932 của Python còn nhận byte đơn 80 (thành U+0080)")

print("== Byte 0x5C làm byte sau")
pairs = {}
for lead in range(256):
    for trail in range(256):
        try:
            text = bytes([lead, trail]).decode("cp932")
        except UnicodeDecodeError:
            continue
        if len(text) == 1 and not decodes(bytes([lead]), "cp932"):
            pairs[lead, trail] = text
leads_seen = sorted({lead for lead, _ in pairs})
trails_seen = sorted({trail for _, trail in pairs})
print(f"{len(pairs)} ký tự hai byte; byte đầu {leads_seen[0]:#04x}-{leads_seen[-1]:#04x}, byte sau {trails_seen[0]:#04x}-{trails_seen[-1]:#04x}")
assert set(leads_seen) <= set(LEADS)
assert all(0x40 <= t <= 0x7E or 0x80 <= t <= 0xFC for t in trails_seen)
assert 0x5C in trails_seen and 0x41 in trails_seen
five_c = {}
for lead in LEADS:
    try:
        five_c[lead] = bytes([lead, 0x5C]).decode("cp932")
    except UnicodeDecodeError:
        pass
examples = " ".join(f"{c}={show_hex(c.encode('cp932'))}" for c in "表ソ十申")
print(f"{len(five_c)} ký tự cp932 có byte sau là 0x5C, ví dụ: {examples}")
assert len(five_c) == 52 and all(c in five_c.values() for c in "表ソ十申")
path = "データ\\表計算\\ソース.txt"
raw = path.encode("cp932")
naive = raw.split(b"\\")
correct = path.split("\\")
good = sum(1 for part in naive if decodes(part, "cp932"))
print(f"cắt đường dẫn theo byte 5C: {len(naive)} mảnh, {good} mảnh giải mã được; cắt sau khi giải mã: {len(correct)} mảnh")
assert len(naive) > len(correct)


def is_lead(byte: int) -> bool:
    return byte in range(0x81, 0xA0) or byte in range(0xE0, 0xFD)


def literal_end(statement: bytes, charset: str) -> int | None:
    """Vị trí dấu nháy đóng của chuỗi mở ở byte 0; dấu gạch chéo ngược thoát ký tự kế tiếp."""
    i = 1
    while i < len(statement):
        byte = statement[i]
        if charset == "cp932" and is_lead(byte):
            i += 2
        elif byte == 0x5C:
            i += 2
        elif byte == 0x27:
            return i
        else:
            i += 1
    return None


def naive_escape(data: bytes) -> bytes:
    return data.replace(b"\\", b"\\\\").replace(b"'", b"\\'")


def safe_escape(data: bytes, charset: str) -> bytes:
    text = data.decode(charset)
    return text.replace("\\", "\\\\").replace("'", "\\'").encode(charset)


def wrap(escaped: bytes) -> bytes:
    return b"'" + escaped + b"'"


print("== Escape theo byte so với theo ký tự (mô phỏng, không có database)")
attack = b"\x95' OR 1=1 --"
legit = "表' OR 1=1 --".encode("cp932")
for label, data in (("đầu vào sai 95 27", attack), ("văn bản hợp lệ 表'", legit)):
    statement = wrap(naive_escape(data))
    end = literal_end(statement, "cp932")
    tail = statement[end + 1 :].decode("cp932", errors="replace") if end is not None else ""
    print(f"{label}: escape theo byte, chuỗi đóng sớm ở byte {end}/{len(statement) - 1}, phần thoát ra ngoài: {tail!r}")
    assert end is not None and end < len(statement) - 1
    assert literal_end(statement, "utf-8") == len(statement) - 1
safe = wrap(safe_escape(legit, "cp932"))
print("escape theo ký tự cho 表':", "chuỗi đóng đúng cuối" if literal_end(safe, "cp932") == len(safe) - 1 else "đóng sớm")
assert literal_end(safe, "cp932") == len(safe) - 1
try:
    safe_escape(attack, "cp932")
except UnicodeDecodeError:
    print("escape theo ký tự từ chối đầu vào 95 27 vì không hợp lệ CP932")
else:
    raise AssertionError("lẽ ra phải từ chối")
print("CP932 và 0x5C: cắt, thoát và đường dẫn phải làm trên ký tự")
python3 -B cp932.py
== CP932 khác Shift_JIS
① (U+2460): cp932 87 40 | shift_jis không mã hóa được
byte 81 60: cp932 U+FF5E | shift_jis U+301C
U+301C qua cp932 một vòng thành U+FF5E
decoder cp932 của Python còn nhận byte đơn 80 (thành U+0080)
== Byte 0x5C làm byte sau
9604 ký tự hai byte; byte đầu 0x81-0xfc, byte sau 0x40-0xfc
52 ký tự cp932 có byte sau là 0x5C, ví dụ: 表=95 5c ソ=83 5c 十=8f 5c 申=90 5c
cắt đường dẫn theo byte 5C: 5 mảnh, 3 mảnh giải mã được; cắt sau khi giải mã: 3 mảnh
== Escape theo byte so với theo ký tự (mô phỏng, không có database)
đầu vào sai 95 27: escape theo byte, chuỗi đóng sớm ở byte 3/14, phần thoát ra ngoài: " OR 1=1 --'"
văn bản hợp lệ 表': escape theo byte, chuỗi đóng sớm ở byte 5/16, phần thoát ra ngoài: " OR 1=1 --'"
escape theo ký tự cho 表': chuỗi đóng đúng cuối
escape theo ký tự từ chối đầu vào 95 27 vì không hợp lệ CP932
CP932 và 0x5C: cắt, thoát và đường dẫn phải làm trên ký tự

Đọc kết quả:

  • Hai bảng cho kết quả khác nhau. ① mã hóa được bằng cp932 (87 40) nhưng không bằng shift_jis của Python; byte 81 60 giải mã thành U+FF5E ở cp932 và U+301C ở shift_jis; và U+301C đi qua cp932 một vòng trở thành U+FF5E, tức một ký tự đổi thành ký tự khác (khớp mô tả “hai điểm mã cùng đổi sang một điểm mã” của tài liệu MySQL). Dữ liệu từ Windows nên đọc bằng cp932, và phải biết thư viện của bạn dùng bảng nào khi gặp nhãn Shift_JIS.
  • Byte sau của ký tự hai byte nằm trong 0x40–0x7E hoặc 0x80–0xFC, nghĩa là trùng dải ASCII (cả chữ A lẫn dấu \). Lab quét 65.536 cặp byte và thấy 9.604 ký tự hai byte với byte đầu trong 0x81–0x9F và 0xE0–0xFC. Trong đó 52 ký tự có byte sau là 0x5C, ví dụ 表 (95 5C), ソ (83 5C), 十 (8F 5C), 申 (90 5C). Vì vậy một byte 0x5C chưa chắc là dấu gạch chéo ngược: nó có thể là nửa sau của một ký tự.
  • Tách theo byte làm hỏng đường dẫn. Đường dẫn データ\表計算\ソース.txt có hai dấu \ thật nhưng cắt ở mọi byte 5C cho 5 mảnh (chỉ 3 mảnh giải mã được); cắt sau khi giải mã cho đúng 3 mảnh.
  • Thoát theo byte làm hở dấu nháy. Bộ thoát theo byte thêm \ trước dấu nháy; nếu ngay trước đó là byte đầu của ký tự hai byte, \ vừa thêm bị ghép làm byte sau của ký tự đó và dấu nháy ở lại không được thoát. Chuỗi đóng sớm cho cả đầu vào sai (95 27) lẫn văn bản hợp lệ (表'); phần còn lại của đầu vào lộ ra ngoài chuỗi. Bộ đọc hiểu UTF-8 không bị ảnh hưởng vì byte sau trong UTF-8 không bao giờ là 0x5C. Thoát theo ký tự (giải mã nghiêm ngặt rồi mới thoát) cho kết quả đúng cho 表' và từ chối đầu vào sai.
  • Điều này không thay cho truy vấn tham số hóa. Tài liệu C API của MySQL nói việc thoát ký tự phụ thuộc charset đang dùng của kết nối, và khuyên đổi charset bằng mysql_set_character_set() thay vì câu lệnh SET NAMES, vì chỉ hàm trước làm mysql_real_escape_string() biết charset mới. Truy vấn tham số hóa tách dữ liệu khỏi câu lệnh nên không cần thoát; bài không mô phỏng nó và không đo database thật.

Độ dài: điểm mã, byte và đơn vị UTF-16

len() của Python đếm điểm mã Unicode. Số byte phụ thuộc encoding, và đơn vị UTF-16 là cách đếm của một số ngôn ngữ khác (bài không kiểm ngôn ngữ nào). Lab in bảng cho vài chuỗi có chủ đích, rồi thử hai việc hay gặp trong thực tế: giới hạn cột tính bằng byte, và đổi encoding dữ liệu cũ.

import random
import unicodedata

SAMPLES = [
    ("ABC", "ABC"),
    ("アイウ nửa chiều rộng", "アイウ"),
    ("デ có dấu đục", "デ"),
    ("デ toàn chiều rộng", "デ"),
    ("アイウ toàn chiều rộng", "アイウ"),
    ("日本語", "日本語"),
    ("① NEC", "①"),
    ("é dựng sẵn (NFC)", unicodedata.normalize("NFC", "é")),
    ("é tách dấu (NFD)", unicodedata.normalize("NFD", "é")),
    ("😀", "😀"),
    ("👨‍👩‍👧 gia đình", "👨‍👩‍👧"),
]


def cp932_bytes(text: str) -> str:
    try:
        return str(len(text.encode("cp932")))
    except UnicodeEncodeError:
        return "không"


print(f"{'chuỗi':26} {'điểm mã':>8} {'UTF-8':>6} {'UTF-16':>7} {'CP932':>6}")
for label, text in SAMPLES:
    utf16 = len(text.encode("utf-16-le")) // 2
    print(f"{label:26} {len(text):8} {len(text.encode('utf-8')):6} {utf16:7} {cp932_bytes(text):>6}")
lookup = dict(SAMPLES)
assert len("アイウ") == 3 and len("アイウ".encode("cp932")) == 3 and len("アイウ".encode("utf-8")) == 9
assert len("😀") == 1 and len("😀".encode("utf-8")) == 4 and len("😀".encode("utf-16-le")) == 4
assert len("デ") == 2 and unicodedata.normalize("NFKC", "デ") == "デ"
assert len(lookup["é dựng sẵn (NFC)"]) == 1 and len(lookup["é tách dấu (NFD)"]) == 2
try:
    "😀".encode("cp932")
except UnicodeEncodeError:
    pass
else:
    raise AssertionError("CP932 không có emoji")
print("NFKC: デ (2 điểm mã) thành デ (1 điểm mã)")

print("== Cột 10 byte")
LIMIT = 10
text = "日本語テキスト"
for encoding in ("cp932", "utf-8"):
    print(f"{encoding}: {len(text)} ký tự, {len(text.encode(encoding))} byte; len(text) <= {LIMIT} là {len(text) <= LIMIT}")
assert len(text) <= LIMIT < len(text.encode("cp932"))


def truncate(text: str, limit: int, encoding: str) -> str:
    out, used = [], 0
    for char in text:
        size = len(char.encode(encoding))
        if used + size > limit:
            break
        out.append(char)
        used += size
    return "".join(out)


cut = text.encode("utf-8")[:LIMIT]
try:
    cut.decode("utf-8")
except UnicodeDecodeError as err:
    print(f"cắt thẳng {LIMIT} byte UTF-8 rồi giải mã: lỗi ({err.reason})")
safe = truncate(text, LIMIT, "utf-8")
print(f"cắt theo ký tự: {safe!r} ({len(safe.encode('utf-8'))} byte)")
assert safe == "日本語" and len(safe.encode("utf-8")) <= LIMIT
assert truncate(text, LIMIT, "cp932") == "日本語テキ" and len(truncate(text, LIMIT, "cp932").encode("cp932")) == LIMIT

print("== Đổi CP932 sang UTF-8 làm dữ liệu dài thêm")
rng = random.Random(21)
HALF = [chr(code) for code in range(0xFF66, 0xFF9F)]
FULL = [chr(code) for code in range(0x3041, 0x3094)] + [chr(code) for code in range(0x30A1, 0x30F7)]
ASCII = [chr(code) for code in range(0x41, 0x5B)]
fits = grew = 0
worst = 0.0
for _ in range(10_000):
    pools = rng.choices([HALF, FULL, ASCII], weights=[1, 2, 1], k=rng.randint(4, 14))
    value = "".join(rng.choice(pool) for pool in pools)
    old, new = len(value.encode("cp932")), len(value.encode("utf-8"))
    if old <= 20:
        fits += 1
        grew += new > 20
        worst = max(worst, new / old)
print(f"{fits} chuỗi vừa cột 20 byte CP932; {grew} chuỗi ({grew / fits:.1%}) vượt 20 byte sau khi đổi sang UTF-8; tối đa gấp {worst:.2f} lần")
assert grew > 0 and worst <= 3.0
print("đếm ký tự khác đếm byte; đổi encoding đổi cả độ dài")
python3 -B lengths.py
chuỗi                       điểm mã  UTF-8  UTF-16  CP932
ABC                               3      3       3      3
アイウ nửa chiều rộng                3      9       3      3
デ có dấu đục                     2      6       2      2
デ toàn chiều rộng                 1      3       1      2
アイウ toàn chiều rộng               3      9       3      6
日本語                               3      9       3      6
① NEC                             1      3       1      2
é dựng sẵn (NFC)                  1      2       1  không
é tách dấu (NFD)                  2      3       2  không
😀                                 1      4       2  không
👨‍👩‍👧 gia đình                    5     18       8  không
NFKC: デ (2 điểm mã) thành デ (1 điểm mã)
== Cột 10 byte
cp932: 7 ký tự, 14 byte; len(text) <= 10 là True
utf-8: 7 ký tự, 21 byte; len(text) <= 10 là True
cắt thẳng 10 byte UTF-8 rồi giải mã: lỗi (unexpected end of data)
cắt theo ký tự: '日本語' (9 byte)
== Đổi CP932 sang UTF-8 làm dữ liệu dài thêm
9135 chuỗi vừa cột 20 byte CP932; 4784 chuỗi (52.4%) vượt 20 byte sau khi đổi sang UTF-8; tối đa gấp 3.00 lần
đếm ký tự khác đếm byte; đổi encoding đổi cả độ dài

Đọc kết quả:

  • Điểm mã, byte và đơn vị UTF-16 là ba số khác nhau. アイウ (katakana nửa chiều rộng) có 3 điểm mã, 3 byte CP932 nhưng 9 byte UTF-8; アイウ toàn chiều rộng có 3 điểm mã, 6 byte CP932 và 9 byte UTF-8. Ký tự 😀 là 1 điểm mã, 4 byte UTF-8, 2 đơn vị UTF-16 và không có trong CP932.
  • Điều mắt thấy là một ký tự chưa chắc là một điểm mã. デ (chữ nửa chiều rộng kèm dấu đục) là 2 điểm mã; chuẩn hóa NFKC (theo tài liệu Python: phân rã tương thích rồi ghép chính tắc) đưa nó thành デ, 1 điểm mã. é có thể là 1 điểm mã (NFC) hoặc 2 (NFD). Biểu tượng gia đình 👨‍👩‍👧 là 5 điểm mã và 18 byte UTF-8. Bài không đo ranh giới “ký tự hiển thị” (grapheme cluster).
  • Kiểm độ dài sai đơn vị là lỗi hay gặp. Chuỗi 7 ký tự qua phép kiểm len(text) <= 10 nhưng chiếm 14 byte CP932 hoặc 21 byte UTF-8, vượt một cột 10 byte. Cắt thẳng 10 byte UTF-8 cắt đôi một ký tự (giải mã lỗi “unexpected end of data”); cắt theo ký tự cho 日本語 (9 byte) mà không phá ký tự.
  • Đổi encoding làm dữ liệu dài thêm. Trong 9.135 chuỗi giả (hỗn hợp theo tỉ lệ 1:2:1 giữa katakana nửa chiều rộng, kana toàn chiều rộng và chữ ASCII) vừa cột 20 byte ở CP932, có 4.784 chuỗi (52,4%) vượt 20 byte khi đổi sang UTF-8, tối đa gấp 3 lần (chuỗi toàn nửa chiều rộng). Tỉ lệ này phụ thuộc thành phần hỗn hợp; chuỗi toàn ASCII không đổi độ dài.

Chọn gì trong từng tình huống

Tình huốngQuyết địnhCăn cứ trong bài
Nhận tệp mà người gửi không nói encodingBOM, rồi UTF-8 nghiêm ngặt, rồi encoding mặc định đã thỏa thuận (ở đây CP932)Hợp lệ UTF-8 là tín hiệu mạnh; hợp lệ CP932 là tín hiệu yếu
Có metadata (header, đặc tả tệp)Tin metadata; chỉ nhận diện khi không cóChuỗi ngắn như あい hợp lệ cả hai encoding
ASCII thuần hoặc chuỗi rất ngắnĐừng nhận diện; dùng encoding mặc định đã thỏa thuậnASCII hợp lệ ở mọi encoding; 26–58% chuỗi UTF-8 ngắn vẫn qua phép kiểm CP932
Gặp nhãn “Shift_JIS”Hỏi bảng nào; dữ liệu từ Windows thường cần CP932① và byte 81 60 khác nhau giữa hai bảng
Cắt, tách, thoát chuỗi CP932 ở mức byteGiải mã trước, xử lý theo ký tự, mã hóa lại; không split/replace trên byte52 ký tự có byte sau là 0x5C
Ghép SQL từ chuỗi có thể là CP932Dùng truy vấn tham số hóa; nếu phải thoát thì bằng hàm theo charset của kết nốiMô phỏng thoát theo byte; tài liệu C API của MySQL
Đặt giới hạn độ dài cộtNêu rõ đơn vị (ký tự hay byte, encoding nào), kiểm đúng đơn vị và cắt theo ký tựlen() 7 so với 14 và 21 byte
Đổi encoding dữ liệu đã cóTính lại độ dài byte trước khi đổi; katakana nửa chiều rộng tăng gấp 352,4% chuỗi giả vượt cột 20 byte

Giới hạn

  • Hành vi codec là của Python 3.14.4 trên macOS arm64 (ví dụ cp932 nhận byte đơn 0x80; U+301C đi vòng thành U+FF5E). iconv, ICU, trình duyệt hay ngôn ngữ khác có thể khác; WHATWG gom nhãn shift_jis cùng windows-31j vào một encoding nhưng bài không kiểm trình duyệt.
  • “Văn bản” trong lab tỉ lệ là chuỗi ngẫu nhiên từ bộ ký tự, không phải tiếng Nhật thật; tần suất thật của câu chữ làm xác suất hợp lệ khác đi. Con số 28,9% ở 8 ký tự là của mô hình này.
  • Bài không đo UTF-16 hay UTF-32 không BOM, EUC-JP, ISO-2022-JP, Windows-1252, thư viện nhận diện thống kê (như chardet, ICU) hay hàm nhận diện của PHP, và không đo ranh giới grapheme.
  • Phần thoát chuỗi là bộ phân tích đồ chơi; hành vi database thật phụ thuộc charset kết nối và phiên bản. Khoảng byte đầu và byte sau mà lab giả định cho bộ phân tích là cấu trúc Shift_JIS mà quét thực tế của cp932 Python khớp, không phải trích từ đặc tả.
  • BOM: cách dò ba chữ ký không phân biệt UTF-16 LE với UTF-32 LE (lab nêu ca này).
  • Lab không ghi tệp ngoài thư mục bạn đã tạo; xóa thư mục đó là dọn xong.

Học tiếp và nguồn

Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc Python 3.14.4 trên macOS arm64, không có nghiệm thu Linux hay thư viện khác.