// vdm/net/content_disposition.cpp #include "vdm/net/content_disposition.hpp" #include #include #include #include #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(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 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> 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