Files
vdm/core/tests/meta/veloxpart_test.cpp
T
samiandClaude Sonnet 5 092e99f7a0 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
2026-09-10 13:52:34 +04:00

247 lines
8.8 KiB
C++

#include "vdm/meta/veloxpart.hpp"
#include <unistd.h>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include "vdm/util/crc32.hpp"
#include "vtest.hpp"
using namespace vdm;
using namespace vdm::meta;
namespace {
VeloxPart sample_full() {
VeloxPart vp;
vp.version = kVersion;
vp.total_size = 5'000'000'000ull;
vp.downloaded = 1'234'567;
vp.urls = {"https://origin.example/big.iso", "https://cdn.example/big.iso?sig=abc",
"https://mirror1.example/big.iso"};
vp.etag = "\"deadbeef-1234\"";
vp.last_modified = "Wed, 01 Jan 2025 00:00:00 GMT";
vp.content_type = "application/octet-stream";
for (int i = 0; i < 8; ++i) {
SegmentRecord s;
s.start = static_cast<std::uint64_t>(i) * 625'000'000ull;
s.end = s.start + 625'000'000ull - 1; // inclusive
s.completed = (i < 2) ? s.end - s.start + 1 : 100'000ull * (i + 1);
vp.segments.push_back(s);
}
vp.flags = kFlagHasShaState;
vp.sha256_state = {std::byte{1}, std::byte{2}, std::byte{3}, std::byte{0xAA}, std::byte{0xFF}};
return vp;
}
VeloxPart sample_minimal() {
VeloxPart vp;
vp.total_size = 0; // chunked / unknown
vp.urls = {"http://x/y"};
SegmentRecord s;
s.start = 0;
s.end = 0; // 1-byte resource
s.completed = 0;
vp.segments.push_back(s);
return vp;
}
// Re-CRC a mutated body (everything except the final u32).
std::vector<std::byte> refresh_crc(std::vector<std::byte> image) {
std::uint32_t c = crc32(ConstByteSpan(image.data(), image.size() - 4));
for (int i = 0; i < 4; ++i)
image[image.size() - 4 + i] = static_cast<std::byte>((c >> (8 * i)) & 0xFF);
return image;
}
struct TempPath {
std::string path;
TempPath() {
const char *d = std::getenv("TMPDIR");
path = (d ? d : "/tmp");
path += "/vdm_vp_test_XXXXXX";
int fd = ::mkstemp(path.data());
if (fd >= 0) {
::close(fd);
::unlink(path.c_str());
}
}
~TempPath() {
::unlink(path.c_str());
::unlink((path + ".tmp").c_str());
}
};
} // namespace
VT_TEST(crc32_known_vector) {
const char *s = "123456789";
VT_CHECK_EQ(crc32(ConstByteSpan(reinterpret_cast<const std::byte *>(s), 9)), 0xCBF43926u);
VT_CHECK_EQ(crc32(ConstByteSpan{}), 0u);
}
VT_TEST(vp_roundtrip_full) {
VeloxPart in = sample_full();
auto image = serialize_veloxpart(in);
auto out = parse_veloxpart(ConstByteSpan(image.data(), image.size()));
VT_REQUIRE(out.has_value());
VT_CHECK(out.value() == in);
VT_CHECK_EQ(out.value().effective_url(),
std::string_view("https://cdn.example/big.iso?sig=abc"));
VT_CHECK_EQ(out.value().segments.size(), 8u);
}
VT_TEST(vp_roundtrip_minimal) {
VeloxPart in = sample_minimal();
auto image = serialize_veloxpart(in);
auto out = parse_veloxpart(ConstByteSpan(image.data(), image.size()));
VT_REQUIRE(out.has_value());
VT_CHECK(out.value() == in);
VT_CHECK(out.value().sha256_state.empty());
}
VT_TEST(vp_serialize_is_deterministic) {
VeloxPart in = sample_full();
auto a = serialize_veloxpart(in);
auto b = serialize_veloxpart(in);
VT_CHECK(a == b);
}
VT_TEST(vp_file_roundtrip) {
TempPath tp;
VeloxPart in = sample_full();
VT_REQUIRE(write_veloxpart_file(tp.path, in, /*fsync=*/true).has_value());
auto out = read_veloxpart_file(tp.path);
VT_REQUIRE(out.has_value());
VT_CHECK(out.value() == in);
// the temp file must be gone after the atomic rename
VT_CHECK_EQ(::access((tp.path + ".tmp").c_str(), F_OK), -1);
}
VT_TEST(vp_read_missing_file_errors) {
auto out = read_veloxpart_file("/vdm_no_such_dir_zz/x.veloxpart.meta");
VT_REQUIRE(!out.has_value());
VT_CHECK_EQ(out.error().code, Error::path_rejected);
}
// --- truncation / corruption table -------------------------------------------------
VT_TEST(vp_reject_empty_and_tiny) {
VT_CHECK_EQ(parse_veloxpart(ConstByteSpan{}).error().code, Error::meta_corrupt);
std::array<std::byte, 3> three{};
VT_CHECK_EQ(parse_veloxpart(ConstByteSpan(three.data(), 3)).error().code, Error::meta_corrupt);
std::array<std::byte, 51> almost{};
VT_CHECK_EQ(parse_veloxpart(ConstByteSpan(almost.data(), almost.size())).error().code,
Error::meta_corrupt);
}
VT_TEST(vp_reject_bad_magic) {
auto image = serialize_veloxpart(sample_minimal());
image[1] = std::byte{'X'}; // "VDMP" -> "VXMP"
image = refresh_crc(std::move(image)); // fix CRC so we're testing the magic check
auto r = parse_veloxpart(ConstByteSpan(image.data(), image.size()));
VT_REQUIRE(!r.has_value());
VT_CHECK_EQ(r.error().code, Error::meta_corrupt);
}
VT_TEST(vp_reject_crc_mismatch) {
auto image = serialize_veloxpart(sample_minimal());
image[20] ^= std::byte{0x40}; // flip a payload bit, do NOT refresh the CRC
auto r = parse_veloxpart(ConstByteSpan(image.data(), image.size()));
VT_REQUIRE(!r.has_value());
VT_CHECK_EQ(r.error().code, Error::meta_corrupt);
// flipping the CRC field itself is also a mismatch
auto image2 = serialize_veloxpart(sample_minimal());
image2.back() ^= std::byte{0xFF};
VT_CHECK_EQ(parse_veloxpart(ConstByteSpan(image2.data(), image2.size())).error().code,
Error::meta_corrupt);
}
VT_TEST(vp_future_version_is_unsupported) {
auto image = serialize_veloxpart(sample_minimal());
image[4] = std::byte{99}; // version u16 low byte
image[5] = std::byte{0};
image = refresh_crc(std::move(image));
auto r = parse_veloxpart(ConstByteSpan(image.data(), image.size()));
VT_REQUIRE(!r.has_value());
VT_CHECK_EQ(r.error().code, Error::meta_version_unsupported);
}
VT_TEST(vp_reject_truncation_at_every_stage) {
auto full = serialize_veloxpart(sample_full());
// Chop the image at many lengths; each must be meta_corrupt, never a crash.
for (std::size_t len = full.size() - 1; len >= 1; len = (len > 8 ? len - 7 : len - 1)) {
auto r = parse_veloxpart(ConstByteSpan(full.data(), len));
VT_CHECK(!r.has_value());
if (r.has_value())
break;
VT_CHECK_EQ(r.error().code, Error::meta_corrupt);
if (len == 1)
break;
}
}
VT_TEST(vp_reject_hostile_url_count) {
auto image = serialize_veloxpart(sample_minimal());
// url_count u32 sits right after magic(4)+ver(2)+flags(2)+total(8)+downloaded(8) = 24
for (int i = 0; i < 4; ++i)
image[24 + i] = std::byte{0xFF};
image = refresh_crc(std::move(image));
auto r = parse_veloxpart(ConstByteSpan(image.data(), image.size()));
VT_REQUIRE(!r.has_value());
VT_CHECK_EQ(r.error().code, Error::meta_corrupt);
}
VT_TEST(vp_reject_hostile_lp_string_length) {
// The case AGENT-CORE singles out: a length prefix that reaches past the buffer.
// Build a minimal image, then overwrite url[0]'s length prefix with a huge value.
auto image = serialize_veloxpart(sample_minimal());
// layout up to url[0] length: magic4 ver2 flags2 total8 downloaded8 url_count4 = 28
for (int i = 0; i < 4; ++i)
image[28 + i] = std::byte{0xFF}; // url[0] len = 4 GiB - 1
image = refresh_crc(std::move(image));
auto r = parse_veloxpart(ConstByteSpan(image.data(), image.size()));
VT_REQUIRE(!r.has_value());
VT_CHECK_EQ(r.error().code, Error::meta_corrupt);
}
VT_TEST(vp_reject_hostile_segment_count) {
auto image = serialize_veloxpart(sample_minimal());
// find the segment_count: it's u32 right before the (24-byte) segment records and the
// trailing crc. minimal sample has 1 segment and no sha state, so:
// segment_count is at size - 4(crc) - 24(one segment) - 4 = size - 32
std::size_t sc = image.size() - 32;
for (int i = 0; i < 4; ++i)
image[sc + i] = std::byte{0xFF};
image = refresh_crc(std::move(image));
auto r = parse_veloxpart(ConstByteSpan(image.data(), image.size()));
VT_REQUIRE(!r.has_value());
VT_CHECK_EQ(r.error().code, Error::meta_corrupt);
}
VT_TEST(vp_reject_trailing_bytes) {
auto image = serialize_veloxpart(sample_minimal());
image.push_back(std::byte{0});
image.push_back(std::byte{0});
image = refresh_crc(std::move(image)); // CRC now covers the padding too
auto r = parse_veloxpart(ConstByteSpan(image.data(), image.size()));
VT_REQUIRE(!r.has_value());
VT_CHECK_EQ(r.error().code, Error::meta_corrupt);
}
VT_TEST(vp_reject_segment_completed_over_length) {
VeloxPart in = sample_minimal();
in.segments[0].start = 0;
in.segments[0].end = 99; // length 100
in.segments[0].completed = 500; // impossible
auto image = serialize_veloxpart(in);
auto r = parse_veloxpart(ConstByteSpan(image.data(), image.size()));
VT_REQUIRE(!r.has_value());
VT_CHECK_EQ(r.error().code, Error::meta_corrupt);
}