Skip to content

Commit f4ad770

Browse files
committed
Allow GCI.escapeHTML to take a custom escape table
In some cases you may want to escape a string in a different way than the default behavior. For instance, if you are trying to make some JSON save to include in a `<script>` tag, you may want to escape less, and using JavaScript codepoints: ```ruby >> CGI.escapeHTML('Hello </script>', ">" => '\u003e', "<" => '\u003c', "&" => '\u0026') => "Hello \\u003c/script\\u003e" ``` Of course you can always use `gsub` for that, but `CGI.escapeHTML` being specialized is able to be very significantly faster: ``` ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23] Warming up -------------------------------------- gsub 82.135k i/100ms escapeHTML 221.405k i/100ms Calculating ------------------------------------- gsub 821.890k (± 2.2%) i/s (1.22 μs/i) - 4.189M in 5.099152s escapeHTML 2.330M (± 0.5%) i/s (429.22 ns/i) - 11.734M in 5.036770s Comparison: escapeHTML: 2329816.5 i/s gsub: 821889.7 i/s - 2.83x slower ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23] Warming up -------------------------------------- gsub 36.235k i/100ms escapeHTML 171.347k i/100ms Calculating ------------------------------------- gsub 359.528k (± 1.5%) i/s (2.78 μs/i) - 1.812M in 5.040422s escapeHTML 1.812M (± 0.7%) i/s (551.84 ns/i) - 9.081M in 5.011762s Comparison: escapeHTML: 1812105.3 i/s gsub: 359527.5 i/s - 5.04x slower ``` ```ruby require "benchmark/ips" require "cgi" ESCAPE = { ">" => '\u003e', "<" => '\u003c', "&" => '\u0026', } ESCAPE_B = { ">".b => '\u003e'.b, "<".b => '\u003c'.b, "&".b => '\u0026'.b, } ESCAPE_REGEX = Regexp.union(ESCAPE_B.keys) str = ("a" * 1024).freeze Benchmark.ips do |x| x.report("gsub") do b = str.b b.gsub!(ESCAPE_REGEX, ESCAPE_B) b.force_encoding(str.encoding) end x.report("escapeHTML") do CGI.escapeHTML(str, ESCAPE) end x.compare! end str = (("a" * 1023) + "<").freeze Benchmark.ips do |x| x.report("gsub") do b = str.b b.gsub!(ESCAPE_REGEX, ESCAPE_B) b.force_encoding(str.encoding) end x.report("escapeHTML") do CGI.escapeHTML(str, ESCAPE) end x.compare! end ```
1 parent bbfac89 commit f4ad770

3 files changed

Lines changed: 148 additions & 13 deletions

File tree

ext/cgi/escape/escape.c

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,21 @@ preserve_original_state(VALUE orig, VALUE dest)
3333
}
3434

3535
static inline long
36-
escaped_length(VALUE str)
36+
escaped_length(VALUE str, long escape_max_len)
3737
{
3838
const long len = RSTRING_LEN(str);
39-
if (len >= LONG_MAX / HTML_ESCAPE_MAX_LEN) {
40-
ruby_malloc_size_overflow(len, HTML_ESCAPE_MAX_LEN);
39+
if (len >= LONG_MAX / escape_max_len) {
40+
ruby_malloc_size_overflow(len, escape_max_len);
4141
}
42-
return len * HTML_ESCAPE_MAX_LEN;
42+
return len * escape_max_len;
4343
}
4444

4545
static VALUE
4646
optimized_escape_html(VALUE str)
4747
{
4848
VALUE escaped;
4949
VALUE vbuf;
50-
char *buf = ALLOCV_N(char, vbuf, escaped_length(str));
50+
char *buf = ALLOCV_N(char, vbuf, escaped_length(str, HTML_ESCAPE_MAX_LEN));
5151
const char *cstr = RSTRING_PTR(str);
5252
const char *end = cstr + RSTRING_LEN(str);
5353

@@ -75,6 +75,86 @@ optimized_escape_html(VALUE str)
7575
return escaped;
7676
}
7777

78+
struct build_escape_table_args {
79+
long max_escape_length;
80+
VALUE *escape_table;
81+
};
82+
83+
static int
84+
build_escape_table_i(VALUE key, VALUE val, VALUE _arg)
85+
{
86+
struct build_escape_table_args *arg = (struct build_escape_table_args *)_arg;
87+
Check_Type(key, T_STRING);
88+
Check_Type(val, T_STRING);
89+
90+
if (RSTRING_LEN(key) != 1) {
91+
rb_raise(rb_eArgError, "CGI.escapeHTML keys must be single ASCII characters");
92+
}
93+
unsigned char c = RSTRING_PTR(key)[0];
94+
95+
if (c >= 0x80) {
96+
rb_raise(rb_eArgError, "CGI.escapeHTML keys must be single ASCII characters");
97+
}
98+
99+
arg->escape_table[c] = val;
100+
101+
long escape_length = RSTRING_LEN(val);
102+
if (arg->max_escape_length < escape_length) {
103+
arg->max_escape_length = escape_length;
104+
}
105+
106+
return ST_CONTINUE;
107+
}
108+
109+
static long
110+
build_escape_table(VALUE *escape_table, VALUE rb_escape_table)
111+
{
112+
struct build_escape_table_args arg = {
113+
.escape_table = escape_table,
114+
};
115+
rb_hash_foreach(rb_escape_table, build_escape_table_i, (VALUE)&arg);
116+
return arg.max_escape_length;
117+
}
118+
119+
static VALUE
120+
dynamic_escape_html(VALUE str, VALUE rb_escape_table)
121+
{
122+
VALUE escape_table[UCHAR_MAX+1] = {0};
123+
long max_escape_length = build_escape_table(escape_table, rb_escape_table);
124+
125+
VALUE vbuf;
126+
char *buf = ALLOCV_N(char, vbuf, escaped_length(str, max_escape_length));
127+
const char *cstr = RSTRING_PTR(str);
128+
const char *end = cstr + RSTRING_LEN(str);
129+
130+
char *dest = buf;
131+
while (cstr < end) {
132+
const unsigned char c = *cstr++;
133+
VALUE escaped_character = escape_table[c];
134+
if (escaped_character) {
135+
const char *ptr;
136+
long len;
137+
RSTRING_GETMEM(escaped_character, ptr, len);
138+
MEMCPY(dest, ptr, char, len);
139+
dest += len;
140+
}
141+
else {
142+
*dest++ = c;
143+
}
144+
}
145+
146+
VALUE escaped;
147+
if (RSTRING_LEN(str) < (dest - buf)) {
148+
escaped = rb_str_new(buf, dest - buf);
149+
preserve_original_state(str, escaped);
150+
}
151+
else {
152+
escaped = rb_str_dup(str);
153+
}
154+
ALLOCV_END(vbuf);
155+
return escaped;
156+
}
157+
78158
static VALUE
79159
optimized_unescape_html(VALUE str)
80160
{
@@ -331,15 +411,24 @@ optimized_unescape(VALUE str, VALUE encoding, int unescape_plus)
331411
*
332412
*/
333413
static VALUE
334-
cgiesc_escape_html(VALUE self, VALUE str)
414+
cgiesc_escape_html(int argc, VALUE *argv, VALUE self)
335415
{
416+
rb_check_arity(argc, 1, 2);
417+
418+
VALUE str = argv[0];
336419
StringValue(str);
337420

338421
if (rb_enc_str_asciicompat_p(str)) {
339-
return optimized_escape_html(str);
422+
if (argc == 1) {
423+
return optimized_escape_html(str);
424+
}
425+
else {
426+
Check_Type(argv[1], T_HASH);
427+
return dynamic_escape_html(str, argv[1]);
428+
}
340429
}
341430
else {
342-
return rb_call_super(1, &str);
431+
return rb_call_super(argc, argv);
343432
}
344433
}
345434

@@ -474,7 +563,7 @@ InitVM_escape(void)
474563
rb_cCGI = rb_define_class("CGI", rb_cObject);
475564
rb_mEscapeExt = rb_define_module_under(rb_cCGI, "EscapeExt");
476565
rb_mEscape = rb_define_module_under(rb_cCGI, "Escape");
477-
rb_define_method(rb_mEscapeExt, "escapeHTML", cgiesc_escape_html, 1);
566+
rb_define_method(rb_mEscapeExt, "escapeHTML", cgiesc_escape_html, -1);
478567
rb_define_method(rb_mEscapeExt, "unescapeHTML", cgiesc_unescape_html, 1);
479568
rb_define_method(rb_mEscapeExt, "escapeURIComponent", cgiesc_escape_uri_component, 1);
480569
rb_define_alias(rb_mEscapeExt, "escape_uri_component", "escapeURIComponent");

lib/cgi/escape.rb

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,21 +80,42 @@ def unescapeURIComponent(string, encoding = @@accept_charset)
8080
# Escape special characters in HTML, namely '&\"<>
8181
# CGI.escapeHTML('Usage: foo "bar" <baz>')
8282
# # => "Usage: foo &quot;bar&quot; &lt;baz&gt;"
83-
def escapeHTML(string)
83+
def escapeHTML(string, escape_table = nil)
8484
enc = string.encoding
85+
86+
if escape_table
87+
escape_table.each_key do |key|
88+
if key.bytesize != 1 || !key.ascii_only?
89+
raise ArgumentError, "CGI.escapeHTML keys must be single ASCII characters"
90+
end
91+
end
92+
end
93+
8594
unless enc.ascii_compatible?
8695
if enc.dummy?
8796
origenc = enc
8897
enc = Encoding::Converter.asciicompat_encoding(enc)
8998
string = enc ? string.encode(enc) : string.b
9099
end
91-
table = Hash[TABLE_FOR_ESCAPE_HTML__.map {|pair|pair.map {|s|s.encode(enc)}}]
92-
string = string.gsub(/#{"['&\"<>]".encode(enc)}/, table)
100+
101+
table = Hash[(escape_table || TABLE_FOR_ESCAPE_HTML__).map {|pair| pair.map {|s|s.encode(enc)}}]
102+
103+
if escape_table
104+
pattern = Regexp.union(table.keys)
105+
string = string.gsub(pattern, table)
106+
else
107+
string = string.gsub(/#{"['&\"<>]".encode(enc)}/, table)
108+
end
109+
93110
string.encode!(origenc) if origenc
94111
string
95112
else
96113
string = string.b
97-
string.gsub!(/['&\"<>]/, TABLE_FOR_ESCAPE_HTML__)
114+
if escape_table
115+
string.gsub!(Regexp.union(escape_table.keys), escape_table)
116+
else
117+
string.gsub!(/['&\"<>]/, TABLE_FOR_ESCAPE_HTML__)
118+
end
98119
string.force_encoding(enc)
99120
end
100121
end

test/cgi/test_cgi_escape.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,19 @@ def test_cgi_escapeHTML
130130
assert_equal("&#39;&amp;&quot;&gt;&lt;", CGI.escapeHTML("'&\"><"))
131131
end
132132

133+
def test_dynamic_cgi_escapeHTML
134+
assert_equal("'&\">&lt;", CGI.escapeHTML("'&\"><", { "<" => "&lt;" }))
135+
assert_equal("'\\u0026\"\\u003e\\u003c", CGI.escapeHTML("'&\"><", {
136+
">" => '\u003e',
137+
"<" => '\u003c',
138+
"&" => '\u0026',
139+
}))
140+
141+
assert_raise(ArgumentError) { CGI.escapeHTML(" ", { "12" => "&lt;" }) }
142+
assert_raise(ArgumentError) { CGI.escapeHTML(" ", { "€" => "&lt;" }) }
143+
assert_raise(ArgumentError) { CGI.escapeHTML(" ", { "" => "&lt;" }) }
144+
end
145+
133146
def test_cgi_escape_html_duplicated
134147
orig = "Ruby".dup.force_encoding("US-ASCII")
135148
str = CGI.escapeHTML(orig)
@@ -260,9 +273,21 @@ def test_cgi_unescapeHTML_charref_preserve_encoding
260273
define_method("test_cgi_escapeHTML:#{enc.name}") do
261274
assert_equal(escaped, CGI.escapeHTML(unescaped))
262275
end
276+
263277
define_method("test_cgi_unescapeHTML:#{enc.name}") do
264278
assert_equal(unescaped, CGI.unescapeHTML(escaped))
265279
end
280+
281+
define_method("test_cgi_dynamic_unescapeHTML:#{enc.name}") do
282+
table = {
283+
"'" => '&#39;',
284+
'&' => '&amp;',
285+
'"' => '&quot;',
286+
'<' => '&lt;',
287+
'>' => '&gt;',
288+
}
289+
assert_equal(escaped, CGI.escapeHTML(unescaped, table))
290+
end
266291
end
267292
end
268293

0 commit comments

Comments
 (0)