Skip to content

Commit e7f62e3

Browse files
authored
fix(xml): handle non-printable characters (#514)
1 parent cccca1c commit e7f62e3

2 files changed

Lines changed: 258 additions & 4 deletions

File tree

lua/pl/xml.lua

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,7 @@ end
557557

558558

559559
do
560-
local escape_table = {
560+
local xml_escape_table = {
561561
["'"] = "'",
562562
['"'] = """,
563563
["<"] = "&lt;",
@@ -567,12 +567,24 @@ do
567567

568568
--- Escapes a string for safe use in xml.
569569
-- Handles quotes(single+double), less-than, greater-than, and ampersand.
570+
-- Non-printable control characters (ASCII 0-31 except tab/LF/CR, and DEL 127) are escaped as \xHH.
571+
-- High bytes (128-255) are preserved to support UTF-8 encoding.
570572
-- @tparam string str string value to escape
571573
-- @return escaped string
572574
-- @usage
573575
-- local esc = xml.xml_escape([["'<>&]]) --> "&quot;&apos;&lt;&gt;&amp;"
576+
-- local esc = xml.xml_escape("hello\x00world") --> "hello\\x00world"
574577
function _M.xml_escape(str)
575-
return (s_gsub(str, "['&<>\"]", escape_table))
578+
-- First, escape non-printable control characters to \xHH format
579+
-- Pattern: [\x00-\x08\x0B\x0C\x0E-\x1F\x7F]
580+
-- Excludes: tab(0x09), newline(0x0A), carriage return(0x0D)
581+
-- Preserves: high bytes (128-255) for UTF-8 support
582+
str = s_gsub(str, "[%z\1-\8\11\12\14-\31\127]", function(c)
583+
return ("\\x%02X"):format(c:byte())
584+
end)
585+
586+
-- Then, escape XML special characters
587+
return (s_gsub(str, "['&<>\"]", xml_escape_table))
576588
end
577589
end
578590
local xml_escape = _M.xml_escape
@@ -588,11 +600,19 @@ do
588600

589601
--- Unescapes a string from xml.
590602
-- Handles quotes(single+double), less-than, greater-than, and ampersand.
603+
-- Also handles \xHH escape sequences for control characters.
591604
-- @tparam string str string value to unescape
592605
-- @return unescaped string
593606
-- @usage
594-
-- local unesc = xml.xml_escape("&quot;&apos;&lt;&gt;&amp;") --> [["'<>&]]
607+
-- local unesc = xml.xml_unescape("&quot;&apos;&lt;&gt;&amp;") --> [["'<>&]]
608+
-- local unesc = xml.xml_unescape("hello\\x00world") --> "hello\x00world"
595609
function _M.xml_unescape(str)
610+
-- First, unescape \xHH sequences
611+
str = str:gsub("\\x(%x%x)", function(hex)
612+
return string.char(tonumber(hex, 16))
613+
end)
614+
615+
-- Then, unescape XML entities
596616
return (str:gsub( "&(%a+);", escape_table))
597617
end
598618
end

spec/xml_spec.lua

Lines changed: 235 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,17 +549,251 @@ describe("xml", function()
549549
assert.same("&quot;&apos;&lt;&gt;&amp;", esc)
550550
end)
551551

552+
553+
it("escapes non-printable characters as \\xHH", function()
554+
-- Test null byte
555+
local esc = xml.xml_escape("hello\000world")
556+
assert.same("hello\\x00world", esc)
557+
558+
-- Test control characters
559+
local esc2 = xml.xml_escape("\001\002\003")
560+
assert.same("\\x01\\x02\\x03", esc2)
561+
562+
-- Test DEL character
563+
local esc3 = xml.xml_escape("test\127end")
564+
assert.same("test\\x7Fend", esc3)
565+
end)
566+
567+
568+
it("preserves tab, newline, carriage return", function()
569+
local esc = xml.xml_escape("hello\tworld\n")
570+
assert.same("hello\tworld\n", esc)
571+
572+
local esc2 = xml.xml_escape("line1\r\nline2")
573+
assert.same("line1\r\nline2", esc2)
574+
end)
575+
576+
577+
it("escapes high ASCII characters (127-255)", function()
578+
-- Only DEL (127) should be escaped, high bytes (128-255) are preserved for UTF-8
579+
-- Note: Using decimal escape sequences (\ddd) for Lua 5.1 compatibility
580+
-- Lua 5.1 doesn't support \xHH hex escapes
581+
local esc = xml.xml_escape("test\127")
582+
assert.same("test\\x7F", esc)
583+
584+
-- High bytes preserved
585+
local esc2 = xml.xml_escape("test\128\255")
586+
assert.same("test\128\255", esc2)
587+
end)
588+
589+
590+
it("handles mixed content with both special and non-printable chars", function()
591+
-- \0 = null byte, \1 = SOH (using decimal escapes for Lua 5.1)
592+
local esc = xml.xml_escape("hello\0<tag>&\1world")
593+
assert.same("hello\\x00&lt;tag&gt;&amp;\\x01world", esc)
594+
end)
595+
596+
597+
it("handles UTF-8 text correctly", function()
598+
-- UTF-8 multi-byte characters should be preserved (not escaped)
599+
local esc = xml.xml_escape("你好世界")
600+
assert.same("你好世界", esc)
601+
602+
local esc2 = xml.xml_escape("hello 世界 <tag>")
603+
assert.same("hello 世界 &lt;tag&gt;", esc2)
604+
end)
605+
606+
607+
it("handles empty string", function()
608+
local esc = xml.xml_escape("")
609+
assert.same("", esc)
610+
end)
611+
612+
613+
it("handles string with only printable characters", function()
614+
local esc = xml.xml_escape("Hello World 123!")
615+
assert.same("Hello World 123!", esc)
616+
end)
617+
618+
619+
it("escapes binary data in text nodes", function()
620+
local doc = xml.new("data")
621+
-- \0=NUL, \1=SOH, \2=STX, \127=DEL (decimal escapes for Lua 5.1)
622+
doc:text("\0\1\2\127")
623+
assert.same("<data>\\x00\\x01\\x02\\x7F</data>", doc:tostring())
624+
end)
625+
626+
627+
it("escapes binary data in attributes", function()
628+
-- \0 = null byte (decimal escape for Lua 5.1)
629+
local doc = xml.new("data", { content = "hello\0world" })
630+
assert.same("<data content='hello\\x00world'/>", doc:tostring())
631+
end)
632+
633+
634+
it("handles real binary data: all control characters", function()
635+
-- Generate a string with all control characters (0-31, excluding 9, 10, 13)
636+
local control_chars = {}
637+
for i = 0, 31 do
638+
if i ~= 9 and i ~= 10 and i ~= 13 then -- exclude tab, LF, CR
639+
table.insert(control_chars, string.char(i))
640+
end
641+
end
642+
table.insert(control_chars, string.char(127)) -- DEL
643+
local binary_data = table.concat(control_chars)
644+
645+
local escaped = xml.xml_escape(binary_data)
646+
-- Verify all control chars are escaped
647+
assert.is_true(escaped:match("\\x00") ~= nil)
648+
assert.is_true(escaped:match("\\x7F") ~= nil)
649+
-- Should not contain raw control chars
650+
-- Use string.find with plain text search instead of pattern match (Lua 5.1 compatible)
651+
assert.is_false(string.find(escaped, string.char(0), 1, true) ~= nil)
652+
end)
653+
654+
655+
it("handles real binary data: simulated file header", function()
656+
-- Simulate a PNG file header: \x89PNG\r\n\x1A\n
657+
local png_header = string.char(0x89) .. "PNG" .. string.char(0x0D, 0x0A, 0x1A, 0x0A)
658+
local doc = xml.new("file", { format = "png" })
659+
doc:text(png_header)
660+
661+
local result = doc:tostring()
662+
-- \137 is high byte (0x89), preserved for UTF-8, won't be escaped
663+
-- Using decimal escapes: \137=0x89, \13=CR, \10=LF (Lua 5.1 compatible)
664+
assert.is_true(result:match("\137") ~= nil)
665+
assert.is_true(result:match("PNG") ~= nil)
666+
assert.is_true(result:match("\13\10") ~= nil) -- CRLF preserved
667+
assert.is_true(result:match("\\x1A") ~= nil) -- SUB (0x1A) escaped
668+
end)
669+
670+
671+
it("handles real binary data: mixed binary and text", function()
672+
-- Simulate binary data with embedded text (like in some protocols)
673+
local data = "START" .. string.char(0x00, 0x01, 0x02) .. "MIDDLE" .. string.char(0x03, 0x04) .. "END"
674+
local escaped = xml.xml_escape(data)
675+
676+
assert.same("START\\x00\\x01\\x02MIDDLE\\x03\\x04END", escaped)
677+
end)
678+
679+
680+
it("handles real binary data: random binary sequence", function()
681+
-- Generate random-like binary data
682+
local binary = {}
683+
local test_bytes = {0x00, 0x01, 0x05, 0x0E, 0x1F, 0x7F, 0xFF, 0xFE, 0x80}
684+
for _, b in ipairs(test_bytes) do
685+
table.insert(binary, string.char(b))
686+
end
687+
local data = table.concat(binary)
688+
689+
local escaped = xml.xml_escape(data)
690+
-- Control chars should be escaped
691+
assert.is_true(escaped:match("\\x00") ~= nil)
692+
assert.is_true(escaped:match("\\x7F") ~= nil)
693+
-- High bytes (128-255) should be preserved for UTF-8
694+
assert.is_true(escaped:match(string.char(0xFF)) ~= nil)
695+
assert.is_true(escaped:match(string.char(0x80)) ~= nil)
696+
end)
697+
698+
699+
it("handles real binary data: protocol packet", function()
700+
-- Simulate a simple binary protocol packet
701+
-- Format: [STX(0x02)] [LENGTH] [DATA] [ETX(0x03)] [CHECKSUM]
702+
local STX = string.char(0x02)
703+
local ETX = string.char(0x03)
704+
local data = "Hello"
705+
local length = string.char(#data)
706+
local checksum = string.char(0xFF)
707+
local packet = STX .. length .. data .. ETX .. checksum
708+
709+
local escaped = xml.xml_escape(packet)
710+
assert.same("\\x02\\x05Hello\\x03" .. string.char(0xFF), escaped)
711+
end)
712+
552713
end)
553714

554715

555716

556717
describe("xml_unescape()", function()
557718

558-
it("escapes reserved characters", function()
719+
it("unescapes reserved characters", function()
559720
local unesc = xml.xml_unescape("&quot;&apos;&lt;&gt;&amp;")
560721
assert.same([["'<>&]], unesc)
561722
end)
562723

724+
725+
it("unescapes \\xHH control character sequences", function()
726+
-- Using decimal escapes in expected values for Lua 5.1: \0=NUL, \1=SOH, etc.
727+
local unesc = xml.xml_unescape("hello\\x00world")
728+
assert.same("hello\0world", unesc)
729+
730+
local unesc2 = xml.xml_unescape("\\x01\\x02\\x03")
731+
assert.same("\1\2\3", unesc2)
732+
end)
733+
734+
735+
it("unescapes mixed XML entities and \\xHH sequences", function()
736+
-- Expected string uses decimal escapes: \0=NUL, \1=SOH (Lua 5.1 compatible)
737+
local unesc = xml.xml_unescape("hello\\x00&lt;tag&gt;&amp;\\x01world")
738+
assert.same("hello\0<tag>&\1world", unesc)
739+
end)
740+
741+
end)
742+
743+
744+
745+
describe("xml escape/unescape roundtrip", function()
746+
747+
it("roundtrips mixed content", function()
748+
-- Original string uses decimal escapes: \0=NUL, \1=SOH (Lua 5.1 compatible)
749+
local original = "hello\0<tag>&\1world"
750+
local escaped = xml.xml_escape(original)
751+
assert.same("hello\\x00&lt;tag&gt;&amp;\\x01world", escaped)
752+
local unescaped = xml.xml_unescape(escaped)
753+
assert.same(original, unescaped)
754+
end)
755+
756+
757+
it("roundtrips binary protocol packet", function()
758+
local STX = string.char(0x02)
759+
local ETX = string.char(0x03)
760+
local original = STX .. string.char(0x05) .. "Hello" .. ETX .. string.char(0xFF)
761+
762+
local escaped = xml.xml_escape(original)
763+
local unescaped = xml.xml_unescape(escaped)
764+
assert.same(original, unescaped)
765+
end)
766+
767+
768+
it("roundtrips all control characters", function()
769+
-- Generate all control characters (excluding tab, LF, CR)
770+
local control_chars = {}
771+
for i = 0, 31 do
772+
if i ~= 9 and i ~= 10 and i ~= 13 then
773+
table.insert(control_chars, string.char(i))
774+
end
775+
end
776+
table.insert(control_chars, string.char(127)) -- DEL
777+
local original = table.concat(control_chars)
778+
779+
local escaped = xml.xml_escape(original)
780+
local unescaped = xml.xml_unescape(escaped)
781+
assert.same(original, unescaped)
782+
end)
783+
784+
785+
it("roundtrips in XML document context", function()
786+
-- escape -> serialize -> parse -> unescape
787+
local original_text = "data\x00with\x01binary<>&"
788+
local doc = xml.new("test")
789+
doc:text(original_text)
790+
791+
local xml_string = doc:tostring()
792+
local escaped_text = xml_string:match("<test>(.-)</test>")
793+
local recovered_text = xml.xml_unescape(escaped_text)
794+
assert.same(original_text, recovered_text)
795+
end)
796+
563797
end)
564798

565799

0 commit comments

Comments
 (0)