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
312 lines
9.4 KiB
C++
312 lines
9.4 KiB
C++
// vdm/meta/veloxpart.cpp
|
|
//
|
|
// Reader first (AGENT-CORE §5). parse_veloxpart() is the attacker-facing surface; it is
|
|
// total on any byte string.
|
|
|
|
#include "vdm/meta/veloxpart.hpp"
|
|
|
|
#include <fcntl.h>
|
|
#include <unistd.h>
|
|
|
|
#include <cerrno>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <utility>
|
|
|
|
#include "vdm/util/crc32.hpp"
|
|
|
|
namespace vdm::meta {
|
|
namespace {
|
|
|
|
// magic(4)+ver(2)+flags(2)+total(8)+downloaded(8)+url_count(4)
|
|
// +etag_len(4)+lm_len(4)+ct_len(4)+seg_count(4)+crc(4)
|
|
constexpr std::size_t kMinImageBytes = 52;
|
|
constexpr char kMagic[4] = {'V', 'D', 'M', 'P'};
|
|
|
|
Error errno_to_error(int e) noexcept {
|
|
switch (e) {
|
|
case ENOSPC:
|
|
case EDQUOT:
|
|
return Error::disk_full;
|
|
case EACCES:
|
|
case EPERM:
|
|
case EROFS:
|
|
return Error::permission_denied;
|
|
case ENOENT:
|
|
case ENOTDIR:
|
|
case EISDIR:
|
|
case ENAMETOOLONG:
|
|
case ELOOP:
|
|
return Error::path_rejected;
|
|
default:
|
|
return Error::io_error;
|
|
}
|
|
}
|
|
|
|
ErrorInfo sys_error(std::string_view what, int e) {
|
|
return ErrorInfo(errno_to_error(e), std::string(what) + ": " + std::strerror(e));
|
|
}
|
|
|
|
ErrorInfo corrupt(std::string_view where) {
|
|
return ErrorInfo(Error::meta_corrupt, std::string(".veloxpart.meta: ") + std::string(where));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// Reader
|
|
|
|
Result<VeloxPart> parse_veloxpart(ConstByteSpan image) {
|
|
if (image.size() > kMaxImageBytes)
|
|
return corrupt("image exceeds cap");
|
|
if (image.size() < kMinImageBytes)
|
|
return corrupt("image shorter than the header");
|
|
|
|
// CRC over everything but the trailing u32 — reject before interpreting any field.
|
|
const ConstByteSpan body = image.first(image.size() - 4);
|
|
const std::uint32_t want = load_le<std::uint32_t>(image.subspan(image.size() - 4));
|
|
if (crc32(body) != want)
|
|
return corrupt("crc32 mismatch");
|
|
|
|
ByteReader r(body);
|
|
|
|
if (as_chars(r.raw(4)) != std::string_view(kMagic, 4))
|
|
return corrupt("bad magic");
|
|
|
|
VeloxPart vp;
|
|
vp.version = r.u16();
|
|
vp.flags = r.u16();
|
|
if (vp.version > kVersion)
|
|
return ErrorInfo(Error::meta_version_unsupported,
|
|
".veloxpart.meta: version " + std::to_string(vp.version) +
|
|
" > supported " + std::to_string(kVersion));
|
|
|
|
vp.total_size = r.u64();
|
|
vp.downloaded = r.u64();
|
|
|
|
const std::uint32_t url_count = r.u32();
|
|
if (url_count > kMaxUrls)
|
|
return corrupt("url_count past cap");
|
|
if (static_cast<std::uint64_t>(url_count) * 4 > r.remaining())
|
|
return corrupt("url_count");
|
|
vp.urls.reserve(url_count);
|
|
for (std::uint32_t i = 0; i < url_count; ++i) {
|
|
std::string_view s = r.lp_string();
|
|
if (r.overran() || s.size() > kMaxStringLen)
|
|
return corrupt("url");
|
|
vp.urls.emplace_back(s);
|
|
}
|
|
|
|
auto read_str = [&](std::string &dst, std::string_view what) -> Result<void> {
|
|
std::string_view s = r.lp_string();
|
|
if (r.overran() || s.size() > kMaxStringLen)
|
|
return corrupt(what);
|
|
dst.assign(s);
|
|
return ok();
|
|
};
|
|
VDM_TRY(read_str(vp.etag, "etag"));
|
|
VDM_TRY(read_str(vp.last_modified, "last_modified"));
|
|
VDM_TRY(read_str(vp.content_type, "content_type"));
|
|
|
|
const std::uint32_t seg_count = r.u32();
|
|
if (seg_count > kMaxSegments)
|
|
return corrupt("segment_count past cap");
|
|
if (static_cast<std::uint64_t>(seg_count) * 24 > r.remaining())
|
|
return corrupt("segment_count");
|
|
vp.segments.reserve(seg_count);
|
|
for (std::uint32_t i = 0; i < seg_count; ++i) {
|
|
SegmentRecord s;
|
|
s.start = r.u64();
|
|
s.end = r.u64();
|
|
s.completed = r.u64();
|
|
if (r.overran())
|
|
return corrupt("segment record");
|
|
if (s.end >= s.start && s.completed > s.end - s.start + 1)
|
|
return corrupt("segment.completed exceeds its range");
|
|
vp.segments.push_back(s);
|
|
}
|
|
|
|
if (vp.flags & kFlagHasShaState) {
|
|
const std::uint32_t n = r.u32();
|
|
if (r.overran() || n > kMaxShaStateLen)
|
|
return corrupt("sha256_state length");
|
|
ConstByteSpan blob = r.raw(n);
|
|
if (r.overran())
|
|
return corrupt("sha256_state body");
|
|
vp.sha256_state.assign(blob.begin(), blob.end());
|
|
}
|
|
|
|
if (r.overran())
|
|
return corrupt("truncated");
|
|
if (r.remaining() != 0)
|
|
return corrupt("trailing bytes after the record");
|
|
|
|
return vp;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// Writer
|
|
|
|
std::vector<std::byte> serialize_veloxpart(const VeloxPart &vp) {
|
|
std::vector<std::byte> out;
|
|
std::size_t est = kMinImageBytes + vp.segments.size() * 24 + vp.sha256_state.size() + 64;
|
|
for (const auto &u : vp.urls)
|
|
est += 4 + u.size();
|
|
est += vp.etag.size() + vp.last_modified.size() + vp.content_type.size();
|
|
out.reserve(est);
|
|
|
|
auto put_bytes = [&](const void *p, std::size_t n) {
|
|
const auto *b = static_cast<const std::byte *>(p);
|
|
out.insert(out.end(), b, b + n);
|
|
};
|
|
auto put_u16 = [&](std::uint16_t v) {
|
|
std::byte t[2];
|
|
store_le<std::uint16_t>(t, v);
|
|
put_bytes(t, 2);
|
|
};
|
|
auto put_u32 = [&](std::uint32_t v) {
|
|
std::byte t[4];
|
|
store_le<std::uint32_t>(t, v);
|
|
put_bytes(t, 4);
|
|
};
|
|
auto put_u64 = [&](std::uint64_t v) {
|
|
std::byte t[8];
|
|
store_le<std::uint64_t>(t, v);
|
|
put_bytes(t, 8);
|
|
};
|
|
auto put_str = [&](std::string_view s) {
|
|
put_u32(static_cast<std::uint32_t>(s.size()));
|
|
put_bytes(s.data(), s.size());
|
|
};
|
|
|
|
// Normalise the sha-state flag to match the payload so a round-trip is exact.
|
|
std::uint16_t flags = vp.flags;
|
|
if (vp.sha256_state.empty())
|
|
flags &= static_cast<std::uint16_t>(~kFlagHasShaState);
|
|
else
|
|
flags |= kFlagHasShaState;
|
|
|
|
put_bytes(kMagic, 4);
|
|
put_u16(vp.version);
|
|
put_u16(flags);
|
|
put_u64(vp.total_size);
|
|
put_u64(vp.downloaded);
|
|
put_u32(static_cast<std::uint32_t>(vp.urls.size()));
|
|
for (const auto &u : vp.urls)
|
|
put_str(u);
|
|
put_str(vp.etag);
|
|
put_str(vp.last_modified);
|
|
put_str(vp.content_type);
|
|
put_u32(static_cast<std::uint32_t>(vp.segments.size()));
|
|
for (const auto &s : vp.segments) {
|
|
put_u64(s.start);
|
|
put_u64(s.end);
|
|
put_u64(s.completed);
|
|
}
|
|
if (!vp.sha256_state.empty()) {
|
|
put_u32(static_cast<std::uint32_t>(vp.sha256_state.size()));
|
|
put_bytes(vp.sha256_state.data(), vp.sha256_state.size());
|
|
}
|
|
|
|
put_u32(crc32(ConstByteSpan(out.data(), out.size())));
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// File helpers
|
|
|
|
Result<VeloxPart> read_veloxpart_file(std::string_view path) {
|
|
std::string p(path);
|
|
int fd = ::open(p.c_str(), O_RDONLY | O_CLOEXEC);
|
|
if (fd < 0)
|
|
return sys_error("open " + p, errno);
|
|
|
|
std::vector<std::byte> buf;
|
|
buf.resize(kMaxImageBytes + 1);
|
|
std::size_t total = 0;
|
|
for (;;) {
|
|
ssize_t n = ::read(fd, buf.data() + total, buf.size() - total);
|
|
if (n < 0) {
|
|
if (errno == EINTR)
|
|
continue;
|
|
int e = errno;
|
|
::close(fd);
|
|
return sys_error("read " + p, e);
|
|
}
|
|
if (n == 0)
|
|
break;
|
|
total += static_cast<std::size_t>(n);
|
|
if (total > kMaxImageBytes) {
|
|
::close(fd);
|
|
return corrupt("sidecar file exceeds cap");
|
|
}
|
|
}
|
|
::close(fd);
|
|
buf.resize(total);
|
|
return parse_veloxpart(ConstByteSpan(buf.data(), buf.size()));
|
|
}
|
|
|
|
Result<void> write_veloxpart_file(std::string_view path, const VeloxPart &vp, bool fsync) {
|
|
std::string p(path);
|
|
std::string tmp = p + ".tmp";
|
|
std::vector<std::byte> image = serialize_veloxpart(vp);
|
|
|
|
int fd = ::open(tmp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
|
|
if (fd < 0)
|
|
return sys_error("open " + tmp, errno);
|
|
|
|
const std::byte *pd = image.data();
|
|
std::size_t remaining = image.size();
|
|
while (remaining > 0) {
|
|
ssize_t n = ::write(fd, pd, remaining);
|
|
if (n < 0) {
|
|
if (errno == EINTR)
|
|
continue;
|
|
int e = errno;
|
|
::close(fd);
|
|
::unlink(tmp.c_str());
|
|
return sys_error("write " + tmp, e);
|
|
}
|
|
pd += n;
|
|
remaining -= static_cast<std::size_t>(n);
|
|
}
|
|
|
|
if (fsync) {
|
|
while (::fdatasync(fd) != 0) {
|
|
if (errno == EINTR)
|
|
continue;
|
|
int e = errno;
|
|
::close(fd);
|
|
::unlink(tmp.c_str());
|
|
return sys_error("fdatasync " + tmp, e);
|
|
}
|
|
}
|
|
if (::close(fd) != 0) {
|
|
int e = errno;
|
|
::unlink(tmp.c_str());
|
|
return sys_error("close " + tmp, e);
|
|
}
|
|
|
|
if (::rename(tmp.c_str(), p.c_str()) != 0) {
|
|
int e = errno;
|
|
::unlink(tmp.c_str());
|
|
return sys_error("rename " + tmp + " -> " + p, e);
|
|
}
|
|
|
|
if (fsync) {
|
|
// fsync the directory so the rename itself is durable.
|
|
std::string dir = p.substr(0, p.find_last_of('/'));
|
|
if (dir.empty() || dir == p)
|
|
dir = ".";
|
|
int dfd = ::open(dir.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
|
if (dfd >= 0) {
|
|
while (::fsync(dfd) != 0 && errno == EINTR) {
|
|
}
|
|
::close(dfd);
|
|
}
|
|
}
|
|
return ok();
|
|
}
|
|
|
|
} // namespace vdm::meta
|