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:
@@ -0,0 +1,279 @@
|
||||
// vdm/net/content_disposition.cpp
|
||||
|
||||
#include "vdm/net/content_disposition.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "net/text_codec.hpp"
|
||||
|
||||
namespace vdm::net {
|
||||
namespace {
|
||||
|
||||
using detail::decode_rfc2047;
|
||||
using detail::latin1_to_utf8;
|
||||
using detail::percent_decode;
|
||||
using detail::to_utf8_best_effort;
|
||||
|
||||
std::string_view trim(std::string_view s) {
|
||||
while (!s.empty() && (s.front() == ' ' || s.front() == '\t'))
|
||||
s.remove_prefix(1);
|
||||
while (!s.empty() &&
|
||||
(s.back() == ' ' || s.back() == '\t' || s.back() == '\r' || s.back() == '\n'))
|
||||
s.remove_suffix(1);
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string ascii_lower(std::string_view s) {
|
||||
std::string r(s);
|
||||
for (char &c : r)
|
||||
if (c >= 'A' && c <= 'Z')
|
||||
c = static_cast<char>(c - 'A' + 'a');
|
||||
return r;
|
||||
}
|
||||
|
||||
// Split "type; a=b; c*=d; e=\"f;g\"" into the type and a param list, honouring quoted
|
||||
// strings (a ';' inside quotes is not a separator). For quoted values the value stored is
|
||||
// the *raw* inner text (quotes removed, escapes NOT yet resolved) with `quoted = true`;
|
||||
// callers resolve escapes and strip path components together (order matters — see
|
||||
// unquote_strip). Keys are lowercased.
|
||||
struct Param {
|
||||
std::string key;
|
||||
std::string value;
|
||||
bool quoted = false;
|
||||
};
|
||||
struct Params {
|
||||
std::string type;
|
||||
std::vector<Param> kv;
|
||||
|
||||
[[nodiscard]] const Param *find(std::string_view key) const {
|
||||
for (const auto &p : kv)
|
||||
if (p.key == key)
|
||||
return &p;
|
||||
return nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
Params tokenize(std::string_view h) {
|
||||
Params out;
|
||||
std::size_t i = 0;
|
||||
const std::size_t n = h.size();
|
||||
|
||||
auto read_segment = [&]() -> std::string_view {
|
||||
std::size_t start = i;
|
||||
bool in_q = false;
|
||||
for (; i < n; ++i) {
|
||||
char c = h[i];
|
||||
if (c == '"') {
|
||||
in_q = !in_q;
|
||||
} else if (c == '\\' && in_q && i + 1 < n) {
|
||||
++i; // skip escaped char
|
||||
} else if (c == ';' && !in_q) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::string_view seg = h.substr(start, i - start);
|
||||
if (i < n)
|
||||
++i; // consume ';'
|
||||
return seg;
|
||||
};
|
||||
|
||||
out.type = ascii_lower(trim(read_segment()));
|
||||
|
||||
while (i < n) {
|
||||
std::string_view seg = trim(read_segment());
|
||||
if (seg.empty())
|
||||
continue;
|
||||
auto eq = seg.find('=');
|
||||
if (eq == std::string_view::npos) {
|
||||
out.kv.emplace_back(ascii_lower(seg), std::string{});
|
||||
continue;
|
||||
}
|
||||
std::string key = ascii_lower(trim(seg.substr(0, eq)));
|
||||
std::string_view rawval = trim(seg.substr(eq + 1));
|
||||
|
||||
Param param;
|
||||
param.key = std::move(key);
|
||||
if (rawval.size() >= 2 && rawval.front() == '"') {
|
||||
std::string_view inner = rawval.substr(1);
|
||||
auto close = inner.rfind('"');
|
||||
if (close != std::string_view::npos)
|
||||
inner = inner.substr(0, close);
|
||||
param.value.assign(inner); // raw, escapes unresolved
|
||||
param.quoted = true;
|
||||
} else {
|
||||
param.value.assign(rawval);
|
||||
}
|
||||
out.kv.push_back(std::move(param));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string strip_path(std::string s) {
|
||||
auto slash = s.find_last_of("/\\");
|
||||
if (slash != std::string::npos)
|
||||
s.erase(0, slash + 1);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Resolve ONLY the `\"` escape (needed so a quote can appear mid-name). Every other
|
||||
// backslash is kept literal and later treated as a path separator by strip_path — real
|
||||
// Windows paths in the wild use `\` unescaped, and path-traversal defence matters more
|
||||
// than supporting the vanishingly rare filename with a literal backslash.
|
||||
std::string unescape_dquote(std::string_view raw) {
|
||||
std::string out;
|
||||
out.reserve(raw.size());
|
||||
for (std::size_t i = 0; i < raw.size(); ++i) {
|
||||
if (raw[i] == '\\' && i + 1 < raw.size() && raw[i + 1] == '"') {
|
||||
out.push_back('"');
|
||||
++i;
|
||||
} else {
|
||||
out.push_back(raw[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Decode an RFC 5987 ext-value: charset'lang'pct-encoded-octets
|
||||
std::string decode_ext_value(std::string_view v) {
|
||||
auto q1 = v.find('\'');
|
||||
if (q1 == std::string_view::npos)
|
||||
return percent_decode(v); // malformed: best effort
|
||||
auto q2 = v.find('\'', q1 + 1);
|
||||
if (q2 == std::string_view::npos)
|
||||
return percent_decode(v.substr(q1 + 1));
|
||||
|
||||
std::string_view charset = v.substr(0, q1);
|
||||
std::string_view enc = v.substr(q2 + 1);
|
||||
std::string bytes = percent_decode(enc);
|
||||
|
||||
std::string cs = ascii_lower(charset);
|
||||
if (cs == "iso-8859-1" || cs == "latin1")
|
||||
return latin1_to_utf8(bytes);
|
||||
return to_utf8_best_effort(bytes); // utf-8 or unknown -> best effort
|
||||
}
|
||||
|
||||
// Reassemble RFC 2231 continuations: name*0*, name*1, name*2* ... in order.
|
||||
std::string join_continuations(const Params &p, std::string_view base, bool &is_ext) {
|
||||
std::vector<std::pair<int, std::string>> parts;
|
||||
is_ext = false;
|
||||
for (const auto ¶m : p.kv) {
|
||||
const std::string &k = param.key;
|
||||
const std::string &val = param.value;
|
||||
if (k.size() <= base.size() + 1 || k.compare(0, base.size(), base) != 0)
|
||||
continue;
|
||||
if (k[base.size()] != '*')
|
||||
continue;
|
||||
std::string_view rest(k);
|
||||
rest.remove_prefix(base.size() + 1); // after "base*"
|
||||
bool star = false;
|
||||
if (!rest.empty() && rest.back() == '*') {
|
||||
star = true;
|
||||
rest.remove_suffix(1);
|
||||
}
|
||||
int idx = 0;
|
||||
for (char c : rest) {
|
||||
if (c < '0' || c > '9') {
|
||||
idx = -1;
|
||||
break;
|
||||
}
|
||||
idx = idx * 10 + (c - '0');
|
||||
}
|
||||
if (idx < 0)
|
||||
continue;
|
||||
if (star)
|
||||
is_ext = true;
|
||||
parts.emplace_back(idx, val);
|
||||
}
|
||||
if (parts.empty())
|
||||
return {};
|
||||
std::sort(parts.begin(), parts.end(),
|
||||
[](const auto &a, const auto &b) { return a.first < b.first; });
|
||||
|
||||
// Piece 0 (if star-form) carries charset'lang' prefix; later pieces are raw
|
||||
// percent-encoded. Concatenate the percent-encoded text then decode once.
|
||||
std::string charset_prefix;
|
||||
std::string enc;
|
||||
bool first = true;
|
||||
for (auto &[idx, val] : parts) {
|
||||
if (first && is_ext) {
|
||||
auto q1 = val.find('\'');
|
||||
auto q2 = (q1 == std::string::npos) ? std::string::npos : val.find('\'', q1 + 1);
|
||||
if (q2 != std::string::npos) {
|
||||
charset_prefix = val.substr(0, q2 + 1);
|
||||
enc += val.substr(q2 + 1);
|
||||
} else {
|
||||
enc += val;
|
||||
}
|
||||
} else {
|
||||
enc += val;
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
if (is_ext)
|
||||
return decode_ext_value(charset_prefix + enc);
|
||||
return to_utf8_best_effort(percent_decode(enc));
|
||||
}
|
||||
|
||||
ContentDisposition::Type classify(std::string_view t) {
|
||||
if (t == "inline")
|
||||
return ContentDisposition::Type::inline_;
|
||||
if (t == "attachment")
|
||||
return ContentDisposition::Type::attachment;
|
||||
if (t == "form-data")
|
||||
return ContentDisposition::Type::form_data;
|
||||
if (t.empty())
|
||||
return ContentDisposition::Type::none;
|
||||
return ContentDisposition::Type::other;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ContentDisposition parse_content_disposition(std::string_view header_value) {
|
||||
ContentDisposition cd;
|
||||
header_value = trim(header_value);
|
||||
if (header_value.empty())
|
||||
return cd;
|
||||
|
||||
Params p = tokenize(header_value);
|
||||
cd.type = classify(p.type);
|
||||
|
||||
// RFC 6266 §4.3: prefer filename* over filename.
|
||||
std::string ext_name;
|
||||
bool ext_is_ext = false;
|
||||
if (const Param *fstar = p.find("filename*")) {
|
||||
ext_name = decode_ext_value(fstar->value);
|
||||
ext_is_ext = true;
|
||||
} else {
|
||||
std::string joined = join_continuations(p, "filename", ext_is_ext);
|
||||
if (!joined.empty())
|
||||
ext_name = std::move(joined);
|
||||
}
|
||||
|
||||
std::string plain_name;
|
||||
if (const Param *f = p.find("filename")) {
|
||||
// Resolve `\"`, then decode legacy encoded-words if present — BEFORE stripping
|
||||
// path components, since a base64 payload can legitimately contain '/'.
|
||||
std::string raw = f->quoted ? unescape_dquote(f->value) : f->value;
|
||||
bool had_ew = false;
|
||||
std::string decoded = decode_rfc2047(raw, &had_ew);
|
||||
plain_name = had_ew ? std::move(decoded) : to_utf8_best_effort(raw);
|
||||
}
|
||||
|
||||
if (!ext_name.empty()) {
|
||||
cd.filename = strip_path(std::move(ext_name));
|
||||
cd.filename_from_ext = ext_is_ext;
|
||||
} else if (!plain_name.empty()) {
|
||||
cd.filename = strip_path(std::move(plain_name));
|
||||
cd.filename_from_ext = false;
|
||||
}
|
||||
|
||||
// Trim ASCII whitespace the decoders may have left at the edges.
|
||||
std::string_view fv = trim(cd.filename);
|
||||
cd.filename.assign(fv);
|
||||
return cd;
|
||||
}
|
||||
|
||||
} // namespace vdm::net
|
||||
Reference in New Issue
Block a user