lib/Encode/Wide.pm

Structural Coverage (Approximate)

TER1 (Statement): 100.00%
TER2 (Branch): 100.00%
TER3 (LCSAJ): 100.0% (14/14)
Approximate LCSAJ segments: 33

LCSAJ Legend

Covered — this LCSAJ path was executed during testing.

Not covered — this LCSAJ path was never executed. These are the paths to focus on.

Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:

        start → end → jump
        

Uncovered paths show [NOT COVERED] in the tooltip.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    1: package Encode::Wide;
    2: 
    3: # TODO: don't transform anything within <script>...</script> in wide_to_html
    4: 
    5: use strict;
    6: use warnings;
    7: 
    8: use Carp qw(croak carp confess);
    9: use Exporter qw(import);
   10: use HTML::Entities;
   11: use Params::Get 0.13;
   12: 
   13: our @EXPORT_OK = qw(wide_to_html wide_to_xml);
   14: 
   15: # HTML::Entities::decode does not handle these four named entities, so we
   16: # decode them ourselves.  The regex is built once at compile time: longest
   17: # key first to avoid partial-match ambiguity (e.g. &Scaron; before &s...).
   18: my %_EXTRA_ENTITY_MAP = (
   19: 	'&ccaron;' => "\x{010D}",	# c with caron
   20: 	'&zcaron;' => "\x{017E}",	# z with caron
   21: 	'&Zcaron;' => "\x{017D}",	# Z with caron
   22: 	'&Scaron;' => "\x{0160}",	# S with caron
   23: );
   24: my $_EXTRA_ENTITY_RE = do {
   25: 	my $pat = join '|', map { quotemeta }
   26: 		sort { length($b) <=> length($a) } keys %_EXTRA_ENTITY_MAP;
   27: 	qr/$pat/;
   28: };
   29: 
   30: # Module-level HTML escape map eliminates the /e eval flag in keep_hrefs substitutions
   31: my %_HTML_ESCAPE = ( '<' => '&lt;', '>' => '&gt;', '"' => '&quot;' );
   32: 
   33: # Encode to HTML whatever the non-ASCII encoding scheme has been chosen
   34: # Can't use HTML:Entities::encode since that doesn't seem to cope with
   35: #	all encodings and misses some characters
   36: #
   37: # See https://www.compart.com/en/unicode/U+0161 etc.
   38: #	https://www.compart.com/en/unicode/U+00EB
   39: #
   40: # keep_hrefs => 1 means ensure hyperlinks still work
   41: # keep_apos => 1 means keep apostrophes, useful within <script>
   42: 
   43: =head1 NAME
   44: 
   45: Encode::Wide - Convert wide characters (Unicode, UTF-8, etc.) into ASCII-safe HTML or XML entities
   46: 
   47: =head1 VERSION
   48: 
   49: 0.07
   50: 
   51: =cut
   52: 
   53: our $VERSION = 0.07;
   54: 
   55: =encoding UTF-8
   56: 
   57: =head1 SYNOPSIS
   58: 
   59:     use Encode::Wide qw(wide_to_html wide_to_xml);
   60: 
   61:     # Basic HTML conversion
   62:     my $html = wide_to_html(string => "Cafe\x{E9} d\x{E9}j\x{E0} vu");
   63:     # => 'Caf&eacute; d&eacute;j&agrave; vu'
   64: 
   65:     # Basic XML conversion (numeric entities, en-dash folded to hyphen)
   66:     my $xml = wide_to_xml(string => "Cafe\x{E9} \x{2013} na\x{EF}ve");
   67:     # => 'Caf&#x0E9; - na&#x0EF;ve'
   68: 
   69:     # Preserve embedded HTML markup (keep_hrefs)
   70:     my $linked = wide_to_html(
   71:         string     => '<a href="/menu">Caf\x{E9}</a>',
   72:         keep_hrefs => 1,
   73:     );
   74:     # => '<a href="/menu">Caf&eacute;</a>'
   75: 
   76:     # Keep apostrophes literal for JavaScript contexts (keep_apos)
   77:     my $js_safe = wide_to_html(
   78:         string    => "it\x{2019}s na\x{EF}ve",
   79:         keep_apos => 1,
   80:     );
   81:     # => "it\x{2019}s na&iuml;ve"   (curly apostrophe kept; i-umlaut encoded)
   82: 
   83:     # Get notified about unhandled characters instead of dying silently
   84:     my $out = wide_to_html(
   85:         string   => $untrusted,
   86:         complain => sub { warn "Unhandled: $_[0]" },
   87:     );
   88: 
   89:     # Accept a scalar reference
   90:     my $text = "na\x{EF}ve";
   91:     my $safe = wide_to_html(string => \$text);
   92:     # => 'na&iuml;ve'
   93: 
   94: =head1 DESCRIPTION
   95: 
   96: Encode::Wide converts strings that contain non-ASCII (wide) characters into
   97: pure 7-bit ASCII output suitable for embedding in HTML pages or XML documents.
   98: Every non-ASCII codepoint is replaced by the appropriate entity reference so
   99: the output can be safely placed in HTML attributes, HTML body text, or XML
  100: element content without triggering encoding errors or security issues.
  101: 
  102: =head2 Why use this module?
  103: 
  104: L<HTML::Entities> is the obvious alternative for HTML, but it makes strict
  105: assumptions about input encoding that cause silent failures when the input
  106: arrives as raw UTF-8 bytes, already-partially-encoded entities, or a mix of
  107: both.  Encode::Wide handles all three representations through a multi-pass
  108: pipeline and falls back to L<HTML::Entities> numeric encoding for any
  109: character not explicitly listed in its tables.
  110: 
  111: For XML, L<XML::Entities> works in the opposite direction (decoding entities,
  112: not encoding them).  Encode::Wide fills that gap.
  113: 
  114: =head2 Input
  115: 
  116: Both functions accept:
  117: 
  118: =over 4
  119: 
  120: =item *
  121: 
  122: A B<Perl Unicode string> (the internal C<utf8> flag is set) - the normal case
  123: when input comes from L<Encode/decode>, a database driver with C<pg_enable_utf8>,
  124: or a source file declared C<use utf8>.
  125: 
  126: =item *
  127: 
  128: A B<raw UTF-8 byte string> - the common case when input arrives from a legacy
  129: web form or an older database driver without automatic decoding.  The pipeline's
  130: raw-byte substitution pass handles this transparently.
  131: 
  132: =item *
  133: 
  134: A B<scalar reference> - C<wide_to_html(string =E<gt> \$var)>.  The string is
  135: read from the referent; the referent is not modified.
  136: 
  137: =item *
  138: 
  139: B<Already-encoded HTML entities> - e.g. C<&eacute;> or C<&lt;>.
  140: By default the pipeline decodes these first so they are not double-encoded.
  141: Pass C<keep_hrefs =E<gt> 1> to suppress decoding when the input contains
  142: trusted HTML that must pass through unchanged.
  143: 
  144: =back
  145: 
  146: =head2 Output
  147: 
  148: Both functions return a B<defined scalar string> containing B<only ASCII
  149: characters> (code points 0x00-0x7F).  The output is safe to concatenate
  150: directly into an HTML or XML document without further escaping.
  151: 
  152: =head2 Choosing between the two functions
  153: 
  154: Use C<wide_to_html> when writing into an HTML context (C<< <p> >>, C<< <td> >>,
  155: attribute values, etc.).  Named entities such as C<&eacute;> and C<&ndash;>
  156: are used wherever possible; they are compact and human-readable in the source.
  157: 
  158: Use C<wide_to_xml> when writing into an XML context (XHTML, RSS, Atom, custom
  159: XML schemas).  Named HTML entities other than the five predefined XML entities
  160: (C<&amp;> C<&lt;> C<&gt;> C<&apos;> C<&quot;>) are not valid in XML.
  161: This function uses only hexadecimal numeric entities (C<&#x0E9;>), which are
  162: valid in all XML 1.0 processors.  Em-dashes and en-dashes are folded to a
  163: plain ASCII hyphen because many XML consumers normalise whitespace and
  164: punctuation anyway.
  165: 
  166: =head1 EXPORT
  167: 
  168: Nothing is exported by default.  Import the functions you need explicitly:
  169: 
  170:     use Encode::Wide qw(wide_to_html);          # one function
  171:     use Encode::Wide qw(wide_to_html wide_to_xml);  # both
  172: 
  173: =head1 COMMON PARAMETERS
  174: 
  175: Both functions accept the following named parameters in addition to C<string>.
  176: Pass them as a flat key-value list:
  177: 
  178:     wide_to_html(string => $text, keep_hrefs => 1, complain => \&handler);
  179: 
  180: =over 4
  181: 
  182: =item C<string> (required)
  183: 
  184: The text to encode.  May be a plain scalar or a B<reference to a scalar>.
  185: Must be defined; passing C<undef> causes the function to C<croak> with a
  186: usage message.
  187: 
  188: =item C<keep_hrefs> (optional, default 0)
  189: 
  190: When true, angle brackets (C<< < >>, C<< > >>) and double-quotes (C<">) are
  191: B<not> escaped, allowing embedded HTML or XML markup to survive intact.
  192: 
  193: B<Security note:> when C<keep_hrefs> is set, entity-decoding is also
  194: suppressed.  Without this suppression, an encoded payload such as
  195: C<&lt;script&gt;> would be decoded to C<< <script> >> and then pass through
  196: unescaped, creating an XSS vector.  With C<keep_hrefs =E<gt> 1> it is the
  197: B<caller's responsibility> to ensure that the input does not contain untrusted
  198: content that could be exploited.
  199: 
  200: =item C<complain> (optional)
  201: 
  202: A code reference called with a diagnostic string when the pipeline encounters a
  203: character it cannot encode.  The function still C<croak>s with a C<BUG:>
  204: prefix after invoking the callback - C<complain> is for logging, not recovery.
  205: 
  206:     wide_to_html(
  207:         string   => $text,
  208:         complain => sub {
  209:             my ($msg) = @_;
  210:             warn "Encode::Wide gap: $msg";
  211:         },
  212:     );
  213: 
  214: =back
  215: 
  216: =head2 wide_to_html
  217: 
  218: Convert a Unicode or UTF-8 string into a pure-ASCII HTML fragment.  Every
  219: non-ASCII character is replaced by its named HTML entity (e.g. C<&eacute;>)
  220: where one exists, or a hexadecimal numeric entity (e.g. C<&#xNNNN;>) otherwise.
  221: Bare ampersands, angle brackets, and double-quotes are also escaped so the
  222: result is safe to embed in HTML body text or attribute values without further
  223: processing.
  224: 
  225: =head3 Arguments
  226: 
  227: All parameters are passed as a flat key-value list.  The C<string> key may be
  228: omitted when passing a bare positional string as the first argument.
  229: 
  230: See L</COMMON PARAMETERS> for C<string>, C<keep_hrefs>, and C<complain>.
  231: 
  232: =over 4
  233: 
  234: =item C<keep_apos> (optional, default 0)
  235: 
  236: When true, apostrophes and their typographic variants (curly single quotes
  237: U+2018, U+2019; grave accent U+0060; Windows-1252 byte 0x98) are B<not>
  238: converted to C<&apos;>.  Useful when the result will be embedded inside a
  239: JavaScript string literal where C<&apos;> is not valid syntax.
  240: 
  241: =back
  242: 
  243: =head3 Returns
  244: 
  245: A defined scalar string whose every character is in the ASCII range
  246: (code points 0x00-0x7F).  The empty string is returned unchanged.
  247: 
  248: =head3 EXAMPLE
  249: 
  250:     use Encode::Wide qw(wide_to_html);
  251: 
  252:     # Accented characters to named entities
  253:     my $out = wide_to_html(string => "na\x{EF}ve caf\x{E9}");
  254:     # => 'na&iuml;ve caf&eacute;'
  255: 
  256:     # Ampersands and angle brackets are escaped
  257:     $out = wide_to_html(string => 'Price < 100 & cost > 0');
  258:     # => 'Price &lt; 100 &amp; cost &gt; 0'
  259: 
  260:     # Existing entities are decoded then re-encoded (no double-encoding)
  261:     $out = wide_to_html(string => '&eacute;');
  262:     # => '&eacute;'
  263: 
  264:     # keep_hrefs: HTML markup passes through; only wide chars are encoded
  265:     $out = wide_to_html(
  266:         string     => '<a href="/m\x{E9}nu">Men\x{FC}</a>',
  267:         keep_hrefs => 1,
  268:     );
  269:     # => '<a href="/m&eacute;nu">Men&uuml;</a>'
  270: 
  271:     # keep_apos: apostrophes kept for JavaScript contexts
  272:     $out = wide_to_html(
  273:         string    => "it\x{2019}s clich\x{E9}",
  274:         keep_apos => 1,
  275:     );
  276:     # => "it\x{2019}s clich&eacute;"
  277: 
  278:     # Scalar reference input
  279:     my $text = "caf\x{E9}";
  280:     $out = wide_to_html(string => \$text);
  281:     # => 'caf&eacute;'
  282: 
  283: =head3 MESSAGES
  284: 
  285: =over 4
  286: 
  287: =item C<Usage: wide_to_html() string not set>
  288: 
  289: B<Fatal> (via C<croak>).  The C<string> parameter was C<undef>.
  290: Resolution: pass a defined scalar or scalar reference.
  291: 
  292: =item C<TODO: wide_to_html(E<lt>hex-tokens...E<gt>)>
  293: 
  294: B<Warning> (via C<carp>).  A character survived all three byte_map passes and
  295: the C<encode_entities_numeric> fallback.  The hex tokens in the message
  296: identify the unhandled codepoint(s).
  297: Resolution: add the character to the appropriate byte_map array, or file a bug
  298: report at L<https://rt.cpan.org/Public/Dist/Display.html?Name=Encode-Wide>.
  299: 
  300: =item C<BUG: wide_to_html(E<lt>hex-tokens...E<gt>)>
  301: 
  302: B<Fatal> (via C<croak>), always preceded by the C<TODO> warning above.
  303: The same unhandled-character condition caused a hard failure.  This should
  304: never occur in normal use; it indicates a gap in the character tables.
  305: 
  306: =back
  307: 
  308: =head3 API SPECIFICATION
  309: 
  310: =head4 Input
  311: 
  312:     {
  313:         string     => { type => SCALAR | SCALARREF, required => 1, defined => 1 },
  314:         keep_hrefs => { type => BOOLEAN, optional => 1, default => 0 },
  315:         keep_apos  => { type => BOOLEAN, optional => 1, default => 0 },
  316:         complain   => { type => CODEREF,  optional => 1 },
  317:     }
  318: 
  319: =head4 Output
  320: 
  321:     { type => SCALAR, constraint => sub { $_[0] !~ /[^[:ascii:]]/ } }
  322: 
  323: =head3 PSEUDOCODE
  324: 
  325:     1. Unless keep_hrefs: decode HTML entities via HTML::Entities::decode
  326:        and the four extra named entities (&ccaron; &zcaron; &Zcaron; &Scaron;)
  327:     2. Escape bare & not followed by a valid entity name
  328:        (possessive ++ quantifier prevents ReDoS backtracking)
  329:     3. Unless keep_hrefs: escape <, >, and " using %_HTML_ESCAPE (no /e eval)
  330:     4. First byte_map pass: typographic punctuation and exclamation mark
  331:     5. Unless keep_apos: encode apostrophe variants to &apos;
  332:        using an alternation regex built from the apostrophe key set
  333:     6. Early return if the string is now pure ASCII
  334:     7. Second byte_map pass: raw UTF-8 byte sequences -> named HTML entities
  335:     8. Third byte_map pass: Perl Unicode chars (\N{U+...}) -> named HTML entities
  336:     9. Fallback: HTML::Entities::encode_entities_numeric for any remaining
  337:        non-ASCII codepoints
  338:    10. If non-ASCII still remains after the fallback: invoke complain callback,
  339:        carp a TODO warning, then croak a BUG error
  340: 
  341: =cut
  342: 
  343: sub wide_to_html
  344: {
345 → 350 → 354  345: 	my $params = Params::Get::get_params('string', @_);
  346: 
  347: 	my $string = $params->{'string'};
  348: 	my $complain = $params->{'complain'};
  349: 
  350: 	if(!defined($string)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

351: croak 'Usage: wide_to_html() string not set'; 352: } 353: 354 → 354 → 366 354: if(ref($string) eq 'SCALAR') {

Mutants (Total: 1, Killed: 1, Survived: 0)

355: $string = ${$string}; 356: } 357: 358: 359: # SECURITY: skip entity-decoding when keep_hrefs is set. Calling 360: # HTML::Entities::decode before the keep_hrefs gate converts encoded 361: # payloads like &lt;script&gt; to raw <script>, which then bypass 362: # re-escaping and produce XSS output. When the caller asserts the input 363: # contains trusted HTML (keep_hrefs => 1) we treat the text as-is and 364: # only encode wide characters; entity-normalisation is then the caller's 365: # responsibility. 366 → 366 → 376 366: unless($params->{'keep_hrefs'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

367: $string = HTML::Entities::decode($string); 368: 369: # Decode the four named entities HTML::Entities::decode misses 370: $string =~ s/($_EXTRA_ENTITY_RE)/$_EXTRA_ENTITY_MAP{$1}/g; 371: } 372: 373: # Escape bare & not already part of a valid entity. 374: # Possessive ++ on the char class prevents O(n^2) backtracking (ReDoS) 375: # when the input contains & not followed by a semicolon-terminated name. 376 → 378 → 388 376: $string =~ s/&(?![A-Za-z#0-9]++;)/&amp;/g; 377: 378: unless($params->{'keep_hrefs'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

379: # Escape the three characters that break HTML attribute or body contexts. 380: # Module-level %_HTML_ESCAPE eliminates the /e eval flag. 381: $string =~ s/([<>"])/$_HTML_ESCAPE{$1}/g; 382: } 383: 384: # $string =~ s/&db=/&amp;db=/g; 385: # $string =~ s/&id=/&amp;id=/g; 386: 387: # Table of byte-sequences->entities 388 → 403 → 417 388: my @byte_map = ( 389: ['“', '&quot;'], # U+201C 390: ['”', '&quot;'], # U+201D 391: ["\xe2\x80\x9c", '&quot;'], # “ 392: ["\xe2\x80\x9d", '&quot;'], # ” 393: ["\xe2\x80\x93", '&ndash;'], 394: ["\xe2\x80\x94", '&mdash;'], 395: ["\xe2\x80\x98", '&apos;'], # ‘ 396: ["\xe2\x80\x99", '&apos;'], # ’ 397: ["\xe2\x80\xA6", '...'], # … 398: ['!', '&excl;'], # Do this early before the ascii check, since it's an ascii character 399: ); 400: 401: $string = _sub_map(\$string, \@byte_map); 402: 403: unless($params->{'keep_apos'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

404: # Multi-byte curly apostrophes can’t be combined in a char-class, so use 405: # an alternation regex built from the key set — no /e eval needed. 406: my %apos_map = ( 407: "'" => '&apos;', 408: "\x{2018}" => '&apos;', # U+2018 left single quotation mark 409: "\x{2019}" => '&apos;', # U+2019 right single quotation mark 410: "\x{0060}" => '&apos;', # U+0060 grave accent used as apostrophe 411: "\x98" => '&apos;', 412: ); 413: my $apos_re = join '|', map { quotemeta } keys %apos_map; 414: $string =~ s/($apos_re)/$apos_map{$1}/g; 415: } 416: 417 → 417 → 421 417: if($string !~ /[^[:ascii:]]/) {

Mutants (Total: 1, Killed: 1, Survived: 0)

418: return $string;

Mutants (Total: 2, Killed: 2, Survived: 0)

419: } 420: 421 → 580 → 595 421: @byte_map = ( 422: ["\xc2\xa0", ' '], # Non breaking space 423: ["\xc2\xa3", '&pound;'], 424: ["\xc2\xa9", '&copy;'], 425: ["\xc2\xae", '&reg;'], 426: ["\xc3\xa2", '&acirc;'], 427: ["\xc3\xa4", '&auml;'], 428: ["\xc3\xa9", '&eacute;'], 429: ["\xc2\xaa", '&ordf;'], # ª 430: ["\xc2\xab", '&quot;'], # « 431: ["\xc2\xbb", '&quot;'], # » 432: ["\xc3\x81", '&Aacute;'], # Á 433: ["\xc3\x83", '&Icirc;'], # ÃŽ 434: ["\xc3\x9e", '&THORN;'], # Þ 435: ["\xc3\xa0", '&agrave;'], # à 436: ["\xc3\xa1", '&aacute;'], # á 437: ["\xc3\xad", '&iacute;'], # í 438: ["\xc3\xb0", '&eth;'], # ð 439: ["\xc3\xba", '&uacute;'], # ú 440: ["\xc3\xb4", '&ocirc;'], # ô 441: ["\xc3\xb6", '&ouml;'], 442: ["\xc3\xb8", '&oslash;'], # ø 443: ["\xc5\xa1", '&scaron;'], 444: ["\xc4\x8d", '&ccaron;'], 445: ["\xc5\xbd", '&Zcaron;'], 446: ["\xc5\xbe", '&zcaron;'], 447: ["\xc3\xa5", '&aring;'], # Ã¥ 448: ["\xc3\xa7", '&ccedil;'], 449: ["\xc3\xaf", '&iuml;'], # ï 450: ["\xc3\xb3", '&oacute;'], 451: ["\xc3\x96", '&Ouml;'], # Ö 452: ["\xc3\xa8", '&egrave;'], 453: ["\xc3\x89", '&Eacute;'], 454: ["\xc3\x9f", '&szlig;'], 455: ["\xc3\xaa", '&ecirc;'], 456: ["\xc3\xab", '&euml;'], 457: ["\xc3\xae", '&icirc;'], 458: ["\xc3\xbb", '&ucirc;'], 459: ["\xc3\xbc", '&uuml;'], # ü 460: ["\xc3\xbe", '&thorn;'], # þ 461: ["\xc5\x9b", '&sacute;'], 462: ["\xc5\xa0", '&Scaron;'], 463: ["\xe2\x80\x93", '&ndash;'], 464: ["\xe2\x80\x94", '&mdash;'], 465: ["\xc3\xb1", '&ntilde;'], # ñ 466: ["\xe2\x80\x9c", '&quot;'], 467: ["\xe2\x80\x9d", '&quot;'], 468: ["\xe2\x80\xa6", '...'], 469: ["\xe2\x97\x8f", '&#x25CF;'], # ● 470: ["\N{U+00A0}", ' '], 471: ["\N{U+00A3}", '&pound;'], 472: ["\N{U+00A9}", '&copy;'], 473: ["\N{U+00AA}", '&ordf;'], # ª 474: ["\N{U+00AB}", '&quot;'], # « 475: ["\N{U+00AE}", '&reg;'], 476: ["\N{U+00B5}", '&micro;'], # µ 477: ["\N{U+00BB}", '&quot;'], # » 478: ["\N{U+00CE}", '&Icirc;'], # ÃŽ 479: ["\N{U+00DE}", '&THORN;'], # Þ 480: ["\N{U+0161}", '&scaron;'], 481: ["\N{U+010D}", '&ccaron;'], 482: ["\N{U+017D}", '&Zcaron;'], 483: ["\N{U+017E}", '&zcaron;'], 484: ["\N{U+00C9}", '&Eacute;'], 485: ["\N{U+00D6}", '&Ouml;'], # Ö 486: ["\N{U+00DF}", '&szlig;'], # ß 487: ["\N{U+00E1}", '&aacute;'], # á 488: ["\N{U+00E2}", '&acirc;'], 489: ["\N{U+00E4}", '&auml;'], 490: ["\N{U+00E5}", '&aring;'], # Ã¥ 491: ["\N{U+00E0}", '&agrave;'], # à 492: ["\N{U+00E7}", '&ccedil;'], # ç 493: ["\N{U+00E8}", '&egrave;'], 494: ["\N{U+00E9}", '&eacute;'], 495: ["\N{U+00ED}", '&iacute;'], # í 496: ["\N{U+00EE}", '&icirc;'], 497: ["\N{U+00EF}", '&iuml;'], # ï 498: ["\N{U+00F0}", '&eth;'], # ð 499: ["\N{U+00F1}", '&ntilde;'], # ñ 500: ["\N{U+00F4}", '&ocirc;'], # ô 501: ["\N{U+00F6}", '&ouml;'], 502: ["\N{U+00F8}", '&oslash;'], # ø 503: ["\N{U+00FA}", '&uacute;'], # ú 504: ["\N{U+00FC}", '&uuml;'], # ü 505: ["\N{U+00FE}", '&thorn;'], # þ 506: ["\N{U+00C1}", '&Aacute;'], # Á 507: ["\N{U+00C9}", '&Eacute;'], 508: ["\N{U+00CA}", '&ecirc;'], 509: ["\N{U+00EB}", '&euml;'], 510: ["\N{U+00F3}", '&oacute;'], 511: ["\N{U+015B}", '&sacute;'], 512: ["\N{U+00FB}", '&ucirc;'], 513: ["\N{U+0160}", '&Scaron;'], 514: ["\N{U+2013}", '&ndash;'], 515: ["\N{U+2014}", '&mdash;'], 516: ["\N{U+2018}", '&quot;'], 517: ["\N{U+2019}", '&quot;'], 518: ["\N{U+201C}", '&quot;'], 519: ["\N{U+201D}", '&quot;'], 520: ["\N{U+2026}", '...'], # … 521: ["\N{U+2122}", '&trade;'], # â„¢ 522: ["\xe2\x84\xa2", '&trade;'], # â„¢ UTF-8 523: ["\N{U+25CF}", '&#x25CF;'], # ● 524: ); 525: 526: $string = _sub_map(\$string, \@byte_map); 527: 528: 529: @byte_map = ( 530: [ 'Á', '&Aacute;' ], 531: [ 'Ã¥', '&aring;' ], 532: [ 'ª', '&ordf;' ], 533: [ 'Å¡', '&scaron;' ], 534: [ 'Å ', '&Scaron;' ], 535: [ 'č', '&ccaron;' ], 536: [ 'Ž', '&Zcaron;' ], 537: [ 'ž', '&zcaron;' ], 538: [ 'à', '&agrave;' ], # à 539: [ 'á', '&aacute;' ], 540: [ 'â', '&acirc;' ], 541: [ 'é', '&eacute;' ], 542: [ 'è', '&egrave;' ], 543: [ 'ç', '&ccedil;' ], 544: [ 'ê', '&ecirc;' ], 545: [ 'ë', '&euml;' ], 546: [ 'ð', '&eth;' ], 547: [ 'í', '&iacute;' ], 548: [ 'ï', '&iuml;' ], 549: [ 'ÃŽ', '&Icirc;' ], 550: [ '©', '&copy;' ], 551: [ '®', '&reg;' ], 552: [ 'ó', '&oacute;' ], 553: [ 'ô', '&ocirc;' ], 554: [ 'ö', '&ouml;' ], 555: [ 'ø', '&oslash;' ], 556: [ 'Å›', '&sacute;' ], 557: [ 'Þ', '&THORN;' ], 558: [ 'þ', '&thorn;' ], 559: [ 'û', '&ucirc;' ], 560: [ 'ü', '&uuml;' ], 561: [ 'ú', '&uacute;' ], 562: [ 'µ', '&micro;'], 563: [ '£', '&pound;' ], 564: [ 'ß', '&szlig;' ], 565: [ '–', '&ndash;' ], 566: [ '—', '&mdash;' ], 567: [ 'ñ', '&ntilde;' ], 568: [ '“', '&quot;' ], 569: [ '”', '&quot;' ], 570: [ '«', '&quot;' ], 571: [ '»', '&quot;' ], 572: [ '…', '...' ], 573: [ 'â„¢', '&trade;' ], 574: [ '●', '&#x25CF;' ], 575: [ "\x80\$", ' ' ], 576: ); 577: 578: $string = _sub_map(\$string, \@byte_map); 579: 580: if($string =~ /[^[:ascii:]]/) {

Mutants (Total: 1, Killed: 1, Survived: 0)

581: $string = HTML::Entities::encode_entities_numeric($string, '\x80-\x{10FFFF}'); 582: if($string =~ /[^[:ascii:]]/) {

Mutants (Total: 1, Killed: 1, Survived: 0)

583: $complain->("TODO: wide_to_html($string)") if($complain); 584: # Sanitize non-ASCII to hex tokens before embedding in the error message 585: $string =~ s{ 586: ([^[:ascii:]]) 587: }{ 588: '>>>>' . sprintf('%04X', ord($1)) . '<<<<' 589: }gex; 590: carp "TODO: wide_to_html($string)"; 591: croak "BUG: wide_to_html($string)"; 592: } 593: } 594: 595: return $string;

Mutants (Total: 2, Killed: 2, Survived: 0)

596: } 597: 598: =head2 wide_to_xml 599: 600: Convert a Unicode or UTF-8 string into a pure-ASCII XML fragment. Every 601: non-ASCII character is replaced by a hexadecimal numeric entity 602: (e.g. C<&#x0E9;>). Only numeric entities are used because named HTML entities 603: such as C<&eacute;> are not defined in XML 1.0 outside of XHTML with a DTD. 604: Em-dashes and en-dashes are folded to a plain ASCII hyphen C<->. 605: Bare ampersands, angle brackets, and double-quotes are escaped so the output 606: is valid XML element content. 607: 608: =head3 Arguments 609: 610: All parameters are passed as a flat key-value list. The C<string> key may be 611: omitted when passing a bare positional string as the first argument. 612: 613: See L</COMMON PARAMETERS> for C<string>, C<keep_hrefs>, and C<complain>. 614: This function does not accept C<keep_apos>. 615: 616: =head3 Returns 617: 618: A defined scalar string whose every character is in the ASCII range 619: (code points 0x00-0x7F). The empty string is returned unchanged. 620: 621: =head3 EXAMPLE 622: 623: use Encode::Wide qw(wide_to_xml); 624: 625: # Accented characters become numeric entities 626: my $out = wide_to_xml(string => "SURN \x{017D}ganjar"); 627: # => 'SURN &#x17D;ganjar' 628: 629: # En-dash and em-dash are folded to a plain hyphen 630: $out = wide_to_xml(string => "2020\x{2013}2026"); 631: # => '2020-2026' 632: 633: # Ampersands and angle brackets are XML-escaped 634: $out = wide_to_xml(string => 'a < b & c > 0'); 635: # => 'a &lt; b &amp; c &gt; 0' 636: 637: # keep_hrefs: XML tags pass through; wide chars are still encoded 638: $out = wide_to_xml( 639: string => '<item lang="fr">Caf\x{E9}</item>', 640: keep_hrefs => 1, 641: ); 642: # => '<item lang="fr">Caf&#x0E9;</item>' 643: 644: # Scalar reference input 645: my $text = "caf\x{E9}"; 646: $out = wide_to_xml(string => \$text); 647: # => 'caf&#x0E9;' 648: 649: =head3 MESSAGES 650: 651: =over 4 652: 653: =item C<Usage: wide_to_xml() string not set> 654: 655: B<Fatal> (via C<croak>). The C<string> parameter was C<undef>. 656: Resolution: pass a defined scalar or scalar reference. 657: 658: =item C<TODO: wide_to_xml(E<lt>hex-tokens...E<gt>)> 659: 660: B<Warning> (via C<carp>). A character survived all three byte_map passes. 661: The hex tokens in the message identify the unhandled codepoint(s). 662: Resolution: add the character to the appropriate byte_map array, or file a bug 663: report at L<https://rt.cpan.org/Public/Dist/Display.html?Name=Encode-Wide>. 664: 665: =item C<BUG: wide_to_xml(E<lt>hex-tokens...E<gt>)> 666: 667: B<Fatal> (via C<croak>), always preceded by the C<TODO> warning above. 668: This should never occur in normal use; it indicates a gap in the XML character 669: tables. Unlike C<wide_to_html>, there is no numeric-entity fallback for XML 670: because there is no safe generic fallback that is valid in all XML contexts. 671: 672: =back 673: 674: =head3 API SPECIFICATION 675: 676: =head4 Input 677: 678: { 679: string => { type => SCALAR | SCALARREF, required => 1, defined => 1 }, 680: keep_hrefs => { type => BOOLEAN, optional => 1, default => 0 }, 681: complain => { type => CODEREF, optional => 1 }, 682: } 683: 684: =head4 Output 685: 686: { type => SCALAR, constraint => sub { $_[0] !~ /[^[:ascii:]]/ } } 687: 688: =head3 PSEUDOCODE 689: 690: 1. Unless keep_hrefs: decode HTML entities via HTML::Entities::decode 691: and the four extra named entities (&ccaron; &zcaron; &Zcaron; &Scaron;) 692: 2. Escape bare & not followed by a valid entity name 693: (possessive ++ quantifier prevents ReDoS backtracking) 694: 3. Unless keep_hrefs: escape <, >, and " using %_HTML_ESCAPE (no /e eval) 695: 4. First byte_map pass: curly quotes -> &quot;, dashes -> -, apostrophes 696: 5. Early return if the string is now pure ASCII 697: 6. Second byte_map pass: raw UTF-8 byte sequences -> numeric XML entities 698: 7. Third byte_map pass: Perl Unicode chars (\N{U+...}) -> numeric XML entities 699: 8. If non-ASCII still remains: invoke complain callback, carp a TODO warning, 700: then croak a BUG error 701: 702: =cut 703: 704: # See https://www.compart.com/en/unicode/U+0161 etc. 705: # https://www.compart.com/en/unicode/U+00EB 706: sub wide_to_xml 707: { 708 → 713 → 717 708: my $params = Params::Get::get_params('string', @_); 709: 710: my $string = $params->{'string'}; 711: my $complain = $params->{'complain'}; 712: 713: if(!defined($string)) {

Mutants (Total: 1, Killed: 1, Survived: 0)

714: croak 'Usage: wide_to_xml() string not set'; 715: } 716: 717 → 717 → 724 717: if(ref($string) eq 'SCALAR') {

Mutants (Total: 1, Killed: 1, Survived: 0)

718: $string = ${$string}; 719: } 720: 721: 722: # SECURITY: skip entity-decoding when keep_hrefs is set — same rationale as 723: # wide_to_html: decoded payloads bypass re-escaping and produce XSS output. 724 → 724 → 733 724: unless($params->{'keep_hrefs'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

725: $string = HTML::Entities::decode($string); 726: 727: # Decode the four named entities HTML::Entities::decode misses 728: $string =~ s/($_EXTRA_ENTITY_RE)/$_EXTRA_ENTITY_MAP{$1}/g; 729: } 730: 731: # Possessive ++ prevents O(n^2) backtracking (ReDoS) on inputs like 732: # "&aaaaaa..." with no closing semicolon. 733 → 735 → 748 733: $string =~ s/&(?![A-Za-z#0-9]++;)/&amp;/g; 734: 735: unless($params->{'keep_hrefs'}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

736: # Escape ASCII markup chars; %_HTML_ESCAPE eliminates the /e eval flag. 737: $string =~ s/([<>"])/$_HTML_ESCAPE{$1}/g; 738: } 739: 740: # $string =~ s/‘/&apos;/g; 741: # $string =~ s/’/&apos;/g; 742: # $string =~ s/‘/&apos;/g; 743: # $string =~ s/‘/&apos;/g; 744: # $string =~ s/\x98/&apos;/g; 745: # $string =~ s/['‘’‘\x98]/&apos;/g; 746: 747: # Table of byte-sequences->entities 748 → 793 → 797 748: my @byte_map = ( 749: [ "\xe2\x80\x9c", '&quot;' ], # “ 750: [ "\xe2\x80\x9d", '&quot;' ], # ” 751: [ '“', '&quot;' ], # U+201C 752: [ '”', '&quot;' ], # U+201D 753: [ "\xe2\x80\x93", '-' ], # ndash 754: [ "\xe2\x80\x94", '-' ], # mdash 755: [ "\xe2\x80\x98", '&apos;' ], # ‘ 756: [ "\xe2\x80\x99", '&apos;' ], # ’ 757: [ "\xe2\x80\xA6", '...' ], # … 758: [ "'", '&apos;' ], 759: [ '‘', '&apos;' ], 760: [ '’', '&apos;' ], 761: [ '‘', '&apos;' ], 762: [ "\x98", '&apos;' ], 763: ); 764: 765: $string = _sub_map(\$string, \@byte_map); 766: 767: # DEAD CODE: the %entity_map below has keys that are multi-character HTML 768: # entity strings (e.g. '&copy;', '&Aacute;'). The s{(.)}{...}gex loop 769: # that follows matches one character at a time; a single char can never 770: # equal a multi-char key, so the true branch is unreachable. 771: # By the time the string reaches here, HTML::Entities::decode has already 772: # converted all recognised named entities to their Unicode equivalents, 773: # which the byte_map passes below encode correctly. 774: # Additional bugs in the removed block: '&uacute;' => '&#0FA;' (decimal, 775: # not hex) and '&ucirc;' => '&#x0F4;' (maps to o-circumflex, not u-circumflex). 776: # This block has been commented out to remove dead code and reach 100% 777: # branch coverage. See t/extended_tests.t section 3 for validation. 778: # 779: # %entity_map = ( 780: # '&copy;' => '&#x0A9;', 781: # '&Aacute;' => '&#x0C1;', 782: # '&ccaron;' => '&#x10D;', 783: # ... 784: # '&excl;' => '!', 785: # ); 786: # $string =~ s{(.)}{ 787: # my $cp = $1; 788: # exists $entity_map{$cp} 789: # ? $entity_map{$cp} 790: # : $cp 791: # }gex; 792: 793: if($string !~ /[^[:ascii:]]/) {

Mutants (Total: 1, Killed: 1, Survived: 0)

794: return $string;

Mutants (Total: 2, Killed: 2, Survived: 0)

795: } 796: 797 → 952 → 963 797: @byte_map = ( 798: ["\xc2\xa0", ' '], # Non breaking space 799: ["\xc2\xa3", '&#x0A3;'], # £ 800: ["\xc2\xa9", '&#x0A9;'], 801: ["\xc2\xaa", '&#x0AA;'], # ª 802: ["\xc2\xab", '&quot;'], # « 803: ["\xc2\xae", '&#x0AE;'], 804: ["\xc3\x81", '&#x0C1;'], # Á 805: ["\xc3\x8e", '&#x0CE;'], # ÃŽ 806: ["\xc3\xa0", '&#x0E0;'], # à 807: ["\xc3\xa1", '&#x0E1;'], # á 808: ["\xc3\xa5", '&#x0E5;'], # Ã¥ 809: ["\xc3\xa9", '&#x0E9;'], 810: ["\xc3\xaf", '&#x0EF;'], # ï 811: ["\xc3\xb1", '&#x0F1;'], # ntilde ñ 812: ["\xc5\xa1", '&#x161;'], 813: ["\xc4\x8d", '&#x10D;'], 814: ["\xc5\xbd", '&#x17D;'], # Ž 815: ["\xc5\xbe", '&#x17E;'], # ž 816: ["\xc3\x96", '&#x0D6;'], # Ö 817: ["\xc3\x9e", '&#x0DE;'], # Þ 818: ["\xc3\x9f", '&#x0DF;'], # ß 819: ["\xc3\xa2", '&#x0E2;'], # â 820: ["\xc3\xad", '&#x0ED;'], # í 821: ["\xc3\xa4", '&#x0E4;'], # ä 822: ["\xc3\xa7", '&#x0E7;'], # ç 823: ["\xc3\xb0", '&#x0F0;'], # ð 824: ["\xc3\xb3", '&#x0F3;'], # ó 825: ["\xc3\xb8", '&#x0F8;'], # ø 826: ["\xc3\xbc", '&#x0FC;'], # ü 827: ["\xc3\xbe", '&#x0FE;'], # þ 828: ["\xc3\xa8", '&#x0E8;'], # è 829: ["\xc3\xee", '&#x0EE;'], 830: ["\xc3\xb4", '&#x0F4;'], # ô 831: ["\xc3\xb6", '&#x0F6;'], # ö 832: ["\xc3\x89", '&#x0C9;'], 833: ["\xc3\xaa", '&#x0EA;'], 834: ["\xc3\xab", '&#x0EB;'], # eumlaut 835: ["\xc3\xba", '&#x0FA;'], # ú 836: ["\xc3\xbb", '&#x0BB;'], # û - ucirc 837: ["\xc5\x9b", '&#x15B;'], # Å› - sacute 838: ["\xc5\xa0", '&#x160;'], 839: ["\xe2\x80\x93", '-'], 840: ["\xe2\x80\x94", '-'], 841: ["\xe2\x80\x9c", '&quot;'], 842: ["\xe2\x80\x9d", '&quot;'], 843: ["\xe2\x80\xa6", '...'], 844: ["\xe2\x97\x8f", '&#x25CF;'], # ● 845: ["\xe3\xb1", '&#x0F1;'], # ntilde ñ - what's this one? 846: 847: ["\N{U+00A0}", ' '], 848: ["\N{U+010D}", '&#x10D;'], 849: ["\N{U+00AB}", '&quot;'], # « 850: ["\N{U+00AE}", '&#x0AE;'], # ® 851: ["\N{U+00B5}", '&#x0B5;'], # µ 852: ["\N{U+00C1}", '&#x0C1;'], # Á 853: ["\N{U+00CE}", '&#x0CE;'], # ÃŽ 854: ["\N{U+00DE}", '&#x0DE;'], # Þ 855: ["\N{U+00E0}", '&#x0E0;'], # à 856: ["\N{U+00E4}", '&#x0E4;'], # ä 857: ["\N{U+00E5}", '&#x0E5;'], # Ã¥ 858: ["\N{U+00EA}", '&#x0EA;'], 859: ["\N{U+00ED}", '&#x0ED;'], 860: ["\N{U+00EE}", '&#x0EE;'], 861: ["\N{U+00FE}", '&#x0FE;'], # þ 862: ["\N{U+00C9}", '&#x0C9;'], 863: ["\N{U+017D}", '&#x17D;'], # Ž 864: ["\N{U+017E}", '&#x17E;'], # ž 865: ["\N{U+00D6}", '&#x0D6;'], # Ö 866: ["\N{U+00DF}", '&#x0DF;'], # ß 867: ["\N{U+00E1}", '&#x0E1;'], # á - aacute 868: ["\N{U+00E2}", '&#x0E2;'], 869: ["\N{U+00E8}", '&#x0E8;'], # è 870: ["\N{U+00EF}", '&#x0EF;'], # ï 871: ["\N{U+00F0}", '&#x0F0;'], # ð 872: ["\N{U+00F1}", '&#x0F1;'], # ñ 873: ["\N{U+00F3}", '&#x0F3;'], # ó 874: ["\N{U+00F4}", '&#x0F4;'], # ô 875: ["\N{U+00F6}", '&#x0F6;'], # ö 876: ["\N{U+00F8}", '&#x0F8;'], # ø 877: ["\N{U+00FA}", '&#x0FA;'], # ú 878: ["\N{U+00FC}", '&#x0FC;'], # ü 879: ["\N{U+015B}", '&#x15B;'], # Å› 880: ["\N{U+00E9}", '&#x0E9;'], 881: ["\N{U+00E7}", '&#x0E7;'], # ç 882: ["\N{U+00EB}", '&#x0EB;'], # ë 883: ["\N{U+00FB}", '&#x0FB;'], # û 884: ["\N{U+0160}", '&#x160;'], 885: ["\N{U+0161}", '&#x161;'], 886: ["\N{U+00A3}", '&#x0A3;'], # £ 887: ["\N{U+00A9}", '&#x0A9;'], # © 888: ["\N{U+2013}", '-'], 889: ["\N{U+2014}", '-'], 890: ["\N{U+2018}", '&quot;'], 891: ["\N{U+2019}", '&quot;'], 892: ["\N{U+201C}", '&quot;'], 893: ["\N{U+201D}", '&quot;'], 894: ["\N{U+2026}", '...'], # … 895: ["\N{U+2122}", '&#x2122;'], # â„¢ 896: ["\xe2\x84\xa2", '&#x2122;'], # â„¢ UTF-8 897: ["\N{U+25CF}", '&#x25CF;'], # ● 898: ); 899: 900: $string = _sub_map(\$string, \@byte_map); 901: 902: @byte_map = ( 903: ["'", '&#039;'], 904: ["\x98", '&#039;'], 905: ['©', '&#x0A9;'], 906: ['ª', '&#x0AA;'], 907: ['®', '&#x0AE;'], 908: ['Ã¥', '&#x0E5;'], 909: ['Å¡', '&#x161;'], 910: ['č', '&#x10D;'], 911: ['Ž', '&#x17D;'], 912: ['ž', '&#x17E;'], 913: ['£', '&#x0A3;'], 914: ['µ', '&#x0B5;'], 915: ['à', '&#x0E0;'], # à 916: ['á', '&#x0E1;'], # á 917: ['â', '&#x0E2;'], 918: ['ä', '&#x0E4;'], # ä 919: ['Á', '&#x0C1;'], # Á 920: ['Ö', '&#x0D6;'], 921: ['ß', '&#x0DF;'], 922: ['ç', '&#x0E7;'], 923: ['è', '&#x0E8;'], 924: ['é', '&#x0E9;'], 925: ['ê', '&#x0EA;'], 926: ['ë', '&#x0EB;'], 927: ['í', '&#x0ED;'], 928: ['ï', '&#x0EF;'], 929: ['ÃŽ', '&#x0CE;'], # ÃŽ 930: ['Þ', '&#x0DE;'], # Þ 931: ['ð', '&#x0F0;'], # ð 932: ['ø', '&#x0F8;'], # ø 933: ['û', '&#x0FB;'], 934: ['ñ', '&#x0F1;'], 935: ['ú', '&#x0FA;'], 936: ['ü', '&#x0FC;'], 937: ['þ', '&#x0FE;'], # þ 938: ['“', '&quot;'], 939: ['”', '&quot;'], 940: ['«', '&quot;'], 941: ['»', '&quot;'], 942: ['—', '-'], 943: ['–', '-'], 944: ['…', '...'], 945: ['â„¢', '&#x2122;'], 946: ['●', '&#x25CF;'], 947: ["\x80\$", ' '], 948: ); 949: 950: $string = _sub_map(\$string, \@byte_map); 951: 952: if($string =~ /[^[:ascii:]]/) {

Mutants (Total: 1, Killed: 1, Survived: 0)

953: $complain->("TODO: wide_to_xml($string)") if($complain); 954: # Sanitize non-ASCII to hex tokens before embedding in the error message 955: $string =~ s{ 956: ([^[:ascii:]]) 957: }{ 958: '>>>>' . sprintf('%04X', ord($1)) . '<<<<' 959: }gex; 960: carp "TODO: wide_to_xml($string)"; 961: croak "BUG: wide_to_xml($string)"; 962: } 963: return $string;

Mutants (Total: 2, Killed: 2, Survived: 0)

964: } 965: 966: # _sub_map -- apply a list of [from, to] substitutions in a single pass. 967: # 968: # Purpose: Replace every occurrence of each 'from' key with its 'to' value. 969: # Longer keys take priority over shorter ones (longest-match-first). 970: # Entry: $_[0] scalar-ref to string; $_[1] arrayref of [from, to] pairs. 971: # Duplicate 'from' keys: the first definition wins. 972: # Exit: Returns a new string (the scalar-ref argument is not modified). 973: # Side Effects: None. 974: sub _sub_map 975: { 976 → 988 → 993 976: my $string = ${$_[0]}; 977: my $byte_map = $_[1]; 978: 979: # Build the alternation regex, longest key first to prevent partial matches 980: my $pattern = join '|', 981: map { quotemeta($_->[0]) } 982: sort { length($b->[0]) <=> length($a->[0]) } 983: @{$byte_map}; 984: 985: # Pre-build a hash for O(1) lookup during substitution. 986: # Iterate in reverse so that the first definition in @byte_map wins on duplicate keys. 987: my %map; 988: for my $pair (reverse @{$byte_map}) { 989: $map{$pair->[0]} = $pair->[1]; 990: } 991: 992: # No /e flag: hash dereference is a simple value interpolation, not code. 993: $string =~ s/($pattern)/$map{$1}/g; 994: 995: return $string;

Mutants (Total: 2, Killed: 2, Survived: 0)

996: } 997: 998: =head1 SECURITY 999: 1000: =head2 XSS via entity decode and keep_hrefs 1001: 1002: By default both functions call C<HTML::Entities::decode> as the first pipeline 1003: step, normalising input like C<&lt;b&gt;> to C<< <b> >> before re-escaping it. 1004: This round-trip is safe when C<keep_hrefs> is false because the re-escape step 1005: then converts C<< < >> and C<< > >> back to C<&lt;> and C<&gt;>. 1006: 1007: When C<keep_hrefs =E<gt> 1> is set, the re-escape step is skipped so that 1008: existing markup survives intact. If the decode step still ran, a malicious 1009: input such as C<&lt;script&gt;alert(1)&lt;/script&gt;> would become the raw 1010: string C<< <script>alert(1)</script> >> and pass through to the output 1011: unescaped, creating a stored XSS vector. 1012: 1013: B<Fix applied in 0.07:> when C<keep_hrefs> is true, the decode step is also 1014: skipped. The pipeline treats the input as already-trusted HTML; wide 1015: characters are still encoded, but entity normalisation becomes the caller's 1016: responsibility. 1017: 1018: =head2 ReDoS in bare-ampersand substitution 1019: 1020: The substitution that escapes bare C<&> characters uses a negative lookahead 1021: to distinguish bare ampersands from valid entity references. A naive 1022: backtracking quantifier inside that lookahead creates O(n^2) work for inputs 1023: such as C<&aaaaa...X> (many word characters, no closing semicolon). 1024: 1025: B<Fix applied in 0.07:> the character class inside the lookahead uses a 1026: possessive quantifier C<[A-Za-z#0-9]++>, which commits matches and prevents 1027: backtracking. Perl 5.10 or later is required, consistent with the declared 1028: C<MIN_PERL_VERSION>. 1029: 1030: =head2 Eval-free substitutions 1031: 1032: All substitutions in this module use plain C</g> rather than C</ge> (evaluate 1033: replacement as Perl code). The C</e> flag was present in earlier versions but 1034: was unnecessary: hash lookups are value interpolation, not executable code. 1035: Removing C</e> eliminates a class of potential code-injection issues should a 1036: future change inadvertently expose user-controlled data in the replacement 1037: expression. 1038: 1039: =head1 LIMITATIONS 1040: 1041: =over 4 1042: 1043: =item Character coverage is hand-maintained 1044: 1045: Both functions use explicit C<@byte_map> tables organised into three passes 1046: (raw UTF-8 bytes, C<\N{U+...}> named chars, literal Unicode source chars). 1047: Characters not covered by these tables fall back to 1048: C<HTML::Entities::encode_entities_numeric> in C<wide_to_html>, or trigger a 1049: fatal C<BUG:> error in C<wide_to_xml> (XML has no safe generic numeric 1050: fallback). To add a missing character, extend all three passes for the 1051: relevant function and add a regression test in C<t/30-basics.t>. 1052: 1053: =item No C<< <script> >> or C<< <style> >> awareness 1054: 1055: C<wide_to_html> encodes wide characters uniformly regardless of context. It 1056: does not detect content inside C<< <script> >> or C<< <style> >> blocks, so 1057: passing a complete HTML document through this function will corrupt embedded 1058: scripts and stylesheets. Feed only text fragments or attribute values, not 1059: full documents. 1060: 1061: =item XML numeric entity format uses a minimal hex width 1062: 1063: The XML pipeline outputs C<&#x0E9;> (three hex digits with one leading zero 1064: for values below 0x100) rather than the canonical four-digit form C<&#x00E9;>. 1065: Both representations are valid XML 1.0. Consumers that perform strict byte- 1066: level comparison of entity strings should normalise to a consistent width 1067: before comparing. 1068: 1069: =item Raw binary input is not supported 1070: 1071: The module assumes its input is either a Perl Unicode string (internal C<utf8> 1072: flag set) or a valid UTF-8 byte string. Passing arbitrary binary data or 1073: text in a single-byte encoding other than Latin-1 will produce incorrect 1074: output or trigger decoding errors. Decode the input with L<Encode/decode> 1075: before calling these functions. 1076: 1077: =item C<keep_hrefs> shifts trust to the caller 1078: 1079: When C<keep_hrefs =E<gt> 1> is set, entity-decoding is suppressed and markup 1080: characters pass through unescaped. The caller must guarantee that the input 1081: does not contain untrusted content that could produce XSS output. 1082: 1083: =back 1084: 1085: =head1 SEE ALSO 1086: 1087: =over 4 1088: 1089: =item * L<Test Dashboard|https://nigelhorne.github.io/Encode-Wide/coverage/> 1090: 1091: =item * L<HTML::Entities> — the standard module for HTML entity encoding and decoding 1092: 1093: =item * L<Encode> — Perl's core character encoding framework 1094: 1095: =item * L<XML::Entities> — decodes XML named entities (the inverse of wide_to_xml) 1096: 1097: =item * L<Unicode::Escape> — alternative Unicode escaping approaches 1098: 1099: =item * L<https://www.compart.com/en/unicode/> — Unicode character reference 1100: 1101: =back 1102: 1103: =head1 SUPPORT 1104: 1105: Please report bugs and feature requests through the RT bug tracker: 1106: 1107: https://rt.cpan.org/Public/Dist/Display.html?Name=Encode-Wide 1108: 1109: Or by email: C<bug-encode-wide at rt.cpan.org> 1110: 1111: You will be notified automatically of progress on your report. 1112: 1113: =head1 FORMAL SPECIFICATION 1114: 1115: =head2 wide_to_html 1116: 1117: Let S be the input string, S' the output string. 1118: 1119: ∀ c ∈ S' : ord(c) ≤ 0x7F (ASCII-only output) 1120: S = "" ⟹ S' = "" (empty pass-through) 1121: keep_hrefs = 0 ⟹ "<" ∉ S' ∧ ">" ∉ S' ∧ ∄ bare " in S' 1122: keep_apos = 0 ⟹ ∄ bare apostrophe in S' 1123: ¬∃ bare & in S' (& appears only as part of a valid &name; or &#xNN; entity) 1124: string = undef ⟹ croak("Usage: wide_to_html() string not set") 1125: 1126: =head2 wide_to_xml 1127: 1128: Let S be the input string, S' the output string. 1129: 1130: ∀ c ∈ S' : ord(c) ≤ 0x7F (ASCII-only output) 1131: S = "" ⟹ S' = "" (empty pass-through) 1132: keep_hrefs = 0 ⟹ "<" ∉ S' ∧ ">" ∉ S' ∧ ∄ bare " in S' 1133: U+2013 ∈ S ⟹ "-" ∈ S' ∧ U+2013 ∉ S' (en-dash collapsed) 1134: U+2014 ∈ S ⟹ "-" ∈ S' ∧ U+2014 ∉ S' (em-dash collapsed) 1135: ¬∃ bare & in S' (& appears only as part of a valid &name; or &#xNN; entity) 1136: string = undef ⟹ croak("Usage: wide_to_xml() string not set") 1137: 1138: =head1 AUTHOR 1139: 1140: Nigel Horne, C<< <njh at nigelhorne.com> >> 1141: 1142: =head1 LICENCE AND COPYRIGHT 1143: 1144: Copyright 2025-2026 Nigel Horne. 1145: 1146: This library is free software; you may redistribute it and/or modify it under 1147: the same terms as Perl itself (GPL version 2 or later). 1148: 1149: If you use this module, please let me know at 1150: C<njh at nigelhorne.com>. 1151: 1152: =cut 1153: 1154: 1;