Nilorea Library
C utilities for networking, threading, graphics
Loading...
Searching...
No Matches
ex_markdown.c

n_markdown regression: the safe subset, the opt-in block kinds, and the hostile-input safety cases.

n_markdown regression: the safe subset, the opt-in block kinds, and the hostile-input safety cases.

Author
Castagnier Mickael
Version
1.0
/*
* Nilorea Library
* Copyright (C) 2005-2026 Castagnier Mickael
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "nilorea/n_log.h"
#include <string.h>
static int failures = 0;
#define CHECK(cond, msg) \
do { \
if (!(cond)) { \
n_log(LOG_ERR, "FAIL: %s", (msg)); \
failures++; \
} \
} while (0)
/* Parse a NUL-terminated literal and return the doc. */
static n_markdown_doc parse(const char* s) {
n_markdown_parse(s, strlen(s), &d);
return d;
}
/* The same, with the optional block kinds asked for. */
static n_markdown_doc parse_ex(const char* s, unsigned flags) {
n_markdown_parse_ex(s, strlen(s), flags, &d);
return d;
}
/* The text of one inline flow, concatenated; the caller frees nothing. */
static void flow_text(const n_markdown_inline* inl, char* out, size_t cap) {
size_t i, w = 0;
out[0] = '\0';
for (i = 0; i < inl->n_spans; i++) {
const char* t = inl->spans[i].text;
size_t n = t ? strlen(t) : 0;
if (w + n + 1 > cap)
break;
memcpy(out + w, t, n);
w += n;
out[w] = '\0';
}
}
/* Cell (row, col) of a table block, as text. */
static void cell_text(const n_markdown_block* b, size_t row, size_t col, char* out, size_t cap) {
size_t at = row * b->n_cols + col;
out[0] = '\0';
if (!b->n_cols || at >= b->n_items)
return;
flow_text(&b->items[at], out, cap);
}
int main(void) {
/* inline styles fold into per-span flags */
{
n_markdown_doc d = parse("a **b** c *i* `k` end");
CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_PARA, "para");
if (d.n_blocks == 1) {
unsigned seen = 0;
size_t i;
for (i = 0; i < d.blocks[0].inl.n_spans; i++)
seen |= d.blocks[0].inl.spans[i].style;
CHECK((seen & N_MARKDOWN_BOLD) && (seen & N_MARKDOWN_ITALIC) &&
(seen & N_MARKDOWN_CODE),
"bold+italic+code seen");
}
}
/* a valid http(s) link carries the url; a non-http scheme is literal */
{
n_markdown_doc d = parse("see [x](https://e.org) and [y](javascript:1)");
int link = 0, js_literal = 1;
size_t i;
if (d.n_blocks == 1)
for (i = 0; i < d.blocks[0].inl.n_spans; i++) {
if (s->style & N_MARKDOWN_LINK) {
link = 1;
CHECK(s->url && strcmp(s->url, "https://e.org") == 0,
"http url captured");
}
if (s->url && strstr(s->url, "javascript"))
js_literal = 0; /* must NOT become a link url */
}
CHECK(link, "http link recognised");
CHECK(js_literal, "javascript: scheme not turned into a link");
}
/* block kinds: quote, code fence, lists */
{
n_markdown_doc d = parse("> quoted\n\n```\ncode\n```\n\n- one\n- two");
int quote = 0, code = 0, ul = 0;
size_t i;
for (i = 0; i < d.n_blocks; i++) {
quote = 1;
code = 1;
if (d.blocks[i].kind == N_MARKDOWN_UL)
ul = d.blocks[i].n_items == 2;
}
CHECK(quote && code && ul, "quote + code block + 2-item list");
}
/* an ordered list */
{
n_markdown_doc d = parse("1. a\n2. b\n3. c");
CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_OL &&
d.blocks[0].n_items == 3,
"ordered list of 3");
}
/* safety: an unclosed emphasis marker stays literal, no markup detected */
{
CHECK(n_markdown_has_markup("**bold**", 8), "has_markup true for bold");
CHECK(!n_markdown_has_markup("plain text", 10),
"has_markup false for plain");
CHECK(!n_markdown_has_markup("an *unclosed marker", 19),
"unclosed marker is not markup");
}
/* NULL / empty input must not crash */
{
n_markdown_parse(NULL, 0, &d);
CHECK(d.n_blocks == 0, "NULL input -> empty doc");
}
/* ---- the optional block kinds, and that they stay optional ---------- */
/* a heading is ordinary text until it is asked for */
{
n_markdown_doc d = parse("# Title\n\nbody");
"heading is a paragraph in the default subset");
}
{
char buf[64];
n_markdown_doc d = parse_ex("# Title\n\nbody", N_MARKDOWN_HEADINGS);
int ok = d.n_blocks == 2 && d.blocks[0].kind == N_MARKDOWN_HEADING &&
d.blocks[0].level == 1 && d.blocks[1].kind == N_MARKDOWN_PARA;
flow_text(&d.blocks[0].inl, buf, sizeof(buf));
CHECK(ok && strcmp(buf, "Title") == 0, "flagged heading, level 1, hashes stripped");
}
{
d.blocks[0].level == 6,
"six hashes is a level 6 heading");
}
{
/* seven is past the deepest heading, and a hash with no space after it
is a hash tag or a preprocessor line, not a heading */
n_markdown_doc d = parse_ex("####### seven\n", N_MARKDOWN_HEADINGS);
CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_PARA, "seven hashes stays text");
d = parse_ex("#hashtag\n", N_MARKDOWN_HEADINGS);
CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_PARA, "#hashtag stays text");
}
/* a table is ordinary text until it is asked for */
{
n_markdown_doc d = parse("| a | b |\n|---|---|\n| 1 | 2 |\n");
"table is a paragraph in the default subset");
}
{
char buf[64];
n_markdown_doc d = parse_ex("| Key | Effect |\n|:--|--:|\n| G | grid |\n| H | help |\n",
const n_markdown_block* b = &d.blocks[0];
int ok = d.n_blocks == 1 && b->kind == N_MARKDOWN_TABLE && b->n_cols == 2 &&
b->n_items == 6;
CHECK(ok, "flagged table: 2 columns, a header and two body rows");
if (ok) {
"alignment read from the rule row");
cell_text(b, 0, 1, buf, sizeof(buf));
CHECK(strcmp(buf, "Effect") == 0, "header cell (0,1)");
cell_text(b, 2, 0, buf, sizeof(buf));
CHECK(strcmp(buf, "H") == 0, "body cell (2,0)");
}
}
{
/* the pipes at the ends are optional, and ":-:" is centred */
n_markdown_doc d = parse_ex("a | b\n--- | :-:\n1 | 2\n", N_MARKDOWN_ALL);
CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_TABLE &&
d.blocks[0].n_cols == 2 && d.blocks[0].align &&
"table without edge pipes, centred second column");
}
{
/* a pipe decides nothing on its own: without the rule row underneath
it, this is a paragraph -- and the parser must still make progress,
which is what this case was written for */
n_markdown_doc d = parse_ex("a | b\nc | d\n", N_MARKDOWN_ALL);
"pipes with no rule row stay a paragraph");
}
{
/* every row is padded or truncated to the header's width, so a
renderer can index items[row * n_cols + col] without checking */
n_markdown_doc d = parse_ex("| a | b |\n|---|---|\n| 1 |\n| 1 | 2 | 3 |\n",
CHECK(d.n_blocks == 1 && d.blocks[0].n_cols == 2 && d.blocks[0].n_items == 6,
"short and long rows are squared off to the header's width");
}
{
/* hostile: nothing but separators, and a table with no content at all */
n_markdown_doc d = parse_ex("|||||\n|---|\n||||\n", N_MARKDOWN_ALL);
"a row of bare pipes parses and does not crash");
d = parse_ex("|\n|-|\n", N_MARKDOWN_ALL);
CHECK(d.n_blocks == 1 && d.blocks[0].n_cols == 1, "an empty table has one column");
}
/* a list item long enough to wrap stays one item */
{
char buf[128];
n_markdown_doc d = parse("- one\n- two carries on\n onto a second line\n- three\n");
int ok = d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_UL &&
d.blocks[0].n_items == 3;
CHECK(ok, "a wrapped list item does not end its list");
if (ok) {
flow_text(&d.blocks[0].items[1], buf, sizeof(buf));
CHECK(strcmp(buf, "two carries on onto a second line") == 0,
"the continuation joins with a space, indent stripped");
}
}
{
/* a continuation that is really the next block must not be swallowed */
n_markdown_doc d = parse_ex("- one\n still one\n## a heading\n", N_MARKDOWN_HEADINGS);
CHECK(d.n_blocks == 2 && d.blocks[0].kind == N_MARKDOWN_UL &&
"a block start ends the item even when indented text preceded it");
}
/* ---- inline options: autolinks, strikethrough, escapes ------------- */
{
#define IA N_MARKDOWN_INLINE_ALL
char buf[160];
/* one link span in a one-paragraph doc: its text and url, or "" */
struct {
const char* in;
const char* url; /* NULL: no link expected */
} al[] = {
{"see https://x.org/a_b_c today", "https://x.org/a_b_c"},
{"see https://x.org.", "https://x.org"},
{"(https://x.org/w)", "https://x.org/w"},
{"https://en.wikipedia.org/wiki/C_(lang)", "https://en.wikipedia.org/wiki/C_(lang)"},
{"wow https://x.org!?", "https://x.org"},
{"<http://a>", "http://a"},
{"<https://x.org/q?a=1&b=2>", "https://x.org/q?a=1&b=2"},
{"HTTPS://X.ORG/P", "HTTPS://X.ORG/P"},
{"xhttps://x.org", NULL}, /* not at a word boundary */
{"https://", NULL}, /* nothing after the scheme */
{"<https://>", NULL},
{"ftp://x.org", NULL}, /* http(s) only */
{"`https://x.org`", NULL}, /* code is code */
{"<javascript:alert(1)>", NULL},
};
size_t k;
for (k = 0; k < sizeof(al) / sizeof(al[0]); k++) {
n_markdown_doc d = parse_ex(al[k].in, IA);
const char* got = NULL;
size_t i;
if (d.n_blocks == 1)
for (i = 0; i < d.blocks[0].inl.n_spans; i++)
got = d.blocks[0].inl.spans[i].url;
CHECK(strcmp(d.blocks[0].inl.spans[i].text, got) == 0,
"an autolink's text is its url");
}
if (al[k].url)
CHECK(got && strcmp(got, al[k].url) == 0, al[k].in);
else
CHECK(got == NULL, al[k].in);
}
/* trailing punctuation goes back to the text */
{
n_markdown_doc d = parse_ex("see https://x.org.", IA);
flow_text(&d.blocks[0].inl, buf, sizeof(buf));
CHECK(strcmp(buf, "see https://x.org.") == 0,
"no character is lost around an autolink");
}
/* [text](url) still wins over the bare URL inside it */
{
n_markdown_doc d = parse_ex("[site](https://x.org)", IA);
CHECK(d.n_blocks == 1 && d.blocks[0].inl.n_spans == 1 &&
strcmp(d.blocks[0].inl.spans[0].text, "site") == 0,
"an explicit link is not re-linked");
}
/* strikethrough */
{
n_markdown_doc d = parse_ex("a ~~gone~~ b", IA);
CHECK(d.n_blocks == 1 && d.blocks[0].inl.n_spans == 3 &&
strcmp(d.blocks[0].inl.spans[1].text, "gone") == 0,
"~~x~~ is a strike span");
d = parse_ex("~~**both**~~", IA);
CHECK(d.n_blocks == 1 && d.blocks[0].inl.n_spans == 1 &&
d.blocks[0].inl.spans[0].style ==
"strike nests with bold");
d = parse_ex("~~~~ and ~one~", IA);
flow_text(&d.blocks[0].inl, buf, sizeof(buf));
CHECK(strcmp(buf, "~~~~ and ~one~") == 0 &&
d.blocks[0].inl.spans[0].style == 0,
"an empty or single-tilde strike is text");
d = parse("a ~~gone~~ b");
CHECK(d.blocks[0].inl.n_spans == 1, "no strike unless asked for");
}
/* escapes */
{
n_markdown_doc d = parse_ex("\\*not italic\\* and \\# not \\a", IA);
flow_text(&d.blocks[0].inl, buf, sizeof(buf));
CHECK(d.blocks[0].inl.n_spans == 1 &&
d.blocks[0].inl.spans[0].style == 0 &&
strcmp(buf, "*not italic* and # not \\a") == 0,
"a backslash makes punctuation literal, and only punctuation");
"an escape is markup: the plain path would show it");
"but not unless asked for");
}
/* the default subset is unchanged, byte for byte */
{
n_markdown_doc d = parse("see https://x.org ~~a~~ \\*b\\*");
CHECK(d.n_blocks == 1 && d.blocks[0].inl.n_spans >= 1 &&
"the default parse links nothing bare");
}
#undef IA
}
/* an emphasis or a code span needs content: an empty one is literal */
{
char buf[96];
size_t k;
const char* lit[] = {"an **unclosed run", "a ** b", "a **** b",
"a `` b", "x __y"};
for (k = 0; k < sizeof(lit) / sizeof(lit[0]); k++) {
n_markdown_doc d = parse(lit[k]);
flow_text(&d.blocks[0].inl, buf, sizeof(buf));
CHECK(d.n_blocks == 1 && strcmp(buf, lit[k]) == 0 &&
d.blocks[0].inl.n_spans == 1 &&
d.blocks[0].inl.spans[0].style == 0,
lit[k]);
}
{
n_markdown_doc d = parse("**a** and *b*");
CHECK(d.n_blocks == 1 && d.blocks[0].inl.n_spans == 3 &&
"non-empty emphasis still works");
}
}
/* a table ends at a line that starts a block of its own */
{
n_markdown_doc d = parse_ex("a | b\n--|--\n1 | 2\n# x | y\n- c|d\n> e|f\n",
CHECK(d.n_blocks == 4 && d.blocks[0].kind == N_MARKDOWN_TABLE &&
d.blocks[0].n_items == 4 && d.blocks[1].kind == N_MARKDOWN_HEADING &&
"a heading, a list item or a quote after a table is not a row");
d = parse_ex("a | b\n--|--\n1 | 2\nplain | row\n", N_MARKDOWN_ALL);
CHECK(d.n_blocks == 1 && d.blocks[0].n_items == 6,
"an ordinary line with a pipe is still a row");
}
/* underscores inside a word neither open nor close emphasis
* (CommonMark): shortcodes and snake_case stay literal */
{
char buf[128];
size_t k;
const char* lit[] = {":throwing_kiss: :wrapped_present: :two_hearts:",
"snake_case_name and other_name",
"café_au_lait_x", "a__b__c", "x_y _z"};
for (k = 0; k < sizeof(lit) / sizeof(lit[0]); k++) {
n_markdown_doc d = parse(lit[k]);
flow_text(&d.blocks[0].inl, buf, sizeof(buf));
CHECK(d.n_blocks == 1 && strcmp(buf, lit[k]) == 0 &&
d.blocks[0].inl.n_spans == 1 &&
d.blocks[0].inl.spans[0].style == 0,
lit[k]);
CHECK(!n_markdown_has_markup(lit[k], strlen(lit[k])), lit[k]);
}
{
n_markdown_doc d = parse("_a_b c_ and __x_y__ and a*b*c");
flow_text(&d.blocks[0].inl, buf, sizeof(buf));
CHECK(strcmp(buf, "a_b c and x_y and abc") == 0 &&
"word-boundary underscores still emphasise; '*' is unchanged");
}
{
n_markdown_doc d = parse("(_paren_) _a_.");
CHECK(d.blocks[0].inl.n_spans == 5 &&
"punctuation is a boundary");
}
}
/* has_markup follows the flags it is given */
{
CHECK(!n_markdown_has_markup("# Title", 7), "a heading is not markup by default");
"a heading is markup when asked for");
"the default subset still counts under the flags");
}
if (failures) {
n_log(LOG_ERR, "ex_markdown: %d failure(s)", failures);
return 1;
}
n_log(LOG_NOTICE, "ex_markdown: all checks passed");
return 0;
}
static int failures
Definition ex_atlas.c:51
int main(void)
Definition ex_atlas.c:117
#define CHECK(cond, msg)
static const char * row(N_GUI_TREE *tree, int index)
the text of a visible row, "" past the end
static n_http_req_status parse(const char *s, n_http_request *out)
Definition ex_http_req.c:36
static n_markdown_doc parse_ex(const char *s, unsigned flags)
Definition ex_markdown.c:51
static void flow_text(const n_markdown_inline *inl, char *out, size_t cap)
Definition ex_markdown.c:58
static void cell_text(const n_markdown_block *b, size_t row, size_t col, char *out, size_t cap)
Definition ex_markdown.c:73
#define IA
#define n_log(__LEVEL__,...)
Logging function wrapper to get line and func.
Definition n_log.h:113
#define LOG_ERR
error conditions
Definition n_log.h:100
void set_log_level(const int log_level)
Set the global log level value ( static int LOG_LEVEL ).
Definition n_log.c:123
#define LOG_NOTICE
normal but significant condition
Definition n_log.h:104
Generic log system.
void n_markdown_free(n_markdown_doc *doc)
free everything a n_markdown_doc owns and zero it
Definition n_markdown.c:918
int n_markdown_has_markup_ex(const char *text, size_t len, unsigned flags)
1 if text contains any markup the given flags would render, else 0
Definition n_markdown.c:940
void n_markdown_parse(const char *text, size_t len, n_markdown_doc *out)
parse text (len bytes, plain UTF-8) into out
Definition n_markdown.c:706
void n_markdown_parse_ex(const char *text, size_t len, unsigned flags, n_markdown_doc *out)
parse text into out, choosing which optional block kinds to recognise
Definition n_markdown.c:710
int n_markdown_has_markup(const char *text, size_t len)
1 if text contains any renderable markup, else 0
Definition n_markdown.c:936
Parse a SAFE, strict subset of Markdown into a block / styled-span model.
#define N_MARKDOWN_CODE
inline code style flag (carries no emphasis)
Definition n_markdown.h:67
#define N_MARKDOWN_ALIGN_CENTER
what "|:---:|" asks for.
Definition n_markdown.h:98
#define N_MARKDOWN_ALIGN_RIGHT
what "|---:|" asks for.
Definition n_markdown.h:100
#define N_MARKDOWN_ITALIC
italic inline style flag
Definition n_markdown.h:65
#define N_MARKDOWN_ALL
every block kind this parser knows.
Definition n_markdown.h:90
#define N_MARKDOWN_LINK
[text](http(s)://url) style flag; url is set only when this is present
Definition n_markdown.h:69
#define N_MARKDOWN_BOLD
bold inline style flag
Definition n_markdown.h:63
n_markdown_span * spans
Definition n_markdown.h:125
n_markdown_inline * items
Definition n_markdown.h:142
n_markdown_inline inl
Definition n_markdown.h:140
#define N_MARKDOWN_TABLES
n_markdown_parse_ex flag: recognise GitHub-style pipe tables.
Definition n_markdown.h:76
@ N_MARKDOWN_HEADING
"## " heading (inl, level)
Definition n_markdown.h:110
@ N_MARKDOWN_OL
ordered list (items)
Definition n_markdown.h:109
@ N_MARKDOWN_QUOTE
Definition n_markdown.h:106
@ N_MARKDOWN_TABLE
pipe table (items, n_cols)
Definition n_markdown.h:111
@ N_MARKDOWN_UL
unordered list (items)
Definition n_markdown.h:108
@ N_MARKDOWN_CODE_BLOCK
``` fenced code (raw code)
Definition n_markdown.h:107
@ N_MARKDOWN_PARA
paragraph
Definition n_markdown.h:105
#define N_MARKDOWN_STRIKE
strike inline style flag (only with N_MARKDOWN_STRIKETHROUGH)
Definition n_markdown.h:71
#define N_MARKDOWN_HEADINGS
n_markdown_parse_ex flag: recognise "# " to "###### " headings.
Definition n_markdown.h:74
int kind
enum n_markdown_block_kind
Definition n_markdown.h:139
unsigned char * align
TABLE: N_MARKDOWN_ALIGN_* per column, n_cols entries, owned.
Definition n_markdown.h:150
#define N_MARKDOWN_ALIGN_LEFT
table column alignment: the default, and what "|:---|" asks for.
Definition n_markdown.h:96
n_markdown_block * blocks
Definition n_markdown.h:155
size_t n_cols
TABLE: cells per row, always at least 1.
Definition n_markdown.h:147
int level
HEADING: 1 to 6.
Definition n_markdown.h:145
A parsed document: an owned array of blocks.
Definition n_markdown.h:154
One flow of inline runs (a paragraph, a quote, or one list item).
Definition n_markdown.h:124
One inline run: visible text, a style bitmask, and (for a link) an already-http(s)-validated url.
Definition n_markdown.h:117