fix(tools): decode saved non-UTF-8 pages by their declared charset (#690)

* fix(tools): decode saved non-UTF-8 pages by their declared charset

fetch_page.py saves a body exactly as the server sent it, but its readers,
posting_guard.py --html and fetch_page.py --text, decoded the file as UTF-8
with replacement. A posting served in EUC-KR, Shift_JIS or windows-1252
reached the integrity gate and the scorer as replacement characters.

Valid UTF-8 still wins, so any page that decoded cleanly before decodes to the
same string. Bytes that are not valid UTF-8 now fall back to the charset the
page declares (UTF-16 BOM, then <meta charset> or http-equiv Content-Type in
the first 1024 bytes), mapped to the codec browsers use for that label. Missing
or unknown labels keep the previous UTF-8-with-replacement result.

* changelog: place the #690 entry under [Unreleased]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 08:28:58 +02:00
1 parent 8fb34c6cb7
commit f97a759945
7 files changed
+227 -11

No files matched your search

+17 -1
View File
@@ -30,6 +30,11 @@ PAGE = (
"<noscript>noscript text</noscript><svg><text>svg text</text></svg>"
"</body></html>"
)
KOREAN_PAGE = (
'<html><head><meta http-equiv="Content-Type" content="text/html; charset=euc-kr">'
"<title>채용공고</title></head><body><h1>백엔드 개발자 채용</h1></body></html>"
)
CONTENT_TYPES = {"/euc-kr.html": "text/html; charset=euc-kr"}
class _Handler(http.server.BaseHTTPRequestHandler):
@@ -37,6 +42,9 @@ class _Handler(http.server.BaseHTTPRequestHandler):
if self.path == "/ok.html":
body = PAGE.encode("utf-8")
self.send_response(200)
elif self.path == "/euc-kr.html":
body = KOREAN_PAGE.encode("cp949")
self.send_response(200)
elif self.path == "/forbidden":
body = b"<html><body>denied</body></html>"
self.send_response(403)
@@ -54,7 +62,7 @@ class _Handler(http.server.BaseHTTPRequestHandler):
else:
body = b"nope"
self.send_response(404)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Type", CONTENT_TYPES.get(self.path, "text/html; charset=utf-8"))
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
@@ -152,6 +160,14 @@ class Fetching(FetchPageFixture):
for leaked in ("alert(", "color:red", "hidden comment", "noscript text", "svg text", "<p>"):
self.assertNotIn(leaked, text)
def test_text_decodes_a_page_by_its_declared_charset(self):
code, out, err = self.run_main(f"{self.base}/euc-kr.html", "--out", str(self.out), "--text")
self.assertEqual(code, 0, err)
self.assertEqual(self.out.read_bytes(), KOREAN_PAGE.encode("cp949"))
text = out.split("--- text ---", 1)[1]
self.assertIn("백엔드 개발자 채용", text)
self.assertNotIn("\ufffd", text)
def test_max_chars_caps_text(self):
code, out, err = self.run_main(
f"{self.base}/ok.html", "--out", str(self.out), "--text", "--max-chars", "7"
+82
View File
@@ -0,0 +1,82 @@
"""tools/page_decode.py decodes a saved page by the charset it declares.
Why this test exists: fetch_page.py saves bodies as the server sent them, and
posting_guard.py --html and fetch_page.py --text used to decode every file as
UTF-8 with replacement. A posting served in EUC-KR (job.incruit.com serves its
postings that way) reached the gate and the scorer as replacement characters.
These tests pin that declared legacy charsets are honoured, and that anything
which decoded cleanly before still decodes to exactly the same string.
"""
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "tools"))
from page_decode import decode_page, declared_codec # noqa: E402
def page(body: str, head: str = "") -> str:
return f"<!doctype html><html><head>{head}<title>Posting</title></head><body>{body}</body></html>"
class LegacyCharsets(unittest.TestCase):
def test_http_equiv_euc_kr_is_read_as_cp949(self):
# 똠 is outside strict EUC-KR; browsers read the euc-kr label as CP949.
text = page("<h1>백엔드 개발자 채용</h1><p>똠방각하 우대</p>",
'<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">')
decoded = decode_page(text.encode("cp949"))
self.assertIn("백엔드 개발자 채용", decoded)
self.assertIn("똠방각하", decoded)
self.assertNotIn("�", decoded)
def test_meta_charset_shift_jis(self):
text = page("<h1>データエンジニア募集</h1>", '<meta charset="Shift_JIS">')
self.assertIn("データエンジニア募集", decode_page(text.encode("cp932")))
def test_iso_8859_1_label_is_read_as_windows_1252(self):
text = page("<p>Løn: 50.000 € om måneden</p>", "<meta charset=iso-8859-1>")
self.assertIn("Løn: 50.000 € om måneden", decode_page(text.encode("cp1252")))
def test_windows_1251_cyrillic(self):
text = page("<p>Инженер данных</p>", '<meta charset="windows-1251">')
self.assertIn("Инженер данных", decode_page(text.encode("cp1251")))
def test_utf16_byte_order_mark(self):
text = page("<p>Ingeniør</p>")
self.assertIn("Ingeniør", decode_page(text.encode("utf-16")))
class UnchangedBehaviour(unittest.TestCase):
def test_valid_utf8_is_unchanged(self):
raw = page("<p>Ingeniør søges, 백엔드, データ</p>").encode("utf-8")
self.assertEqual(decode_page(raw), raw.decode("utf-8", errors="replace"))
def test_valid_utf8_wins_over_a_wrong_meta_label(self):
# Browsers would follow a utf-8 HTTP header here; the saved file does not
# keep headers, so valid UTF-8 has to win over a stale <meta>.
raw = page("<p>Café</p>", '<meta charset="iso-8859-1">').encode("utf-8")
self.assertIn("Café", decode_page(raw))
def test_utf8_bom_is_kept_for_the_invisible_character_count(self):
raw = b"\xef\xbb\xbf" + page("<p>Plain posting.</p>").encode("utf-8")
self.assertTrue(decode_page(raw).startswith(""))
def test_undeclared_legacy_bytes_fall_back_to_utf8_with_replacement(self):
raw = page("<p>Café</p>").encode("latin-1")
self.assertEqual(decode_page(raw), raw.decode("utf-8", errors="replace"))
def test_unknown_and_unsafe_labels_are_ignored(self):
for label in ("x-unknown", "utf-7", "utf-16"):
raw = page("<p>Café</p>", f'<meta charset="{label}">').encode("latin-1")
with self.subTest(label=label):
self.assertIsNone(declared_codec(raw))
self.assertEqual(decode_page(raw), raw.decode("utf-8", errors="replace"))
def test_meta_after_the_prescan_window_is_ignored(self):
raw = (" " * 1100 + page("<p>Café</p>", '<meta charset="windows-1252">')).encode("cp1252")
self.assertIsNone(declared_codec(raw))
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -228,6 +228,37 @@ class InputHandling(GuardFixture):
self.assertEqual(code, 0, err)
self.assertIn("Caf� manager wanted", self.text_of(out))
def test_html_mode_reads_a_page_saved_in_its_declared_charset(self):
# job.incruit.com serves postings as EUC-KR; fetch_page.py saves the bytes as sent.
page = (
'<html><head><meta http-equiv="Content-Type" content="text/html; charset=euc-kr">'
"</head><body><h1>백엔드 개발자 채용</h1><p>Python, SQL 경력 3년 이상</p></body></html>"
)
p = self.write("page.html", page.encode("cp949"))
code, out, err = self.run_main("--in", str(p), "--html", "--json")
self.assertEqual(code, 0, err)
self.assertIn("백엔드 개발자 채용", self.text_of(out))
self.assertNotIn("\ufffd", self.text_of(out))
def test_html_mode_scans_a_legacy_cyrillic_page_for_homoglyphs(self):
# Cyrillic о and а inside Latin words, saved as windows-1251.
page = (
'<html><head><meta charset="windows-1251"></head>'
"<body><p>Join Nоvo Nаrdisk as an engineer.</p></body></html>"
)
p = self.write("page.html", page.encode("cp1251"))
code, out, err = self.run_main("--in", str(p), "--html", "--json")
self.assertEqual(code, 0, err)
self.assertEqual(len(self.findings_of(out)["homoglyph_runs"]), 2)
self.assertIn("⚠", out.splitlines()[0])
def test_html_mode_keeps_valid_utf8_when_meta_names_another_charset(self):
page = '<html><head><meta charset="iso-8859-1"></head><body><p>Café manager</p></body></html>'
p = self.write("page.html", page)
code, out, err = self.run_main("--in", str(p), "--html", "--json")
self.assertEqual(code, 0, err)
self.assertIn("Café manager", self.text_of(out))
def test_missing_in_file_exits_2(self):
code, out, err = self.run_main("--in", str(self.tmp / "nope.txt"))
self.assertEqual(code, 2)