core: meta/veloxpart — resume sidecar, reader first + fuzzed (stage 5)

util/crc32.hpp — header-only CRC-32 (zlib polynomial, reflected), used to
integrity-check the sidecar.

meta/veloxpart — the <name>.veloxpart.meta resume file (docs/04 §5).
Little-endian, versioned, CRC-32 over the whole record. Layout: magic,
version, flags, total_size, downloaded, url set (original/effective/
mirrors), etag/last-modified/content-type, segment records (start, end
INCLUSIVE, completed), optional sha256 streaming-hash blob.

parse_veloxpart() is the attacker-facing surface (the file sits in a
world-writable-ish download dir) and is total on any byte string: CRC
checked before any field is interpreted; magic, a version it understands,
every count and length bounded by a hard cap AND checked against the
remaining buffer; ByteReader latches on overrun; trailing bytes rejected.
Every malformation is meta_corrupt / meta_version_unsupported, never a
crash or an unbounded allocation. serialize_veloxpart() is deterministic
(unchanged sidecar isn't rewritten). File helpers write atomically
(temp + rename) and fdatasync the file and its directory.

Tests: crc32 known vector; full + minimal round-trips; deterministic
serialize; file round-trip; and a truncation/corruption table — bad
magic, CRC mismatch (payload and CRC-field flips), future version,
truncation at every stage, hostile url_count / segment_count / lp_string
length (the case the brief singles out), trailing bytes, impossible
segment.completed.

tools/fuzz/fuzz_veloxpart — feeds raw bytes and bytes-with-valid-CRC
(so the field parser and ByteReader bounds checks are actually reached),
and round-trip-stability-checks anything accepted. Ran 1.1M execs clean
under ASan+UBSan+libFuzzer (clang++-21); fuzz_content_disposition and
fuzz_url likewise re-run to 1.1M. tools/fuzz gains a -runs=0 seed-replay
CTest smoke per target (regression tripwire; the campaign stays manual).

Fuzz-found and fixed: parse_content_disposition could emit a filename
containing NUL / control bytes from a mangled filename* ext-value —
strip_path only removed path separators. Now sanitize_leaf() also drops
C0 controls and DEL (rules/ still owns the authoritative sanitize; `..`
and printable-unsafe content pass through as before).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
This commit is contained in:
2026-09-10 13:52:34 +04:00
co-authored by Claude Sonnet 5
parent 2e3251f0b5
commit 092e99f7a0
15 changed files with 830 additions and 15 deletions
+16 -3
View File
@@ -111,6 +111,20 @@ Params tokenize(std::string_view h) {
return out;
}
// Drop C0 control bytes and DEL, then trim edge whitespace. NUL and control characters
// are never a legitimate part of a filename and are a classic truncation/spoofing vector,
// so the decode layer strips them even though rules/ (stage 9) owns the authoritative
// sanitize. `..` and other "unsafe but printable" content is left for rules/.
std::string sanitize_leaf(std::string s) {
std::string out;
out.reserve(s.size());
for (unsigned char c : s)
if (c >= 0x20 && c != 0x7F)
out.push_back(static_cast<char>(c));
std::string_view v = trim(out);
return std::string(v);
}
std::string strip_path(std::string s) {
auto slash = s.find_last_of("/\\");
if (slash != std::string::npos)
@@ -270,9 +284,8 @@ ContentDisposition parse_content_disposition(std::string_view header_value) {
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);
// Drop control bytes (incl. NUL) and edge whitespace the decoders may have produced.
cd.filename = sanitize_leaf(std::move(cd.filename));
return cd;
}