core: net/probe + Content-Disposition parser + URL splitter (stage 3)

net/content_disposition — total parser for the mojibake-prone header:
RFC 6266 filename (quoted/token), RFC 5987 filename* ext-values
(charset'lang'pct-encoded, incl. RFC 2231 continuations), legacy RFC 2047
encoded-words (=?UTF-8?B?..?= / ?Q?), and raw Latin-1 bytes; prefers
filename* over filename; strips path components AFTER decoding (a base64
payload can hold '/'). 22-case test table.

net/text_codec (internal) — percent-decode, UTF-8 validation, Latin-1->
UTF-8, base64, RFC 2047 — shared by the CD parser and the URL splitter.

net/url — a small total URL splitter (scheme/userinfo/host/port/path/
query/fragment, http(s) validity) and url_filename() for the last path
segment; used for the filename fallback.

net/probe — HEAD then a ranged GET bytes=0-0 that PROVES resumability
(206 + matching Content-Range + a validator), rather than trusting
Accept-Ranges which servers lie about; the ranged GET is also the HEAD-
refused (403/405/501) fallback. 401/407 -> success result with
requires_auth, not an error. Runs on its own pool (max_concurrent,
default 4) outside the segment budget per ADR 0011 §5. suggest_filename()
does the resolution order (explicit -> disposition -> URL -> download.bin)
with a light strip; rules/ (stage 9) owns the authoritative sanitize.

tools/fuzz — libFuzzer targets for the CD parser and the URL splitter,
compiling the parser sources directly so they're fully instrumented;
self-guards on VELOX_BUILD_FUZZ + Clang (the top-level CMake adds every
tools/* unconditionally). Seed corpora included.

Fixed on the way: a p -> Transfer -> State -> cbs -> p reference cycle in
Prober that leaked every probe (drop the stored Transfer; the worker
keeps State alive). Tests green under ASan/UBSan and TSan.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
This commit is contained in:
2026-09-09 23:53:55 +04:00
co-authored by Claude Sonnet 5
parent fdacf732fa
commit 201ebc55d4
25 changed files with 1731 additions and 19 deletions
+148
View File
@@ -0,0 +1,148 @@
#include "vdm/net/content_disposition.hpp"
#include <string>
#include <string_view>
#include "vtest.hpp"
using vdm::net::ContentDisposition;
using vdm::net::parse_content_disposition;
using Type = vdm::net::ContentDisposition::Type;
namespace {
// "€" is U+20AC -> UTF-8 E2 82 AC. "£" is U+00A3 -> UTF-8 C2 A3.
const std::string kEuro = "\xE2\x82\xAC";
const std::string kPound = "\xC2\xA3";
const std::string kEAcute = "\xC3\xA9"; // é U+00E9
} // namespace
VT_TEST(cd_plain_quoted) {
auto cd = parse_content_disposition(R"(attachment; filename="report.pdf")");
VT_CHECK(cd.type == Type::attachment);
VT_CHECK_EQ(cd.filename, std::string("report.pdf"));
VT_CHECK(!cd.filename_from_ext);
}
VT_TEST(cd_plain_token_unquoted) {
auto cd = parse_content_disposition("attachment; filename=report.pdf");
VT_CHECK_EQ(cd.filename, std::string("report.pdf"));
}
VT_TEST(cd_inline_no_filename) {
auto cd = parse_content_disposition("inline");
VT_CHECK(cd.type == Type::inline_);
VT_CHECK(!cd.has_filename());
}
VT_TEST(cd_rfc5987_utf8_ext_value) {
auto cd = parse_content_disposition("attachment; filename*=UTF-8''%e2%82%ac%20rates.pdf");
VT_CHECK_EQ(cd.filename, kEuro + " rates.pdf");
VT_CHECK(cd.filename_from_ext);
}
VT_TEST(cd_prefers_ext_over_plain) {
auto cd = parse_content_disposition(
R"(attachment; filename="EURO rates.pdf"; filename*=UTF-8''%e2%82%ac%20rates.pdf)");
VT_CHECK_EQ(cd.filename, kEuro + " rates.pdf");
VT_CHECK(cd.filename_from_ext);
}
VT_TEST(cd_rfc5987_latin1_ext_value) {
// %A3 = £ in ISO-8859-1
auto cd = parse_content_disposition("attachment; filename*=ISO-8859-1''%A3rates.pdf");
VT_CHECK_EQ(cd.filename, kPound + "rates.pdf");
}
VT_TEST(cd_legacy_rfc2047_base64) {
// base64("<euro> rates.pdf") with euro as UTF-8
// "€ rates.pdf" -> bytes E2 82 AC 20 72 61 74 65 73 2E 70 64 66 -> base64:
auto cd =
parse_content_disposition(R"(attachment; filename="=?UTF-8?B?4oKsIHJhdGVzLnBkZg==?=")");
VT_CHECK_EQ(cd.filename, kEuro + " rates.pdf");
}
VT_TEST(cd_legacy_rfc2047_qencoded_latin1) {
// =?ISO-8859-1?Q?=A3rates.pdf?= -> £rates.pdf
auto cd = parse_content_disposition(R"(attachment; filename="=?ISO-8859-1?Q?=A3rates.pdf?=")");
VT_CHECK_EQ(cd.filename, kPound + "rates.pdf");
}
VT_TEST(cd_raw_utf8_bytes_in_quotes) {
std::string h = "attachment; filename=\"caf" + kEAcute + ".txt\"";
auto cd = parse_content_disposition(h);
VT_CHECK_EQ(cd.filename, "caf" + kEAcute + ".txt");
}
VT_TEST(cd_raw_latin1_byte_in_quotes) {
// 0xE9 is 'é' in Latin-1; not valid UTF-8 alone -> transcoded
std::string h = "attachment; filename=\"caf\xE9.txt\"";
auto cd = parse_content_disposition(h);
VT_CHECK_EQ(cd.filename, "caf" + kEAcute + ".txt");
}
VT_TEST(cd_strips_path_components) {
VT_CHECK_EQ(parse_content_disposition(R"(attachment; filename="../../etc/passwd")").filename,
std::string("passwd"));
// real Windows paths in the wild use unescaped backslashes as separators
VT_CHECK_EQ(parse_content_disposition(R"(attachment; filename="C:\Windows\evil.exe")").filename,
std::string("evil.exe"));
// a base64 payload can contain '/', so decode must happen before path stripping
VT_CHECK_EQ(parse_content_disposition(R"(attachment; filename="=?UTF-8?B?Li4vLi4vc2VjcmV0?=")")
.filename,
std::string("secret")); // decodes to "../../secret", then stripped
}
VT_TEST(cd_quoted_dquote_escape) {
// \" is the one escape we resolve, so a quote can appear mid-name
auto cd = parse_content_disposition(R"(attachment; filename="quote\"here.txt")");
VT_CHECK_EQ(cd.filename, std::string("quote\"here.txt"));
}
VT_TEST(cd_semicolon_inside_quotes_is_not_a_separator) {
auto cd = parse_content_disposition(R"(attachment; filename="a;b;c.txt")");
VT_CHECK_EQ(cd.filename, std::string("a;b;c.txt"));
}
VT_TEST(cd_form_data) {
auto cd = parse_content_disposition(R"(form-data; name="file"; filename="upload.bin")");
VT_CHECK(cd.type == Type::form_data);
VT_CHECK_EQ(cd.filename, std::string("upload.bin"));
}
VT_TEST(cd_rfc2231_continuations) {
auto cd = parse_content_disposition(
"attachment; filename*0*=UTF-8''%e2%82%ac; filename*1*=%20rates; filename*2=.pdf");
VT_CHECK_EQ(cd.filename, kEuro + " rates.pdf");
}
VT_TEST(cd_empty_and_garbage_do_not_crash) {
VT_CHECK(!parse_content_disposition("").has_filename());
VT_CHECK(!parse_content_disposition(";;;;").has_filename());
VT_CHECK(!parse_content_disposition("attachment;").has_filename());
VT_CHECK(!parse_content_disposition(R"(attachment; filename=)").has_filename());
VT_CHECK(!parse_content_disposition(R"(attachment; filename="")").has_filename());
// truncated ext-value
auto cd = parse_content_disposition("attachment; filename*=UTF-8''%e2%82");
VT_CHECK(cd.type == Type::attachment); // no crash; filename is whatever fell out
// truncated encoded-word
auto trunc = parse_content_disposition(R"(attachment; filename="=?UTF-8?B?4oKs")");
(void)trunc;
}
VT_TEST(cd_bad_percent_escapes_in_ext_value) {
// stray % and non-hex digits are emitted literally, no crash
auto cd = parse_content_disposition("attachment; filename*=UTF-8''%ZZ%%file%2");
VT_CHECK(cd.type == Type::attachment);
}
VT_TEST(cd_case_insensitive_keys_and_type) {
auto cd = parse_content_disposition(R"(ATTACHMENT; FileName="x.txt")");
VT_CHECK(cd.type == Type::attachment);
VT_CHECK_EQ(cd.filename, std::string("x.txt"));
}
VT_TEST(cd_unknown_type_is_other) {
auto cd = parse_content_disposition(R"(signal; filename="x.txt")");
VT_CHECK(cd.type == Type::other);
VT_CHECK_EQ(cd.filename, std::string("x.txt"));
}