diff --git a/vlib/x/markdown/README.md b/vlib/x/markdown/README.md index d570353673..6afcf99150 100644 --- a/vlib/x/markdown/README.md +++ b/vlib/x/markdown/README.md @@ -23,6 +23,8 @@ extensions. Designed for feature parity with HTML output follows CommonMark block boundaries, including a newline before a nested list or code block inside a tight list. Image alt text flattens inline formatting, nested links, and images to their text content. +Numeric character references follow CommonMark digit limits and replace invalid Unicode values +with U+FFFD. URI autolinks allow empty scheme payloads and reject ASCII control characters. ### GFM Extensions (via `.gfm()` helper or individual extensions) - **Tables**: `| col | col |` with alignment (`:--`, `:--:`, `--:`) diff --git a/vlib/x/markdown/commonmark_inline_boundary_test.v b/vlib/x/markdown/commonmark_inline_boundary_test.v new file mode 100644 index 0000000000..a5a6ad1d31 --- /dev/null +++ b/vlib/x/markdown/commonmark_inline_boundary_test.v @@ -0,0 +1,41 @@ +// Copyright 2026 The V Language. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +module markdown + +fn test_numeric_entities_replace_invalid_unicode_values() { + for source in ['�', '�', '�', '�', '�', '�', '�', + '�'] { + assert to_html(source) == '

�

\n', source + } + assert to_html('􏿿 􏿿') == '

${rune(0x10ffff)} ${rune(0x10ffff)}

\n' + assert to_html('A A A') == '

A A A

\n' +} + +fn test_numeric_entity_digit_limits_and_malformed_controls() { + for source in ['A', '�', 'A', '�', '&#;', '&#x;', '&#-1;', + '&#+65;', '&#xG;', ' z;', '&# 65;', 'A', 'A'] { + assert to_html(source) == '

${source.replace('&', '&')}

\n', source + } +} + +fn test_uri_autolinks_allow_empty_payload_and_validate_schemes() { + for source in ['ab:', 'http:', 'a+.-:', 'abcdefghijklmnopqrstuvwxyzabcdef:'] { + assert to_html('<${source}>') == '

${source}

\n', source + } + for source in ['a:', 'abcdefghijklmnopqrstuvwxyzabcdefg:', '1a:', 'a_:', ':'] { + assert to_html('<${source}>') == '

<${source}>

\n', source + } +} + +fn test_uri_autolinks_reject_ascii_controls() { + for c in 0 .. 33 { + assert try_autolink('') == -1, c.str() + } + assert try_autolink('') == -1 + for c in [u8(1), u8(11), u8(31), u8(127)] { + control := c.ascii_str() + assert to_html('') == '

<ab:${control}payload>

\n' + } + assert to_html('') == '

ab:foo

\n' +} diff --git a/vlib/x/markdown/inline.v b/vlib/x/markdown/inline.v index b5ac9265e4..ea2761f160 100644 --- a/vlib/x/markdown/inline.v +++ b/vlib/x/markdown/inline.v @@ -984,11 +984,11 @@ fn try_autolink(s string) int { } for i := 0; i < inner.len; i++ { c := inner[i] - if c == ` ` || c == `\t` || c == `\n` || c == `\r` || c == `<` { + if c <= 0x20 || c == 0x7f || c == `<` { return -1 } } - // Absolute URI: scheme ':' + at least one more character. + // Absolute URI: scheme ':' + zero or more non-control characters. idx := inner.index(':') or { -1 } if idx > 0 { scheme := inner[..idx] @@ -1001,7 +1001,7 @@ fn try_autolink(s string) int { break } } - if ok && idx + 1 < inner.len { + if ok { return end } } @@ -1230,10 +1230,10 @@ fn (mut p InlineParser) try_entity() ?&Node { return text_node(multi) } } - // Out-of-range numeric references (> U+10FFFF) are not entities at all - // (they stay literal); zero and surrogates decode to U+FFFD below. + // CommonMark limits the digit count, not the numeric value. Invalid + // Unicode values, zero, and surrogates decode to U+FFFD below. if candidate.len > 3 && candidate[1] == `#` { - if numeric_entity_too_large(candidate[2..candidate.len - 1]) { + if numeric_entity_too_long(candidate[2..candidate.len - 1]) { return none } } @@ -1251,39 +1251,13 @@ const multi_rune_entities = { 'ngE': '≧̸' } -// numeric_entity_too_large reports whether a numeric entity body (the part -// between `&#` and `;`, with optional `x` prefix) denotes a value above -// U+10FFFF. Malformed bodies return false (the decoder rejects them). -fn numeric_entity_too_large(body string) bool { - mut entity_digits := body - mut base := 10 - if entity_digits.len > 0 && (entity_digits[0] == `x` || entity_digits[0] == `X`) { - base = 16 - entity_digits = entity_digits[1..] +// numeric_entity_too_long checks CommonMark's decimal and hexadecimal digit limits. +// Malformed bodies within these limits are rejected by the entity decoder. +fn numeric_entity_too_long(body string) bool { + if body.len > 0 && (body[0] == `x` || body[0] == `X`) { + return body.len - 1 > 6 } - if entity_digits.len == 0 { - return false - } - limit := u64(0x10ffff) - mut v := u64(0) - for i := 0; i < entity_digits.len; i++ { - c := entity_digits[i] - mut d := u64(0) - if c >= `0` && c <= `9` { - d = u64(c - `0`) - } else if base == 16 && c >= `a` && c <= `f` { - d = u64(c - `a`) + 10 - } else if base == 16 && c >= `A` && c <= `F` { - d = u64(c - `A`) + 10 - } else { - return false - } - if v > (limit - d) / u64(base) { - return true - } - v = v * u64(base) + d - } - return v > limit + return body.len > 7 } // parse_newline handles a newline character.