Mini UTF-8 Codec
Mini UTF-8 Codec
কোনো built-in .encode('utf-8')/.decode('utf-8') ছাড়া, শুধু bit-shifting দিয়ে নিজের UTF-8 encoder আর decoder লেখা — বাংলা যুক্তাক্ষরসহ string দিয়ে টেস্ট করে, malformed byte sequence ধরার validator বানিয়ে।
কেন এই প্রজেক্ট
Unicode/UTF-8 লেসনে আমরা দেখেছি কীভাবে একটা code point ১, ২, ৩ বা ৪ byte-এ ভাগ হয়। কিন্তু “বুঝেছি কীভাবে ভাগ হয়” আর “নিজে সেই bit-shifting লিখতে পারি” — এই দুইয়ের মধ্যে একটা বড় ফাঁক থাকে, আর সেই ফাঁকটাই এই প্রজেক্ট ভরাট করবে।
আসল শিক্ষাটা এনকোডিং-এ নেই — ডিকোডিং-এ। এনকোডিং হলো একমুখী: একটা বৈধ code point সবসময় একটা বৈধ byte sequence দেয়। কিন্তু ডিকোডিং-এ ইনপুট আসে বাইরের জগৎ থেকে — নেটওয়ার্ক থেকে, ফাইল থেকে — আর সেটা যেকোনো byte-এর যেকোনো ক্রম হতে পারে, বৈধ UTF-8 না-ও হতে পারে। তাই “ডিকোডিং মানে এনকোডিং-এর উল্টো কাজ করা” — এই ধারণাটাই ভুল। ডিকোডারকে প্রতিটা ধাপে যাচাই করতে হয় যে যা পড়ছে সেটা আসলেই বৈধ কি না। এই প্রজেক্টের আসল লক্ষ্য সেই validation logic-টা হাতে-কলমে তৈরি করা — যেটা নেটওয়ার্ক প্রোটোকল পার্সার থেকে শুরু করে সিকিউরিটি ফিল্টার পর্যন্ত সবখানে একই প্যাটার্নে ফিরে আসে।
এনকোডিং নিয়ম
| Code point পরিসীমা | Byte সংখ্যা | Bit প্যাটার্ন |
|---|---|---|
| U+0000 – U+007F | ১ | 0xxxxxxx |
| U+0080 – U+07FF | ২ | 110xxxxx 10xxxxxx |
| U+0800 – U+FFFF (surrogate বাদে) | ৩ | 1110xxxx 10xxxxxx 10xxxxxx |
| U+10000 – U+10FFFF | ৪ | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
বাংলা ব্লক (U+0980–U+09FF) পড়ে U+0800-এর ওপরে, তাই প্রতিটা বাংলা অক্ষর ৩ byte নেয় — এটাই নিচে হাতে-কলমে verify করব।
লক্ষ্য
$ python utf8codec.py encode "বিদ্যা"
বিদ্যা → e0 a6 ac e0 a6 bf e0 a6 a6 e0 a7 8d e0 a6 af e0 a6 be
মিলছে built-in .encode('utf-8')-এর সাথে: ✓ (18 bytes)
$ python utf8codec.py test-malformed
OK invalid continuation byte : সঠিকভাবে reject হলো
OK overlong encoding of "/" : সঠিকভাবে reject হলো
OK code point out of range : সঠিকভাবে reject হলো
OK unpaired surrogate U+D800 : সঠিকভাবে reject হলো
ধাপে ধাপে
১. Encoder — code point থেকে bytes
def encode_codepoint(cp: int) -> bytes:
if not (0 \<= cp \<= 0x10FFFF):
raise ValueError(f'অবৈধ code point: U+{cp:X}')
if 0xD800 \<= cp \<= 0xDFFF:
raise ValueError(f'surrogate code point encode করা যায় না: U+{cp:X}')
if cp \<= 0x7F:
return bytes([cp])
elif cp \<= 0x7FF:
b0 = 0xC0 | (cp >> 6)
b1 = 0x80 | (cp & 0x3F)
return bytes([b0, b1])
elif cp \<= 0xFFFF:
b0 = 0xE0 | (cp >> 12)
b1 = 0x80 | ((cp >> 6) & 0x3F)
b2 = 0x80 | (cp & 0x3F)
return bytes([b0, b1, b2])
else:
b0 = 0xF0 | (cp >> 18)
b1 = 0x80 | ((cp >> 12) & 0x3F)
b2 = 0x80 | ((cp >> 6) & 0x3F)
b3 = 0x80 | (cp & 0x3F)
return bytes([b0, b1, b2, b3])
def encode_utf8(text: str) -> bytes:
out = bytearray()
for ch in text:
out += encode_codepoint(ord(ch))
return bytes(out)
ord() ব্যবহার করা হচ্ছে শুধু code point বের করতে (সেটা built-in UTF-8 codec না) — আসল bit-shifting কাজটা encode_codepoint-এর ভেতরেই।
হাতে-কলমে একটা উদাহরণ verify করি — বাংলা “ব” (BA, U+09AC = decimal 2476):
cp = 0x09AC = 0000100110101100 (16 bit)
cp >> 12 = 0000 → b0 = 0xE0 | 0x0 = 0xE0
(cp >> 6) & 0x3F = 100110 = 38(0x26) → b1 = 0x80 | 0x26 = 0xA6
cp & 0x3F = 101100 = 44(0x2C) → b2 = 0x80 | 0x2C = 0xAC
ফল: E0 A6 AC — যেটা Python-এর 'ব'.encode('utf-8') ঠিক এটাই দেয়।
২. Decoder — leading byte থেকে sequence length
Leading byte-এর উপরের bit-গুলো দেখেই বোঝা যায় পুরো sequence-টা কত byte লম্বা।
def leading_byte_info(b0: int):
"""(sequence length, code point-এর প্রাথমিক bit) রিটার্ন করে।"""
if b0 \< 0x80:
return 1, b0
if b0 & 0xE0 == 0xC0:
return 2, b0 & 0x1F
if b0 & 0xF0 == 0xE0:
return 3, b0 & 0x0F
if b0 & 0xF8 == 0xF0:
return 4, b0 & 0x07
raise ValueError(f'অবৈধ leading byte: 0x{b0:02X}')
লক্ষ্য করুন — এই ফাংশনটাই একটা continuation byte (10xxxxxx, অর্থাৎ 0x80–0xBF) কে leading byte হিসেবে দিলে reject করে, কারণ সেটা উপরের চারটা if-এর কোনোটাতেই মেলে না।
৩. পূর্ণ decode + validation
MIN_CP_FOR_LENGTH = {1: 0, 2: 0x80, 3: 0x800, 4: 0x10000}
def decode_one(data: bytes, i: int):
b0 = data[i]
length, cp = leading_byte_info(b0)
if i + length > len(data):
raise ValueError(f'position {i}: {length}-byte sequence-এর জন্য পর্যাপ্ত byte নেই')
for k in range(1, length):
b = data[i + k]
if b & 0xC0 != 0x80:
raise ValueError(
f'position {i + k}: continuation byte (10xxxxxx) আশা করেছিলাম, '
f'পেলাম 0x{b:02X}'
)
cp = (cp \<\< 6) | (b & 0x3F)
if cp \< MIN_CP_FOR_LENGTH[length]:
raise ValueError(
f'position {i}: overlong encoding — U+{cp:04X}-কে {length} byte দিয়ে '
f'লেখা হয়েছে, ছোট encoding-এই যথেষ্ট ছিল'
)
if 0xD800 \<= cp \<= 0xDFFF:
raise ValueError(f'position {i}: unpaired surrogate U+{cp:04X} — UTF-8-এ অবৈধ')
if cp > 0x10FFFF:
raise ValueError(f'position {i}: code point পরিসীমার বাইরে: U+{cp:X}')
return cp, i + length
def decode_utf8(data: bytes) -> list:
codepoints = []
i = 0
while i \< len(data):
cp, i = decode_one(data, i)
codepoints.append(cp)
return codepoints
চারটা আলাদা ব্যর্থতার কারণ খেয়াল করুন — প্রতিটাই ভিন্ন বাস্তব bug/আক্রমণ প্রতিনিধিত্ব করে:
- Continuation byte ভুল — ডেটা করাপশন বা truncated stream
- Overlong encoding — একই code point-কে ইচ্ছাকৃতভাবে বেশি byte দিয়ে লেখা, historically security filter bypass করতে ব্যবহৃত হয়েছে (নিচে দেখুন)
- Out-of-range code point — Unicode-এর নিজস্ব সীমা (U+10FFFF পর্যন্ত) লঙ্ঘন
- Unpaired surrogate — UTF-16-এর জন্য সংরক্ষিত code point (
U+D800–U+DFFF) UTF-8-এ কখনো সরাসরি আসার কথা না
৪. বাংলা যুক্তাক্ষরসহ string দিয়ে round-trip টেস্ট
“বিদ্যা” শব্দে একটা যুক্তাক্ষর আছে — দ + ্ (হসন্ত) + য = “দ্য” ligature। এনকোডারের চোখে এটা আলাদা কিছু না, শুধু ৬টা code point পরপর — কিন্তু এটাই grapheme cluster লেসনের মূল কথা: একটা “অক্ষর” যা চোখে দেখা যায়, আর একটা Unicode code point — এই দুইটা এক জিনিস না।
def selftest():
samples = ["A", "café", "বিদ্যা", "😀", ""]
for s in samples:
mine = encode_utf8(s)
builtin = s.encode('utf-8')
assert mine == builtin, f'{s!r}: mismatch — mine={mine.hex()} builtin={builtin.hex()}'
decoded_cps = decode_utf8(mine)
decoded_str = ''.join(chr(cp) for cp in decoded_cps)
assert decoded_str == s, f'{s!r}: round-trip ব্যর্থ, পেলাম {decoded_str!r}'
print(f'OK {s!r:12} → {mine.hex(" ")} ({len(mine)} bytes)')
chr() দিয়ে code point থেকে আবার string বানানো হচ্ছে — এটাও built-in UTF-8 codec না, শুধু code point ↔ character mapping।
৫. Malformed input দিয়ে validator টেস্ট
চারটা টেস্ট ভেক্টর, প্রতিটাই বাস্তব জগতের একটা পরিচিত কেস:
MALFORMED_CASES = [
(bytes([0xE0, 0x41, 0xAC]), 'invalid continuation byte'),
(bytes([0xC0, 0xAF]), 'overlong encoding of "/"'),
(bytes([0xF7, 0xBF, 0xBF, 0xBF]), 'code point out of range'),
(bytes([0xED, 0xA0, 0x80]), 'unpaired surrogate U+D800'),
]
def test_malformed():
for data, label in MALFORMED_CASES:
try:
decode_utf8(data)
print(f'FAIL {label}: ভুলভাবে accept হয়ে গেছে!')
except ValueError as e:
print(f'OK {label}: সঠিকভাবে reject হলো — {e}')
0xC0 0xAF বিশেষভাবে কুখ্যাত — এটা / (U+002F, normally ১ byte-এই লেখা হয়) কে জোর করে ২ byte-এ লেখার overlong encoding। পুরনো IIS সার্ভারে এই ট্রিকটা path-traversal ফিল্টার bypass করতে ব্যবহৃত হয়েছিল, কারণ ফিল্টার শুধু raw / byte (0x2F) খুঁজত, decode করার আগে — decode করার পরে এটা ঠিকই / হয়ে যেত। এটাই “validate করার আগে normalize করো” নিয়মের ইতিহাস।
নিজেকে চ্যালেঞ্জ করুন
- UTF-16 encoder/decoder যোগ করুন — surrogate pair-এর গণিত (high/low surrogate কম্বিনেশন) নিজে লিখুন
- Streaming decoder বানান — পুরো buffer একসাথে না দিয়ে byte-by-byte feed করলেও কাজ করবে এমন state machine
- Grapheme cluster segmentation যোগ করুন — বাংলা conjunct-গুলো কোথায় “একটা অক্ষর” হিসেবে ভাঙা উচিত, তার নিয়ম লিখুন
- CESU-8 detector — যে ডেটা surrogate pair-কে UTF-16-এর মতো আলাদা আলাদা এনকোড করেছে, সেটা চিহ্নিত করুন
- Fuzz testing — লক্ষ লক্ষ random byte sequence generate করে আপনার decoder কখনো crash করে কি না, Python-এর built-in decoder-এর সাথে accept/reject সিদ্ধান্ত মেলে কি না যাচাই করুন
এটা যেখানে গিয়ে মিশবে
| এখানে যা শিখলেন | পরে কোথায় লাগবে |
|---|---|
| Leading-byte state machine | Level 5 — Compiler lexer, ছোট state machine দিয়ে টোকেন চেনা |
| Byte-by-byte stream validation | Level 7 — Network protocol parser, malformed packet handle করা |
| Overlong encoding-এর মতো bypass ট্রিক | Level 10 — Input validation, encoding-based injection attack |
| DFA-স্টাইল leading/continuation নিয়ম | Level 13 — Regex Engine প্রজেক্ট, DFA-ভিত্তিক matcher |
| Text encoding সঠিকভাবে সংরক্ষণ | Level 8 — Database-এ collation ও encoding সংক্রান্ত bug এড়ানো |