一个符号导致的问题

Jianyong Chen

2022-08-07

Preface

最近在优化 Seastar 中 httpd 的一些逻辑,完事之后跑一跑单测想确认下对现有逻辑的确无影响,但是一跑发现有一个 chunk 相关的测试挂了——但是我明明还没有改动到这块,只是改了一下 keepalive 的判定逻辑。

细究之后发现还是挺有意思的,所以打算把它记录下来;当然也不仅仅只是记录这个问题,查这个问题的时候也顺便学习了一下 Seastar 的单测该怎么写怎么执行、httpd 中 HTTP 协议解析流程,HTTP 中的 trailer 概念,以及 ragel 这个新工具的使用,所以也一并记录下来(同时也说明文章很有可能会抓不住重点 :))

问题

单测是这样:

SEASTAR_TEST_CASE(test_full_chunk_format) {
    return check_http_reply(
        {
            "GET /test HTTP/1.1\r\n"
            "Host: test\r\n"
            "Transfer-Encoding: chunked\r\n\r\n",

            "a;abc-def;hello=world;aaaa\r\n"
            "1234567890\r\n",

            "a;a0-!#$%&'*+.^_`|~=\"quoted string obstext\x80\x81\xff quoted_pair: \\a\"\r\n"
            "1234521345\r\n",

            "0\r\n"
            "a:b\r\n"
            "~|`_^.+*'&%$#!-0a:  ~!@#$%^&*()_+\x80\x81\xff\r\n  obs fold  \r\n"
            "\r\n"
        },
        {
            "12345678901234521345",
            "abc-def",
            "hello=world",
            "aaaa",
            "a0-!#$%&'*+.^_`|~=quoted string obstext\x80\x81\xff quoted_pair: a",
            "a: b",
            "~|`_^.+*'&%$#!-0a: ~!@#$%^&*()_+\x80\x81\xff obs fold"
        },
        false,
        new echo_string_handler()
    );
}

这个单测主要是为了测试 httpd 是否能正确解析 chunked 数据——通过 chunked_source_impl

check_http_reply() 的逻辑也很简单:

future<> check_http_reply(std::vector<sstring>&& req_parts,
                          std::vector<std::string>&& resp_parts,
                          bool stream, handler_base* handler);

它的输入是一个字符串数组,他们共同组成一个 HTTP 请求;函数中会构造一个到 httpd 的 tcp 连接,并将数据发送给 httpd——因为它只是一个 tcp 连接,所以需要手动构造一个 HTTP 请求数据包

上面对原始代码的格式做了一些美化,从而可以很容易地看出来这是一个以 chunk 形式发送的 request;发送完请求后,就读取 httpd server 的响应——httpd 会将请求体原封不动地响应给客户端——但是是经过了 HTTP 解析的,所以响应中只有发送的 request body,并且不包含 HTTP 所需要的 chunk 所需要的 \r\n

它的第二个参数是一个字符串数组,每条字符串都应该是整个响应的一部分,所以读取完毕之后检查 resp_parts 中的每一个字符串是否都包含在读取到的响应中,从而校验 chunk 解析功能的正确性

但是在跑这个单测(./httpd_test --run_test='test_full_chunk_format')的时候发现并没有得到正常的 200 响应,而是 400 Bad Request 并附带有这样一条错误信息:

Can’t parse chunked request trailer

所以问题很有可能出现在 request trailer 的解析流程中

HTTP trailer

首先 trailer 是什么?对 HTTP 大家可能都知道有 request line、request header 和 request body,知道 request trailer 就比较少了(我之前听过这个东西,但是直到查这个问题时我才知道它具体是什么)

首先 HTTP trailer 其实就是 HTTP 头部,但是它并不放在 request body 之前,而是放在 request body 之后——所以才被称为 trailer;

A trailer allows the sender to include additional fields at the end of a chunked message in order to supply metadata that might be dynamically generated while the message body is sent, such as a message integrity check, digital signature, or post-processing status. The trailer fields are identical to header fields, except they are sent in a chunked trailer instead of the message’s header section.

它只在 chunk 编码传输时才使用:chunk 方式传输一般是因为发送方无法提前知道 body 长度(比如动态生成的内容),而有时候我们希望在 body 的基础上传输一些元信息,比如很常见的一个就是以 HTTP 头部的方式传输 body 的校验和——不过这个在 body 长度未知的情况下无法做到,所以我们无法在正常发送 request header 时传输这类信息

而 HTTP trailer 就提供了这样一种在 body 内容传输完成之后再补充一些元信息的能力;一般情况下以 chunk 方式发送 body 是这样的:

HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

7\r\n
Mozilla\r\n
9\r\n
Developer\r\n
7\r\n
Network\r\n
0\r\n
\r\n

末尾是一个长度为 0 的 chunk,但是如果还想发送 trailer,则在最后一个 chunk 之后增加 header:

...
0\r\n
Expires: Wed, 21 Oct 2015 07:28:00 GMT\r\n
Content-MD5: SDOFJSLDKASDFLKJSDLFKSDLFKJS===\r\n
\r\n

ragel

Seastar httpd 并没有手写 HTTP 协议解析器,而是使用 ragel 生成了一份解析代码,这是一个类似于 ANTLR 的代码生成器,它可以将正则语言生成为一个高效的解析器(以状态机形式),从而可以用在语法解析等文本处理场景

%% machine_chunk_tail;

%%{

crlf = '\r\n';
tchar = alpha | digit | '-' | '!' | '#' | '$' | '%' | '&' | '\'' | '*'
        | '+' | '.' | '^' | '_' | '`' | '|' | '~';
sp = ' ';
ht = '\t';
sp_ht = sp | ht;
ows = sp_ht*;
rws = sp_ht+;

obs_text = 0x80..0xFF; # defined in RFC 7230, Section 3.2.6.
field_vchars = (graph | obs_text)+ %checkpoint;
field_content = (field_vchars ows)*;

field = tchar+ >mark %store_field_name;
value = field_content >mark %no_mark_store_value;
header_1st = field ':' ows value crlf %assign_field;
header_cont = rws value crlf %extend_field;
header = header_1st header_cont*;
main := header* crlf @done;

}%%

的确和之前在编译原理课上学过的正则语言很相似,这里的话其实还是根据 RFC 7230 中给出的 EBNF 文法进行了一些适配(不过我觉得这里不应该该名字,直接使用 RFC 里面提供的名字更加便于查找其含义);

除了状态机的匹配之外,另外一个很重要的就是匹配到内容之后需要进行的动作(action),上面的 %checkpoint>mark%extend_field 都是在调用一些函数(实际上就是 C 函数体嵌入在 ragel 的 action 中,这份文件也是 C/C++ 函数和 ragel 指令的混合体,最终生成的是一份完整的 C/C++ 文件,这些 action 太长了就没有给出定义),其中 > 表示匹配了该内容但是在正式执行匹配之前执行指定的动作,% 表示在匹配完成之后执行函数;这些函数要么是标记一些状态(比如 >mark),要么是保存一些结果(比如 %store_field_name 就是在解析完一个 HTTP header name 之后将该 name 保存在 parser 内部以便后续使用)

最终得到的顶层文法规则就是 main,ragel 根据该规则自顶向下解析生成一个状态机形式的解析器;不过解析器代码很难看,我们可以通过 ragel 和 dot 工具将一份 .rl 文件给转换成一张图片,从而可以清晰地看出状态转移的过程:

% ragel -Vp chunk_parsers.rl -o chunk_parsers.dot
% dot chunk_parses.dot -Tsvg -o chunk_parsers.svg

生成的 dot 代码:


digraph chunk_tail {
    rankdir=LR;
    node [ shape = point ];
    ENTRY;
    en_1;
    node [ shape = circle, height = 0.2 ];
    node [ fixedsize = true, height = 0.65, shape = doublecircle ];
    14;
    node [ shape = circle ];
    1 -> 2 [ label = "'\\r'" ];
    1 -> 3 [ label = "'!', '#'..''', '*'..'+', '-'..'.', '0'..'9', 'A'..'Z', '^'..'z', '|', '~' / mark" ];
    2 -> 14 [ label = "'\\n' / done" ];
    3 -> 3 [ label = "'!', '#'..''', '*'..'+', '-'..'.', '0'..'9', 'A'..'Z', '^'..'z', '|', '~'" ];
    3 -> 4 [ label = "':' / store_field_name" ];
    4 -> 5 [ label = "-128..-1, '!'..'~' / mark" ];
    4 -> 4 [ label = "'\\t', SP" ];
    4 -> 7 [ label = "'\\r' / mark, no_mark_store_value" ];
    5 -> 5 [ label = "-128..-1, '!'..'~' / checkpoint" ];
    5 -> 6 [ label = "'\\t', SP / checkpoint" ];
    5 -> 7 [ label = "'\\r' / checkpoint, no_mark_store_value" ];
    6 -> 5 [ label = "-128..-1, '!'..'~'" ];
    6 -> 6 [ label = "'\\t', SP" ];
    6 -> 7 [ label = "'\\r' / no_mark_store_value" ];
    7 -> 8 [ label = "'\\n'" ];
    8 -> 9 [ label = "'\\t', SP / assign_field" ];
    8 -> 2 [ label = "'\\r' / assign_field" ];
    8 -> 3 [ label = "'!', '#'..''', '*'..'+', '-'..'.', '0'..'9', 'A'..'Z', '^'..'z', '|', '~' / assign_field, mark" ];
    9 -> 10 [ label = "-128..-1, '!'..'~' / mark" ];
    9 -> 9 [ label = "'\\t', SP" ];
    9 -> 12 [ label = "'\\r' / mark, no_mark_store_value" ];
    10 -> 10 [ label = "-128..-1, '!'..'~' / checkpoint" ];
    10 -> 11 [ label = "'\\t', SP / checkpoint" ];
    10 -> 12 [ label = "'\\r' / checkpoint, no_mark_store_value" ];
    11 -> 10 [ label = "-128..-1, '!'..'~'" ];
    11 -> 11 [ label = "'\\t', SP" ];
    11 -> 12 [ label = "'\\r' / no_mark_store_value" ];
    12 -> 13 [ label = "'\\n'" ];
    13 -> 9 [ label = "'\\t', SP / extend_field" ];
    13 -> 2 [ label = "'\\r' / extend_field" ];
    13 -> 3 [ label = "'!', '#'..''', '*'..'+', '-'..'.', '0'..'9', 'A'..'Z', '^'..'z', '|', '~' / extend_field, mark" ];
    ENTRY -> 1 [ label = "IN" ];
    en_1 -> 1 [ label = "main" ];
}

最终生成的图片:

seastar-chunk-parsers-ragel-svg.png

也很复杂,就随便看看吧

问题排查

了解了这些背景知识之后,开始排查问题。

首先找到 Can't parse chunked request trailer 在代码中的位置,以及触发它的条件;这个例子中很简单,就是解析器的 _statestate::error,进一步查找这个状态的触发条件,发现是生成的解析器的最后一片代码:

        if (!done) {
            if (p == eof) {
                _state = state::eof;
            } else if (p != pe) {
                _state = state::error;
            } else {
                p = nullptr;
            }
        } else {
            _state = state::done;
        }

走到这个 if-else 的时候,如果还没有解析完全(p != pe),那么说明解析途中肯定出错了;这里我把 p 指向的值和 p 所在的位置打印出来,对比输入的 trailer:

            "0\r\n"
            "a:b\r\n"
            "~|`_^.+*'&%$#!-0a:  ~!@#$%^&*()_+\x80\x81\xff\r\n  obs fold  \r\n"
            "\r\n"

trailer 是 0\r\n 之后的内容,从 a:b\r\n 开始,打印出来发现 p 指向的是 \x80 位置,说明这个字符并没有被状态机的状态匹配到,这才会提前走到了上面的判断逻辑;根据前面的文法规则,可以发现这个位置是一个 header value,对应 value 这条规则,而且根据其值可以发现,它其实是属于 obs-text 这个文法规则:

Historically, HTTP has allowed field content with text in the ISO-8859-1 charset [ISO-8859-1], supporting other charsets only through use of [RFC2047] encoding. In practice, most HTTP header field values use only a subset of the US-ASCII charset [USASCII]. Newly defined header fields SHOULD limit their field values to US-ASCII octets. A recipient SHOULD treat other octets in field content (obs-text) as opaque data.

查阅 RFC ,大概意思是说 HTTP 中 header value 在实际生活中只使用了部分 ASCII 字符,其余的一些就被称为 obs-text(obs 是 obsolete 的缩写);问题就出在这个 obs-text 字符集上,一般情况下我们用到的字符都是 [0, 127] 之间,但是这个字符集则是另外一半 ([128, 255]),Seastar 使用的是 char 来表示字符,这个类型是否带符号是由具体的平台实现决定的

而我当时使用的是 MacBook M1 上面使用 Parallels Desktop 安装的一个 ArchLinux ARM 版本;通常在 ARM 上 char 是无符号的,X86 上则是带符号的;

但是呢,Ragel 默认认为字符集都是带符号的,即使是在 ARM 上也是如此,所以 0x80..0xFF 就被表示为了 -128..-1,可以写一个简单的例子看看:

#include <string.h>
#include <stdio.h>

%%{
    machine foo;
    clcf= '\r\n';
    obs_text = 0x80..0xFF; # defined in RFC 7230, Section 3.2.6.
    main := obs_text (clcf)+;
}%%

%% write data;

int main( int argc, char **argv )
{
    int cs, res = 0;
    if ( argc > 1 ) {
        char *p = argv[1];
        char *pe = p + strlen(p) + 1;
        %% write init;
        %% write exec;
    }
    printf("result = %i\n", res );
    return 0;
}

生成状态机状态转移图:

seastar-ragel-signed-svg.png

果然,0x80..0xFF 被转化为了 -128..-1,但是在实际匹配的过程中,由于输入的 const char *p 都是无符号的,所以肯定匹配不进这些 case

不过,还好 ragel 提供了 alphtype 指令用于指定以何种字符集类解析文法:

The alphtype statement specifies the alphabet data type that the machine operates on. During the compilation of the machine, integer literals are expected to be in the range of possible values of the alphtype. The default is char for all languages except Go where the default is byte.

这里说默认 alphtypechar 类型,但是这个 char 并不是指的 C 语言中的 char(和具体平台实现相关),它就是带符号的,不过我们可以将其改为 unsigned char,在 machine foo 下面加一条 alphtype unsigned char; 指令,重新生成 svg 图:

seastar-ragel-unsigned-svg.png

果然这次 0x80..0xFF 就以无符号形式解析了,这样输入的 char 也可以正常匹配了

另外再提一嘴 obs fold 这个东西,最开始 HTTP header value 其实是支持跨行的,就像上面 trailer 所展示的那样,

Historically, HTTP header field values could be extended over multiple lines by preceding each extra line with at least one space or horizontal tab (obs-fold). This specification deprecates such line folding except within the message/http media type (Section 8.3.1). A sender MUST NOT generate a message that includes line folding (i.e., that has any field-value that contains a match to the obs-fold rule) unless the message is intended for packaging within the message/http media type.

header value 在另外一行的部分就被称为 obs-fold

Reference