svn commit: r1936530 - in spamassassin/trunk: . lib/Mail/SpamAssassin/Message t

[email protected] Thu, 23 Jul 2026 23:04:51 -0000
Newsgroups gmane.mail.spam.spamassassin.cvs
Message-ID <178484789173.1517728.5960958978928584608@svn03-he-fi>
Author: fkento
Date: Thu Jul 23 23:04:51 2026
New Revision: 1936530

Log:
Message::Node: fix BOM'd UTF-16 decoding in _normalize

detect_utf16() returned undef when the data began with a UTF-16 BOM
("let perl figure it out from the BOM"), but its only caller --
_normalize()'s UTF-16 branch -- used solely the decoder it returned.
So BOM'd UTF-16 fell through to the Windows-1252 last resort and was
mangled (the BOM bytes ff fe / fe ff decoded to c3 bf c3 be, with the
interleaved NUL bytes preserved).

Return the BOM-aware UTF-16 decoder from detect_utf16() on a BOM instead
of undef; it consumes the BOM and picks endianness from it. detect_utf16()
now returns undef only when the data does not look like UTF-16 at all.

While here, bound the endianness heuristic to a 1024-byte prefix instead
of scanning the whole part: the check is statistical, so a prefix gives
the same verdict while avoiding the unpack() of a multi-MB body into
per-nibble arrays. The BOM pre-check now tests $_[0] directly (only the
first two bytes matter) rather than copying the body first.

Add t/node_utf16.t: direct unit tests covering detect_utf16() and
_normalize('UTF-16') for BOM'd and BOM-less UTF-16 of either endianness,

Added:
   spamassassin/trunk/t/node_utf16.t
Modified:
   spamassassin/trunk/MANIFEST
   spamassassin/trunk/lib/Mail/SpamAssassin/Message/Node.pm

Modified: spamassassin/trunk/MANIFEST
==============================================================================
--- spamassassin/trunk/MANIFEST	Thu Jul 23 20:10:03 2026	(r1936529)
+++ spamassassin/trunk/MANIFEST	Thu Jul 23 23:04:51 2026	(r1936530)
@@ -671,6 +671,7 @@ t/missing_hb_separator.t
 t/mkrules.t
 t/mkrules_else.t
 t/neuralnetwork.t
+t/node_utf16.t
 t/nonspam.t
 t/olevbmacro.t
 t/originating_ip_hdr.t

Modified: spamassassin/trunk/lib/Mail/SpamAssassin/Message/Node.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/Message/Node.pm	Thu Jul 23 20:10:03 2026	(r1936529)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Message/Node.pm	Thu Jul 23 23:04:51 2026	(r1936530)
@@ -450,7 +450,6 @@ sub decode {
 
 # Detect endianness of UTF-16 encoded data
 sub detect_utf16 {
-	my $data = $_[0];  # could not avoid copying large strings
 	my $utf16le_clues = 0;
 	my $utf16be_clues = 0;
 	my $sum_h_e = 0;
@@ -459,12 +458,21 @@ sub detect_utf16 {
 	my $sum_l_o = 0;
 	my $decoder = undef;
 
-	# avoid scan if BOM present
-	if( $data =~ /^(?:\xff\xfe|\xfe\xff)/ ) {
+	# A BOM already declares the encoding and endianness, so skip the heuristic
+	# scan and return the plain UTF-16 decoder, which consumes the BOM and picks
+	# the endianness from it.  Only the first two bytes matter, so test $_[0]
+	# directly rather than copying the (possibly multi-MB) body.
+	if( $_[0] =~ /^(?:\xff\xfe|\xfe\xff)/ ) {
 		dbg( "message: detect_utf16: found BOM" );
-		return;	# let perl figure it out from the BOM
+		return Encode::find_encoding("UTF-16");
 	}
-	
+
+	# The endianness heuristic below is statistical, so a bounded prefix gives the
+	# same verdict as the whole string while avoiding the unpack() of a large body
+	# into per-nibble arrays.  1024 is even, so the slice ends on a UTF-16 pair
+	# boundary.
+	my $data = substr($_[0], 0, 1024);
+
 	my @msg_h = unpack 'H' x length( $data ), $data;
 	my @msg_l = unpack 'h' x length( $data ), $data;
 
@@ -594,6 +602,10 @@ sub _normalize {
     # or declaring endianness as reported at:
     # https://bz.apache.org/SpamAssassin/show_bug.cgi?id=7252
 
+    # detect_utf16() sniffs the endianness of BOM-less UTF-16, and returns the
+    # BOM-aware UTF-16 decoder when a BOM is present.  (It returns undef only when
+    # the data does not look like UTF-16 at all, in which case we fall through to
+    # the guesswork below.)
     my $decoder = detect_utf16( $_[0] );
     if (defined $decoder) {
       if (eval { $rv = $decoder->decode($_[0], Encode::FB_CROAK | Encode::LEAVE_SRC); defined $rv }) {

Added: spamassassin/trunk/t/node_utf16.t
==============================================================================
--- /dev/null	00:00:00 1970	(empty, because file is newly added)
+++ spamassassin/trunk/t/node_utf16.t	Thu Jul 23 23:04:51 2026	(r1936530)
@@ -0,0 +1,84 @@
+#!/usr/bin/perl
+use strict;
+use warnings FATAL => 'all';
+use lib 'blib/lib'; use lib '../blib/lib';
+use Test::More;
+use Encode qw();
+use Mail::SpamAssassin::Message::Node;
+
+# ---------------------------------------------------------------------------
+# Direct unit tests for the UTF-16 handling in Mail::SpamAssassin::Message::Node:
+#
+#   * detect_utf16($bytes) - returns an Encode decoder for UTF-16 input
+#     (BOM'd or BOM-less, either endianness).
+#   * _normalize($bytes, 'UTF-16', 0, 0) - transcodes UTF-16 octets to UTF-8
+#     octets via that decoder.
+#
+# Regression guard: detect_utf16() used to return undef when a BOM was present
+# ("let perl figure it out from the BOM"), but its only caller only ever used the
+# decoder it returned -- so BOM'd UTF-16 dropped through to the Windows-1252 last
+# resort in _normalize() and was mangled.  The BOM'd cases below cover that.
+# ---------------------------------------------------------------------------
+
+my $sample = "The quick brown fox jumps over the lazy dog.\n";
+
+my $le_bom   = "\xff\xfe" . Encode::encode('UTF-16LE', $sample);
+my $be_bom   = "\xfe\xff" . Encode::encode('UTF-16BE', $sample);
+my $le_nobom = Encode::encode('UTF-16LE', $sample);
+my $be_nobom = Encode::encode('UTF-16BE', $sample);
+
+my $utf8  = Encode::encode('UTF-8', "caf\x{e9} \x{4e16}\x{754c}\n");
+my $ascii = "plain ascii, no nulls here\n";
+
+plan tests => 6;
+
+# --- detect_utf16: returns the CORRECT decoder for every UTF-16 variant ------
+# The BOM'd cases are the regression guard (before the fix these returned undef).
+# A BOM declares its own endianness, so both BOM'd inputs must resolve to the
+# BOM-aware 'UTF-16' codec -- NOT UTF-16LE/UTF-16BE, which would ignore the BOM
+# and mis-decode the opposite endianness.  BOM-less inputs must resolve to the
+# specific endianness the heuristic sniffed.  We check both the decoder's name
+# and that it actually decodes the sample back to the original text.
+my @detect = (
+  ['BOM LE',    $le_bom,    'UTF-16'],
+  ['BOM BE',    $be_bom,    'UTF-16'],
+  ['no-BOM LE', $le_nobom,  'UTF-16LE'],
+  ['no-BOM BE', $be_nobom,  'UTF-16BE'],
+);
+for my $c (@detect) {
+  my ($name, $bytes, $want) = @$c;
+  subtest "detect_utf16: $name -> $want" => sub {
+    plan tests => 2;
+    my $dec = Mail::SpamAssassin::Message::Node::detect_utf16($bytes);
+    is(defined $dec ? $dec->name : undef, $want,
+       "returns the $want decoder");
+    is(defined $dec ? $dec->decode($bytes, Encode::LEAVE_SRC) : undef, $sample,
+       "that decoder round-trips the sample text");
+  };
+}
+
+# --- _normalize('UTF-16'): every variant transcodes to clean UTF-8 ----------
+# Result must be UTF-8 octets: the ASCII text is contiguous again (a BOM'd input
+# that fell through to Windows-1252 would keep its interleaved NUL bytes and the
+# mangled BOM, so both checks would fail).
+subtest '_normalize decodes all UTF-16 variants to UTF-8' => sub {
+  plan tests => 4;
+  for my $c (['BOM LE', $le_bom], ['BOM BE', $be_bom],
+             ['no-BOM LE', $le_nobom], ['no-BOM BE', $be_nobom]) {
+    my ($name, $bytes) = @$c;
+    my $out = Mail::SpamAssassin::Message::Node::_normalize($bytes, 'UTF-16', 0, 0);
+    ok($out =~ /\QThe quick brown fox\E/ && index($out, "\x00") < 0,
+       "$name decoded (BOM consumed, no NULs)");
+  }
+};
+
+# --- _normalize leaves already-UTF-8 and plain ASCII unchanged --------------
+# Each input is declared with the charset it actually is, so we exercise the
+# matching decode path rather than relying on the "try UTF-8 first" fallback.
+subtest '_normalize leaves UTF-8 / ASCII unchanged' => sub {
+  plan tests => 2;
+  is(Mail::SpamAssassin::Message::Node::_normalize($utf8, 'UTF-8', 0, 0), $utf8,
+     'valid UTF-8 octets unchanged');
+  is(Mail::SpamAssassin::Message::Node::_normalize($ascii, 'us-ascii', 0, 0), $ascii,
+     'plain ASCII unchanged');
+};