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):
- Có BOM thì tin BOM.
- 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. - Giải mã UTF-8 nghiêm ngặt; qua được thì coi là UTF-8.
- 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 FEnê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.
abchợ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ằngshift_jischuẩ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,0x83và0x88–0x9Flà 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
cp932của Python còn nhận cả byte đơn0x80(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ằngcp932(87 40) nhưng không bằngshift_jiscủa Python; byte81 60giải mã thành U+FF5E ởcp932và U+301C ởshift_jis; và U+301C đi quacp932mộ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ằngcp932, 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–0x7Ehoặc0x80–0xFC, nghĩa là trùng dải ASCII (cả chữAlẫn dấu\). Lab quét 65.536 cặp byte và thấy 9.604 ký tự hai byte với byte đầu trong0x81–0x9Fvà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 byte0x5Cchư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
データ\表計算\ソース.txtcó hai dấu\thật nhưng cắt ở mọi byte5Ccho 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ệnhSET NAMES, vì chỉ hàm trước làmmysql_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) <= 10như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ống | Quyết định | Căn cứ trong bài |
|---|---|---|
| Nhận tệp mà người gửi không nói encoding | BOM, 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ận | ASCII 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 byte | Giải mã trước, xử lý theo ký tự, mã hóa lại; không split/replace trên byte | 52 ký tự có byte sau là 0x5C |
| Ghép SQL từ chuỗi có thể là CP932 | Dù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ối | Mô phỏng thoát theo byte; tài liệu C API của MySQL |
| Đặt giới hạn độ dài cột | Nê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 3 | 52,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ụ
cp932nhận byte đơn0x80; 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ãnshift_jiscùngwindows-31jvà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
cp932Python 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
- Hash table: va chạm, load factor và vì sao O(1) chỉ là kỳ vọng:
hash()của chuỗi và byte phụ thuộc cùng chuỗi byte này. - Đọc benchmark: số đo và ngoại suy: cách đọc các tỉ lệ mô phỏng ở trên.
- WHATWG, Encoding Standard: các chữ ký BOM, danh sách nhãn của
shift_jisvà khuyến nghị dùng UTF-8. - IETF, RFC 3629: UTF-8, a transformation format of ISO 10646: dải điểm mã, mã thay thế, dạng dài thừa và BOM.
- Python 3.14, codecs: hằng số BOM,
utf-8-sigvà bảng bí danh củacp932vàshift_jis. - Python 3.14, unicodedata: các dạng chuẩn hóa NFC, NFD, NFKC và NFKD.
- MySQL 26.7, The cp932 Character Set: khác biệt giữa
cp932vàsjis. - MySQL 26.7 C API, mysql_real_escape_string(): thoát ký tự phụ thuộc charset của kết nối.
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.