Land src/core/package: the pure RSBK container — format ladder, JSON manifest, framing/layout codec

Two-integer ladder (formatVersion/minReaderVersion), bare-name-only entries
validated on encode and decode, prefix decode that proves exact file size
without ever reading a payload.
This commit is contained in:
2026-08-02 07:27:43 -04:00
parent 09a9ef838f
commit 043558a54d
12 changed files with 1351 additions and 0 deletions
+1
View File
@@ -92,3 +92,4 @@ enable_testing()
add_subdirectory(src/core)
add_subdirectory(src/app)
add_subdirectory(src/shell/instrument)
add_subdirectory(src/core/package)
+78
View File
@@ -0,0 +1,78 @@
# src/core/package — the pure RSBK bank-package codec
## Scope
The hand-rolled `RSBK` bank-package container, entirely pure (REAPER-free,
unit-tested outside the DAW): the format contract and version ladder, the JSON
manifest, and the framing/layout codec. No filesystem — the shell
(`src/shell/package`) streams bytes against the layouts produced here. The
export/import *decisions* (`export_plan` / `import_plan`) are separate modules
landing after the format.
## Invariants
- **The container is the proprietary `RSBK` — ruled, not revisitable here.** No
ZIP, no compressor, no link edge to `vendor/WDL/WDL/zlib/`. The version
ladder, not a format swap, is how the format moves.
- **Two version integers, two jobs.** `formatVersion` = what the writer
emitted; `minReaderVersion` = the oldest reader that can read it safely. The
reader's whole rule is `minReaderVersion <= kPackageFormatVersion`. Additive
changes (a new optional manifest key, a new enum value with a defined
degrade) bump `formatVersion` only; structural changes bump both. The full
ladder lives as a comment in `package_format.h` and is READ and validated,
never merely written.
- **TooNew refuses whole.** A `minReaderVersion` above this build yields the
header (so the refusal can name the writer's semver and version) and nothing
else — no manifest, no layout, no half-success. The fields through the writer
semver are FROZEN for all future versions to keep that refusal producible.
- **Path expression is structurally impossible.** Entry names are bare file
names (`isValidEntryName`: no separators, no `..`, no drive/UNC/rooted form),
enforced on encode AND decode because a package can arrive from anywhere.
There is no field in the format capable of expressing a path.
- **Framing only, never a payload.** `bank_package` produces header bytes and
an ordered `{name, offset, length}` layout; it never holds, copies, or hashes
an entry's audio. `decodePackage` proves prefix + payload lengths equal the
observed file size exactly, so truncation and trailing garbage are Malformed
without any payload being read.
- **Per-sample shape has one owner.** Each entry nests a one-sample `BankModel`
blob emitted/parsed by `bank_model`'s own codec (the `bank_book_json`
precedent), so a future `Sample` field reaches packages with no change here.
- **Hostile input: error signaled, never UB** — the `bank_model.h` deserialize
standard, plus allocation caps on every length field so a forged header
cannot demand gigabytes.
## Modules
- `package_format` — the contract: magic, `kPackageFormatVersion` /
`kPackageMinReaderVersion`, the ladder comment, the three-way
`classifyPackageVersion` (`Readable` / `TooNew` / `Malformed`), the
entry-name rule, and `PackageHeader`.
- `package_manifest` — the manifest model (`PackageEntry` / `PackageManifest`)
and its JSON codec. Per entry: bare name, byte length, and a whole-file
`capture::hashBytes` digest (deliberately NOT `hashWavContent`, which skips
chunks and cannot answer "did these bytes survive") — the digest is carried
here, computed where payloads are streamed (shell). The bank's `slot_map`
rides along. Unknown keys skip at every level; duplicate entry names are
rejected both ways.
- `bank_package` — framing and arithmetic composing the two above:
`encodePackage` (prefix bytes + layout + total size, stamping this build's
ladder pair and `version::stampVersion()`), `decodePackage` (prefix + observed
file size in; header/manifest/layout out), and `requiredPrefixSize` (the
incremental-read seam for the shell). Framing rides `core/wire/bytes.h`.
## Gotchas
- Enums nested inside the `BankModel` blob follow `bank_model`'s own rule — an
out-of-range `sourceMode`/`tier` REJECTS the parse — so growing one of those
vocabularies is a `minReaderVersion` bump for packages, not an additive
change. Any enum integer the manifest itself ever adds must instead follow
the degrade-to-`Unknown` rule (`core/wire`'s `BakeStatus` precedent) to stay
additive. The manifest carries no enum of its own today.
- Sample-id rules (remap, collision, dedup across the destination) are
deliberately NOT enforced by the codec — they are `import_plan` decisions. The
codec rejects only what makes the container itself incoherent (duplicate
entry names, invalid names, a non-single-sample nested index).
- `requiredPrefixSize` trusts fields beyond the frozen region only when the
version pair classifies `Readable`; for `TooNew` it stops at the semver —
don't "fix" it to read the manifest length there, a future structural format
may have moved it.
+14
View File
@@ -0,0 +1,14 @@
reasampler_pure_library(package_format SOURCES package_format.cpp)
reasampler_test(package_format LINK package_format)
reasampler_pure_library(package_manifest
SOURCES package_manifest.cpp
LINK PUBLIC bank_model slot_map PRIVATE package_format json)
reasampler_test(package_manifest LINK package_manifest)
# bytes.h is header-only (see src/core/wire/CLAUDE.md) — no wire link edge needed.
reasampler_pure_library(bank_package
SOURCES bank_package.cpp
LINK PUBLIC package_format package_manifest PRIVATE app_version)
# app_version: the tests pin the stamped writer semver against stampVersion().
reasampler_test(bank_package LINK bank_package app_version)
+139
View File
@@ -0,0 +1,139 @@
#include "core/package/bank_package.h"
#include <cstring>
#include "core/version/app_version.h"
#include "core/wire/bytes.h"
// Byte offsets (format 1, see package_format.h's ladder): magic at 0, u32
// formatVersion at 4, u32 minReaderVersion at 8, u32 semver length W at 12,
// semver at 16, u32 manifest length M at 16+W, manifest at 20+W, payloads at
// 20+W+M. The region through the semver is the FROZEN refusal surface.
namespace reasampler::package {
namespace {
constexpr std::size_t kMagicBytes = 4;
constexpr std::size_t kSemverLenAt = 12;
constexpr std::size_t kSemverAt = 16;
bool magicMatches(const std::vector<std::uint8_t>& bytes) {
return bytes.size() >= kMagicBytes &&
std::memcmp(bytes.data(), kPackageMagic, kMagicBytes) == 0;
}
std::uint32_t u32At(const std::vector<std::uint8_t>& bytes, std::size_t at) {
std::uint32_t v = 0;
for (std::size_t b = 0; b < 4; ++b)
v |= static_cast<std::uint32_t>(bytes[at + b]) << (b * 8);
return v;
}
// Appends the payload spans for `entries` starting at `firstOffset`. False on
// u64 overflow (a forged length field summing past 2^64 must not wrap into a
// plausible layout).
bool appendSpans(const std::vector<PackageEntry>& entries, std::uint64_t firstOffset,
std::vector<PackageEntrySpan>& out, std::uint64_t& end) {
std::uint64_t offset = firstOffset;
for (const auto& e : entries) {
out.push_back({e.fileName, offset, e.byteLength});
if (offset + e.byteLength < offset) return false;
offset += e.byteLength;
}
end = offset;
return true;
}
} // namespace
std::optional<EncodedPackage> encodePackage(const PackageManifest& m) {
auto manifestJson = serializeManifest(m);
if (!manifestJson) return std::nullopt;
if (manifestJson->size() > kMaxManifestBytes) return std::nullopt;
const std::string& writer = version::stampVersion();
if (writer.size() > kMaxWriterVersionBytes) return std::nullopt;
EncodedPackage enc;
auto& out = enc.prefix;
out.insert(out.end(), kPackageMagic, kPackageMagic + kMagicBytes);
wire::putLE(out, kPackageFormatVersion);
wire::putLE(out, kPackageMinReaderVersion);
wire::putLE(out, static_cast<std::uint32_t>(writer.size()));
out.insert(out.end(), writer.begin(), writer.end());
wire::putLE(out, static_cast<std::uint32_t>(manifestJson->size()));
out.insert(out.end(), manifestJson->begin(), manifestJson->end());
if (!appendSpans(m.entries, out.size(), enc.layout, enc.totalSize))
return std::nullopt;
return enc;
}
DecodedPackage decodePackage(const std::vector<std::uint8_t>& prefix,
std::uint64_t totalFileSize) {
DecodedPackage dec; // status starts Malformed; every early return means it
wire::ByteReader r(prefix);
if (r.str(kMagicBytes) != std::string(kPackageMagic, kMagicBytes)) return dec;
const std::uint32_t formatVersion = r.u32();
const std::uint32_t minReader = r.u32();
if (!r.ok) return dec;
const PackageReadability verdict = classifyPackageVersion(formatVersion, minReader);
if (verdict == PackageReadability::Malformed) return dec;
const std::uint32_t semverLen = r.u32();
if (!r.ok || semverLen > kMaxWriterVersionBytes) return dec;
std::string writer = r.str(semverLen);
if (!r.ok) return dec;
dec.header = PackageHeader{formatVersion, minReader, std::move(writer)};
if (verdict == PackageReadability::TooNew) {
// Refuse whole: the header names the writer for the message; nothing
// past the frozen region is read, and no manifest is produced.
dec.status = PackageReadability::TooNew;
return dec;
}
const std::uint32_t manifestLen = r.u32();
if (!r.ok || manifestLen > kMaxManifestBytes) return dec;
const std::string manifestJson = r.str(manifestLen);
if (!r.ok) return dec;
auto manifest = deserializeManifest(manifestJson);
if (!manifest) return dec;
std::uint64_t end = 0;
std::vector<PackageEntrySpan> layout;
if (!appendSpans(manifest->entries, r.pos, layout, end)) return dec;
// Exact-size proof: a byte missing (truncation) or a byte extra (trailing
// garbage) both fail, even though no payload is read here.
if (end != totalFileSize) return dec;
dec.status = PackageReadability::Readable;
dec.manifest = std::move(*manifest);
dec.layout = std::move(layout);
dec.prefixSize = r.pos;
return dec;
}
std::optional<std::uint64_t> requiredPrefixSize(const std::vector<std::uint8_t>& bytes) {
if (bytes.size() < kSemverAt) return kSemverAt;
if (!magicMatches(bytes)) return std::nullopt;
const auto verdict = classifyPackageVersion(u32At(bytes, 4), u32At(bytes, 8));
if (verdict == PackageReadability::Malformed) return std::nullopt;
const std::uint32_t semverLen = u32At(bytes, kSemverLenAt);
if (semverLen > kMaxWriterVersionBytes) return std::nullopt;
const std::uint64_t throughSemver = kSemverAt + semverLen;
if (verdict == PackageReadability::TooNew) return throughSemver;
if (bytes.size() < throughSemver + 4) return throughSemver + 4;
const std::uint32_t manifestLen = u32At(bytes, static_cast<std::size_t>(throughSemver));
if (manifestLen > kMaxManifestBytes) return std::nullopt;
return throughSemver + 4 + manifestLen;
}
} // namespace reasampler::package
+71
View File
@@ -0,0 +1,71 @@
#pragma once
// bank_package — RSBK framing and layout arithmetic: header encode, prefix
// decode, and the ordered {name, offset, length} entry layout. Framing only,
// never a payload: this module never holds, copies, or hashes an entry's audio
// — the shell streams payloads one at a time against the layout produced here.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/package/package_format.h"
#include "core/package/package_manifest.h"
namespace reasampler::package {
// Where one payload sits in the finished package file (absolute byte offset).
struct PackageEntrySpan {
std::string name;
std::uint64_t offset = 0;
std::uint64_t length = 0;
bool operator==(const PackageEntrySpan& o) const {
return name == o.name && offset == o.offset && length == o.length;
}
};
// The write side's product: the prefix bytes (magic through manifest, written
// verbatim as the file's head), the layout to stream each payload at, and the
// finished file's exact size — what the shell verifies after the last append.
struct EncodedPackage {
std::vector<std::uint8_t> prefix;
std::vector<PackageEntrySpan> layout;
std::uint64_t totalSize = 0;
};
// Encodes the package prefix for `m`, stamping this build's version pair and
// version::stampVersion() as the writer semver. nullopt when the manifest
// cannot be represented (serializeManifest's rejections) — refused on encode so
// an undecodable package is never written.
std::optional<EncodedPackage> encodePackage(const PackageManifest& m);
// The read side's product. header is meaningful for Readable and TooNew (a
// refusal must still name the writer); manifest, layout, and prefixSize only
// for Readable — TooNew produces NO manifest, so a refused decode cannot
// half-succeed.
struct DecodedPackage {
PackageReadability status = PackageReadability::Malformed;
PackageHeader header;
PackageManifest manifest;
std::vector<PackageEntrySpan> layout;
std::uint64_t prefixSize = 0;
};
// Decodes a package's leading bytes. `totalFileSize` is the on-disk size the
// caller observed: decode proves prefix + payload lengths equal it exactly, so
// a truncated or garbage-extended file is Malformed even though the payloads
// themselves are never read here. `prefix` may be the whole file or any head of
// it that requiredPrefixSize accepted. Error signaled, never UB.
DecodedPackage decodePackage(const std::vector<std::uint8_t>& prefix,
std::uint64_t totalFileSize);
// How many leading bytes decodePackage needs. May grow as bytes arrive: with
// fewer than the returned count on hand, read to that count and ask again.
// For a TooNew package it stops at the frozen region (through the writer
// semver) — field positions beyond it belong to the newer format and are not
// trusted. nullopt: these bytes can never frame a package (bad magic,
// incoherent versions, an over-cap length field) — stop reading.
std::optional<std::uint64_t> requiredPrefixSize(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler::package
+23
View File
@@ -0,0 +1,23 @@
#include "core/package/package_format.h"
namespace reasampler::package {
PackageReadability classifyPackageVersion(std::uint32_t formatVersion,
std::uint32_t minReaderVersion) {
if (formatVersion == 0 || minReaderVersion == 0) return PackageReadability::Malformed;
if (minReaderVersion > formatVersion) return PackageReadability::Malformed;
if (minReaderVersion > kPackageFormatVersion) return PackageReadability::TooNew;
return PackageReadability::Readable;
}
bool isValidEntryName(const std::string& name) {
if (name.empty() || name.size() > kMaxEntryNameBytes) return false;
if (name == ".") return false;
if (name.find("..") != std::string::npos) return false;
for (char c : name) {
if (c == '/' || c == '\\' || c == ':') return false;
}
return true;
}
} // namespace reasampler::package
+80
View File
@@ -0,0 +1,80 @@
#pragma once
// package_format — the RSBK bank-package contract: magic, the version ladder,
// the readability classification, and the entry-name rule. Pure: standard
// library only. The framing codec that acts on this contract is bank_package;
// the manifest grammar is package_manifest.
#include <cstdint>
#include <string>
namespace reasampler::package {
// Version ladder for the RSBK container (read-and-validate, like the origin
// ledger's "v"):
//
// format 1 (current) magic "RSBK" | u32 formatVersion | u32 minReaderVersion
// | u32 len + writer semver | u32 len + JSON manifest
// | payloads concatenated in manifest entry order.
// All integers little-endian.
//
// Two integers, two jobs: formatVersion is what the writer emitted (monotonic,
// bumped on ANY change); minReaderVersion is the oldest reader that can read the
// package safely (bumped only on a STRUCTURAL change — a field's meaning shifts,
// a section is removed, framing moves; an additive change — a new optional
// manifest key, a new enum value with a defined degrade — leaves it alone). The
// reader's whole rule: read iff minReaderVersion <= kPackageFormatVersion.
// formatVersion beyond that is message text and log material only.
//
// FROZEN FOR ALL FUTURE VERSIONS: the fields through the writer semver. A
// too-new package must still yield the writer's version so the refusal can name
// what to install — a structural change may rearrange anything after the semver,
// never before it.
inline constexpr char kPackageMagic[4] = {'R', 'S', 'B', 'K'};
inline constexpr std::uint32_t kPackageFormatVersion = 1;
inline constexpr std::uint32_t kPackageMinReaderVersion = 1;
// Hostile-input allocation caps (error signaled, never a multi-gigabyte
// allocation off a forged length field). Generous against real content: a
// semver is ~10 bytes; a manifest for hundreds of samples is well under 1 MB.
inline constexpr std::uint32_t kMaxWriterVersionBytes = 64;
inline constexpr std::uint32_t kMaxManifestBytes = 64u * 1024u * 1024u;
inline constexpr std::size_t kMaxEntryNameBytes = 255;
// The three-way read verdict (the FutureVersion precedent): TooNew refuses the
// whole package before anything is produced; Malformed is a header no honest
// writer emits. Also the status of a full prefix decode in bank_package.
enum class PackageReadability {
Readable,
TooNew,
Malformed,
};
// Classify a stored header pair against THIS build's ladder. minReaderVersion
// above kPackageFormatVersion is TooNew; a zero version or minReader >
// formatVersion is Malformed (a writer cannot require a reader newer than what
// it wrote).
PackageReadability classifyPackageVersion(std::uint32_t formatVersion,
std::uint32_t minReaderVersion);
// The entry-name rule that makes path expression structurally impossible: a
// bare file name only. Rejects empty, ".", any ".." occurrence, any '/', '\\'
// or ':' (which also bans every absolute form — drive, UNC, rooted), and names
// over kMaxEntryNameBytes. Enforced on encode AND decode by package_manifest.
bool isValidEntryName(const std::string& name);
// The fixed header, informational semver included. writerVersion is
// version::stampVersion() on the write side — it exists so a TooNew refusal can
// tell the user which build to install; it never gates.
struct PackageHeader {
std::uint32_t formatVersion = kPackageFormatVersion;
std::uint32_t minReaderVersion = kPackageMinReaderVersion;
std::string writerVersion;
bool operator==(const PackageHeader& o) const {
return formatVersion == o.formatVersion &&
minReaderVersion == o.minReaderVersion &&
writerVersion == o.writerVersion;
}
};
} // namespace reasampler::package
+201
View File
@@ -0,0 +1,201 @@
#include "core/package/package_manifest.h"
#include <utility>
#include "core/json/json.h"
#include "core/package/package_format.h"
// Duplicate entry names are rejected in both directions: two payloads landing
// on one destination name is incoherent, and on import it would be a silent
// overwrite. Sample-id rules (remap, collision) are deliberately NOT enforced
// here — they are the import plan's decisions, not the codec's.
namespace reasampler::package {
namespace {
using json::numToStr;
using ObjWriter = json::Writer;
bool duplicateName(const std::vector<PackageEntry>& entries) {
for (std::size_t i = 0; i < entries.size(); ++i)
for (std::size_t j = i + 1; j < entries.size(); ++j)
if (entries[i].fileName == entries[j].fileName) return true;
return false;
}
// The one-sample BankModel image of `s` — bank_model's own writer, verbatim, so
// the per-sample shape has exactly one owner. nullopt when add() would reject
// the record (its guards are the format's guards too).
std::optional<std::string> nestSample(const model::Sample& s) {
model::BankModel one;
if (one.add(s) != model::AddResult::Added) return std::nullopt;
return one.serialize();
}
} // namespace
bool PackageEntry::operator==(const PackageEntry& o) const {
return fileName == o.fileName && byteLength == o.byteLength &&
byteHash == o.byteHash && sample == o.sample;
}
bool PackageManifest::operator==(const PackageManifest& o) const {
return bankDisplayName == o.bankDisplayName && exportTimestamp == o.exportTimestamp &&
entries == o.entries && slots == o.slots;
}
std::optional<std::string> serializeManifest(const PackageManifest& m) {
for (const auto& e : m.entries)
if (!isValidEntryName(e.fileName)) return std::nullopt;
if (duplicateName(m.entries)) return std::nullopt;
std::string out;
{
ObjWriter root(out);
root.keyStr("bankName", m.bankDisplayName);
root.keyRaw("exported", numToStr(m.exportTimestamp));
root.keyBegin("entries");
out += '[';
for (std::size_t i = 0; i < m.entries.size(); ++i) {
const auto& e = m.entries[i];
auto nested = nestSample(e.sample);
if (!nested) return std::nullopt;
if (i) out += ',';
ObjWriter w(out);
w.keyStr("name", e.fileName);
// byteLength rides as a signed decimal; 2^63 bytes is beyond any file.
w.keyRaw("length", numToStr(static_cast<std::int64_t>(e.byteLength)));
w.keyStr("hash", e.byteHash);
w.keyRaw("index", *nested);
}
out += ']';
root.keyBegin("slots");
out += m.slots.serialize();
} // root closes here (NRVO note in json::Writer)
return out;
}
namespace {
// Mirrors bank_book_json's private slots parser: [{id, slot}, ...] pairs handed
// to SlotMap::fromEntries, which owns the defensive repair rules.
bool parseSlots(json::Reader& r, model::SlotMap& out) {
std::vector<std::pair<std::string, int>> pairs;
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) {
out = model::SlotMap::fromEntries(pairs);
return true;
}
do {
if (!r.consume('{')) return false;
std::string id;
int slot = 0;
bool haveId = false, haveSlot = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "id") { if (!r.parseString(id)) return false; haveId = true; }
else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; }
else { if (!r.skipValue()) return false; }
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveId || !haveSlot) return false;
pairs.emplace_back(std::move(id), slot);
} while (r.consume(','));
if (!r.consume(']')) return false;
out = model::SlotMap::fromEntries(pairs);
return true;
}
bool parseEntry(json::Reader& r, PackageEntry& e) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return false; // an entry needs all four fields
bool haveName = false, haveLength = false, haveHash = false, haveSample = false;
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "name") {
if (!r.parseString(e.fileName)) return false;
haveName = true;
} else if (key == "length") {
std::int64_t v = 0;
if (!r.parseInt64(v)) return false;
if (v < 0) return false;
e.byteLength = static_cast<std::uint64_t>(v);
haveLength = true;
} else if (key == "hash") {
if (!r.parseString(e.byteHash)) return false;
haveHash = true;
} else if (key == "index") {
std::string raw;
if (!r.captureValue(raw)) return false;
auto idx = model::BankModel::deserialize(raw);
// Exactly one sample: add()'s silent drop (rejected record) or a
// multi-sample blob both fail the entry rather than half-parse.
if (!idx || idx->size() != 1) return false;
e.sample = idx->all().front();
haveSample = true;
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveName || !haveLength || !haveHash || !haveSample) return false;
return isValidEntryName(e.fileName);
}
bool parseManifest(json::Reader& r, PackageManifest& m) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true; // empty object: a valid empty manifest
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "bankName") {
if (!r.parseString(m.bankDisplayName)) return false;
} else if (key == "exported") {
if (!r.parseInt64(m.exportTimestamp)) return false;
} else if (key == "entries") {
if (!r.consume('[')) return false;
r.skipWs();
if (!r.consume(']')) {
do {
PackageEntry e;
if (!parseEntry(r, e)) return false;
m.entries.push_back(std::move(e));
} while (r.consume(','));
if (!r.consume(']')) return false;
}
} else if (key == "slots") {
if (!parseSlots(r, m.slots)) return false;
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
r.skipWs();
if (!r.eof()) return false; // trailing garbage
return !duplicateName(m.entries);
}
} // namespace
std::optional<PackageManifest> deserializeManifest(const std::string& json) {
PackageManifest m;
json::Reader r(json);
if (!parseManifest(r, m)) return std::nullopt;
return m;
}
} // namespace reasampler::package
+56
View File
@@ -0,0 +1,56 @@
#pragma once
// package_manifest — the RSBK manifest model and its JSON codec. Per-sample
// shape is NOT owned here: each entry nests a one-sample BankModel blob emitted
// by bank_model's own writer (the bank_book_json precedent), so a future Sample
// field reaches packages for free. Pure: no filesystem, no host types.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/model/bank_model.h"
#include "core/model/slot_map.h"
namespace reasampler::package {
// One payload's transport record. `byteHash` is capture::hashBytes over the
// payload's raw bytes — the whole-file digest, deliberately NOT hashWavContent
// (which skips chunks and so cannot answer "did these bytes survive the trip").
// FNV-1a: a corruption detector, not a cryptographic checksum. The codec only
// carries the digest; hashing happens where the payload is streamed (shell).
struct PackageEntry {
std::string fileName; // bare name inside the package (isValidEntryName)
std::uint64_t byteLength = 0;
std::string byteHash;
model::Sample sample;
bool operator==(const PackageEntry& o) const;
};
// Everything the manifest carries besides the payloads: informational envelope
// (source bank name, export moment), the entries, and the bank's display
// positions (a bank's arrangement is part of what the user built).
struct PackageManifest {
std::string bankDisplayName;
std::int64_t exportTimestamp = 0; // unix epoch seconds
std::vector<PackageEntry> entries;
model::SlotMap slots;
bool operator==(const PackageManifest& o) const;
};
// Emits the manifest JSON. nullopt when the manifest cannot be represented:
// an invalid or duplicate entry name, or a sample record BankModel itself would
// reject (empty id, absolute path) — refusing on encode so an undecodable
// package is never written.
std::optional<std::string> serializeManifest(const PackageManifest& m);
// Parses manifest JSON (nullopt on malformed input, never UB). Unknown keys are
// skipped at every level, so an additive newer manifest still parses. Rejects
// what encode rejects — entry names are validated on BOTH directions because a
// package can arrive from anywhere — plus a missing per-entry field or a
// negative length.
std::optional<PackageManifest> deserializeManifest(const std::string& json);
} // namespace reasampler::package
+344
View File
@@ -0,0 +1,344 @@
// Standalone tests for reasampler::package::bank_package — no REAPER, no test
// framework. Byte-level suites hand-roll RSBK images with wire::putLE rather
// than calling encodePackage, so a layout regression in encode cannot hide from
// decode (the two sides are pinned against each other AND against raw bytes).
#include "../src/core/package/bank_package.h"
#include <cstdio>
#include <cstdint>
#include <string>
#include <vector>
#include "../src/core/version/app_version.h"
#include "../src/core/wire/bytes.h"
using namespace reasampler::package;
using namespace reasampler::model;
namespace wire = reasampler::wire;
namespace version = reasampler::version;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- fixtures ----------------------------------------------------------------
// Every Sample field populated, every optional PRESENT.
static Sample fullSample() {
Sample s;
s.id = "smp-full";
s.displayName = "Kick (wet)";
s.relativePath = "reasampler_bank/kick.wav";
s.sourceMode = SourceMode::RazorArea;
s.sourceRange = {1.25, 3.5, 480.0, 1920.0};
s.trackGuids = {"{AAA}", "{BBB}"};
s.wetDry = 0.75;
s.channelCount = 2;
s.sampleRate = 48000;
s.lengthSeconds = 2.25;
s.lengthBeats = 4.5;
s.captureTempo = 120.5;
s.captureTimeSigNum = 7;
s.captureTimeSigDenom = 8;
s.key = "F#m";
s.rootNote = 60;
s.loop = LoopPoints{100, 4800};
s.levels = {-0.3, -12.7, -14.0};
s.clipped = true;
s.tier = Tier::Archive;
s.contentHash = "W0123456789abcdef";
s.provenance = Provenance{"smp-parent", "fx-snapshot"};
s.createdTimestamp = 1754000000;
return s;
}
// Every Sample optional ABSENT (key, rootNote, loop, provenance).
static Sample bareSample() {
Sample s;
s.id = "smp-bare";
s.displayName = "Snare";
s.relativePath = "reasampler_bank/snare.wav";
s.sourceMode = SourceMode::Realtime;
s.contentHash = "Wfedcba9876543210";
s.createdTimestamp = 1754000001;
return s;
}
static PackageManifest fixture(std::uint64_t len0, std::uint64_t len1) {
PackageManifest m;
m.bankDisplayName = "Drums \"live\"";
m.exportTimestamp = 1754100000;
m.entries.push_back({"kick.wav", len0, "1111222233334444", fullSample()});
m.entries.push_back({"snare.wav", len1, "5555666677778888", bareSample()});
m.slots.append("smp-full");
m.slots.append("smp-bare");
return m;
}
// A hand-rolled RSBK image: frozen region + a raw tail (manifest framing or
// deliberate garbage), independent of encodePackage.
static std::vector<std::uint8_t> rawHeader(std::uint32_t fv, std::uint32_t mv,
const std::string& semver) {
std::vector<std::uint8_t> out;
out.insert(out.end(), kPackageMagic, kPackageMagic + 4);
wire::putLE(out, fv);
wire::putLE(out, mv);
wire::putLE(out, static_cast<std::uint32_t>(semver.size()));
out.insert(out.end(), semver.begin(), semver.end());
return out;
}
static void appendManifest(std::vector<std::uint8_t>& out, const std::string& json) {
wire::putLE(out, static_cast<std::uint32_t>(json.size()));
out.insert(out.end(), json.begin(), json.end());
}
// One-entry manifest JSON with `extra` spliced in as additional root content
// ("" for none) — for images a current writer would never emit.
static std::string handManifest(const std::string& name, int length,
const std::string& extra) {
return std::string("{") + extra +
"\"entries\":[{\"name\":\"" + name +
"\",\"length\":" + std::to_string(length) + ",\"hash\":\"h\","
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\","
"\"relativePath\":\"bank/a.wav\"}]}}]}";
}
// --- encode / decode round trip ----------------------------------------------
static void testEncodeDecodeRoundTrip() {
const PackageManifest m = fixture(96000, 0); // a zero-length payload is legal
auto enc = encodePackage(m);
CHECK(enc.has_value());
// Layout arithmetic: payloads start at the prefix end, in manifest order.
CHECK(enc->layout.size() == 2);
CHECK(enc->layout[0].name == "kick.wav");
CHECK(enc->layout[0].offset == enc->prefix.size());
CHECK(enc->layout[0].length == 96000);
CHECK(enc->layout[1].offset == enc->prefix.size() + 96000);
CHECK(enc->layout[1].length == 0);
CHECK(enc->totalSize == enc->prefix.size() + 96000);
// decodePackage(encodePackage(x)) == x — payloads are never read by the
// codec, so the prefix plus the true total size is the whole input.
const DecodedPackage dec = decodePackage(enc->prefix, enc->totalSize);
CHECK(dec.status == PackageReadability::Readable);
CHECK(dec.manifest == m);
CHECK(dec.layout == enc->layout);
CHECK(dec.prefixSize == enc->prefix.size());
// The header stamps this build's ladder pair and its informational semver.
CHECK(dec.header.formatVersion == kPackageFormatVersion);
CHECK(dec.header.minReaderVersion == kPackageMinReaderVersion);
CHECK(dec.header.writerVersion == version::stampVersion());
}
static void testEncodeRefusesWhatManifestRefuses() {
PackageManifest m = fixture(1, 1);
m.entries[0].fileName = "../evil.wav";
CHECK(!encodePackage(m).has_value());
}
// --- truncation: every byte offset -------------------------------------------
static void testTruncationAtEveryByteOffsetIsMalformed() {
const PackageManifest m = fixture(3, 5);
auto enc = encodePackage(m);
CHECK(enc.has_value());
// The complete on-disk image: prefix + both payloads.
std::vector<std::uint8_t> file = enc->prefix;
for (std::uint8_t b : {1, 2, 3, 10, 20, 30, 40, 50}) file.push_back(b);
CHECK(file.size() == enc->totalSize);
CHECK(decodePackage(file, file.size()).status == PackageReadability::Readable);
for (std::size_t cut = 0; cut < file.size(); ++cut) {
const std::vector<std::uint8_t> truncated(file.begin(), file.begin() + cut);
const DecodedPackage dec = decodePackage(truncated, truncated.size());
if (dec.status != PackageReadability::Malformed) {
std::printf("FAIL: truncation at %zu not Malformed\n", cut);
++g_fail;
break;
}
// A refused decode must not half-succeed at any cut either.
CHECK(dec.manifest.entries.empty());
CHECK(dec.layout.empty());
}
// One byte extra (trailing garbage) is as Malformed as one byte missing.
std::vector<std::uint8_t> extended = file;
extended.push_back(0);
CHECK(decodePackage(extended, extended.size()).status == PackageReadability::Malformed);
// And a file size that disagrees with the same bytes.
CHECK(decodePackage(file, file.size() + 1).status == PackageReadability::Malformed);
CHECK(decodePackage(file, file.size() - 1).status == PackageReadability::Malformed);
}
// --- version ladder: TooNew refuses whole ------------------------------------
static void testTooNewProducesNoManifest() {
// A future structural format: only the frozen region is trustworthy, so the
// tail is deliberate garbage that would crash a parser that kept reading.
std::vector<std::uint8_t> bytes = rawHeader(9, 9, "9.9.9");
for (int i = 0; i < 32; ++i) bytes.push_back(0xFF);
const DecodedPackage dec = decodePackage(bytes, bytes.size());
CHECK(dec.status == PackageReadability::TooNew);
// The refusal message's three facts survive...
CHECK(dec.header.formatVersion == 9);
CHECK(dec.header.minReaderVersion == 9);
CHECK(dec.header.writerVersion == "9.9.9");
// ...and nothing else is produced: no manifest, no layout, no half-success.
CHECK(dec.manifest.entries.empty());
CHECK(dec.manifest == PackageManifest{});
CHECK(dec.layout.empty());
CHECK(dec.prefixSize == 0);
// Boundary: minReader exactly one past this build.
auto boundary = rawHeader(kPackageFormatVersion + 1, kPackageFormatVersion + 1, "2.0.0");
CHECK(decodePackage(boundary, boundary.size()).status == PackageReadability::TooNew);
// A TooNew header truncated inside the frozen region cannot name the
// writer, so it is Malformed, not an unactionable refusal.
std::vector<std::uint8_t> cut = rawHeader(9, 9, "9.9.9");
cut.resize(18); // mid-semver
CHECK(decodePackage(cut, cut.size()).status == PackageReadability::Malformed);
}
// --- version ladder: additive forward compatibility --------------------------
// The reason two integers exist: a NEWER formatVersion whose minReaderVersion
// still reaches back to this build must read, with its unknown keys skipped.
static void testNewerAdditiveFormatReads() {
const std::string manifest = handManifest(
"a.wav", 4,
"\"instrumentState\":{\"future\":[1,2,3]},\"anotherNewKey\":\"x\",");
std::vector<std::uint8_t> bytes =
rawHeader(kPackageFormatVersion + 1, kPackageMinReaderVersion, "1.9.0");
appendManifest(bytes, manifest);
const std::uint64_t total = bytes.size() + 4; // the one entry's payload
const DecodedPackage dec = decodePackage(bytes, total);
CHECK(dec.status == PackageReadability::Readable);
CHECK(dec.header.formatVersion == kPackageFormatVersion + 1);
CHECK(dec.header.writerVersion == "1.9.0");
CHECK(dec.manifest.entries.size() == 1);
CHECK(dec.manifest.entries[0].fileName == "a.wav");
CHECK(dec.manifest.entries[0].sample.id == "s1");
CHECK(dec.layout.size() == 1);
CHECK(dec.layout[0].offset == bytes.size());
CHECK(dec.layout[0].length == 4);
}
// --- hostile headers ---------------------------------------------------------
static void testHostileHeadersAreMalformed() {
// Wrong magic.
std::vector<std::uint8_t> bad = rawHeader(1, 1, "1.0.0");
appendManifest(bad, "{}");
bad[0] = 'Z';
CHECK(decodePackage(bad, bad.size()).status == PackageReadability::Malformed);
// Incoherent version pairs (the classify rules, proven through the framing).
for (auto [fv, mv] : {std::pair<std::uint32_t, std::uint32_t>{0, 0}, {0, 1},
{1, 0}, {1, 2}}) {
std::vector<std::uint8_t> b = rawHeader(fv, mv, "1.0.0");
appendManifest(b, "{}");
CHECK(decodePackage(b, b.size()).status == PackageReadability::Malformed);
}
// A forged semver length over the cap must not read past the buffer.
std::vector<std::uint8_t> overSemver;
overSemver.insert(overSemver.end(), kPackageMagic, kPackageMagic + 4);
wire::putLE(overSemver, std::uint32_t{1});
wire::putLE(overSemver, std::uint32_t{1});
wire::putLE(overSemver, kMaxWriterVersionBytes + 1);
CHECK(decodePackage(overSemver, overSemver.size()).status ==
PackageReadability::Malformed);
// A forged manifest length over the cap: refused before any allocation.
std::vector<std::uint8_t> overManifest = rawHeader(1, 1, "1.0.0");
wire::putLE(overManifest, kMaxManifestBytes + 1);
CHECK(decodePackage(overManifest, overManifest.size()).status ==
PackageReadability::Malformed);
// A manifest whose entry name expresses a path: rejected on decode even
// though no current encoder would write it.
std::vector<std::uint8_t> traversal = rawHeader(1, 1, "1.0.0");
appendManifest(traversal, handManifest("../evil.wav", 4, ""));
CHECK(decodePackage(traversal, traversal.size() + 4).status ==
PackageReadability::Malformed);
}
// --- requiredPrefixSize ------------------------------------------------------
static void testRequiredPrefixSizeGrowsToTheFullPrefix() {
auto enc = encodePackage(fixture(3, 5));
CHECK(enc.has_value());
const auto& prefix = enc->prefix;
// Empty: the fixed region first.
CHECK(requiredPrefixSize({}) == std::uint64_t{16});
// With the fixed region: asks through the semver + manifest-length field.
const std::string& semver = version::stampVersion();
std::vector<std::uint8_t> first16(prefix.begin(), prefix.begin() + 16);
CHECK(requiredPrefixSize(first16) == std::uint64_t{16 + semver.size() + 4});
// With that much: the full prefix size. And the answer is a fixpoint.
std::vector<std::uint8_t> upToManifestLen(
prefix.begin(), prefix.begin() + 20 + static_cast<long>(semver.size()));
CHECK(requiredPrefixSize(upToManifestLen) == std::uint64_t{prefix.size()});
CHECK(requiredPrefixSize(prefix) == std::uint64_t{prefix.size()});
// The returned count is exactly enough for decodePackage.
CHECK(decodePackage(prefix, enc->totalSize).status == PackageReadability::Readable);
}
static void testRequiredPrefixSizeRefusals() {
// Bad magic: stop reading.
std::vector<std::uint8_t> bad(16, 0);
CHECK(!requiredPrefixSize(bad).has_value());
// Incoherent versions: stop reading.
std::vector<std::uint8_t> zeroed = rawHeader(0, 0, "1.0.0");
CHECK(!requiredPrefixSize(zeroed).has_value());
// TooNew: asks only through the frozen region — the manifest-length field
// belongs to the newer format and is never trusted.
std::vector<std::uint8_t> tooNew = rawHeader(9, 9, "9.9.9");
for (int i = 0; i < 8; ++i) tooNew.push_back(0xFF); // garbage where M would be
CHECK(requiredPrefixSize(tooNew) == std::uint64_t{16 + 5});
// Oversize length fields: stop reading.
std::vector<std::uint8_t> overSemver;
overSemver.insert(overSemver.end(), kPackageMagic, kPackageMagic + 4);
wire::putLE(overSemver, std::uint32_t{1});
wire::putLE(overSemver, std::uint32_t{1});
wire::putLE(overSemver, kMaxWriterVersionBytes + 1);
CHECK(!requiredPrefixSize(overSemver).has_value());
std::vector<std::uint8_t> overManifest = rawHeader(1, 1, "1.0.0");
wire::putLE(overManifest, kMaxManifestBytes + 1);
CHECK(!requiredPrefixSize(overManifest).has_value());
}
int main() {
testEncodeDecodeRoundTrip();
testEncodeRefusesWhatManifestRefuses();
testTruncationAtEveryByteOffsetIsMalformed();
testTooNewProducesNoManifest();
testNewerAdditiveFormatReads();
testHostileHeadersAreMalformed();
testRequiredPrefixSizeRefusals();
testRequiredPrefixSizeGrowsToTheFullPrefix();
if (g_fail == 0) {
std::printf("bank_package_tests: all passed\n");
return 0;
}
std::printf("bank_package_tests: %d failure(s)\n", g_fail);
return 1;
}
+96
View File
@@ -0,0 +1,96 @@
// Standalone tests for reasampler::package's format contract — no REAPER, no
// test framework. Pins the version-ladder classification (both integers, every
// branch) and the entry-name rule that makes path expression structurally
// impossible in a package.
#include "../src/core/package/package_format.h"
#include <cstdio>
#include <string>
using namespace reasampler::package;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- classifyPackageVersion --------------------------------------------------
static void testClassifyReadable() {
CHECK(classifyPackageVersion(kPackageFormatVersion, kPackageMinReaderVersion) ==
PackageReadability::Readable);
// The additive-forward-compat direction: a newer writer whose minReader
// still reaches back to this build reads fine.
CHECK(classifyPackageVersion(kPackageFormatVersion + 5, kPackageMinReaderVersion) ==
PackageReadability::Readable);
// Boundary: minReader exactly this build's format version.
CHECK(classifyPackageVersion(kPackageFormatVersion + 1, kPackageFormatVersion) ==
PackageReadability::Readable);
}
static void testClassifyTooNew() {
// Boundary: one past this build's format version refuses.
CHECK(classifyPackageVersion(kPackageFormatVersion + 1, kPackageFormatVersion + 1) ==
PackageReadability::TooNew);
CHECK(classifyPackageVersion(99, 42) == PackageReadability::TooNew);
}
static void testClassifyMalformed() {
// Zero versions: no honest writer emits them (the ladder starts at 1).
CHECK(classifyPackageVersion(0, 0) == PackageReadability::Malformed);
CHECK(classifyPackageVersion(1, 0) == PackageReadability::Malformed);
CHECK(classifyPackageVersion(0, 1) == PackageReadability::Malformed);
// A writer cannot require a reader newer than what it wrote.
CHECK(classifyPackageVersion(1, 2) == PackageReadability::Malformed);
// Incoherence outranks TooNew: even with both above this build, minReader >
// formatVersion is Malformed, not a refusal message.
CHECK(classifyPackageVersion(5, 9) == PackageReadability::Malformed);
}
// --- isValidEntryName --------------------------------------------------------
static void testEntryNameAccepts() {
CHECK(isValidEntryName("kick.wav"));
CHECK(isValidEntryName("Snare 03 (wet).wav"));
CHECK(isValidEntryName("no-extension"));
CHECK(isValidEntryName(".hidden")); // a leading dot is a bare name
CHECK(isValidEntryName("a.b.c.wav")); // single dots are fine
CHECK(isValidEntryName(std::string(kMaxEntryNameBytes, 'x'))); // at the cap
}
static void testEntryNameRejectsSeparatorsAndDots() {
CHECK(!isValidEntryName(""));
CHECK(!isValidEntryName("."));
CHECK(!isValidEntryName(".."));
CHECK(!isValidEntryName("..\\evil.wav"));
CHECK(!isValidEntryName("../evil.wav"));
CHECK(!isValidEntryName("a..b.wav")); // any ".." occurrence rejects
CHECK(!isValidEntryName("dir/inner.wav"));
CHECK(!isValidEntryName("dir\\inner.wav"));
CHECK(!isValidEntryName("/rooted.wav"));
CHECK(!isValidEntryName("\\rooted.wav"));
}
static void testEntryNameRejectsAbsolutePrefixes() {
CHECK(!isValidEntryName("C:\\abs.wav"));
CHECK(!isValidEntryName("C:/abs.wav"));
CHECK(!isValidEntryName("c:relative-to-drive.wav")); // ':' bans drive forms
CHECK(!isValidEntryName("\\\\server\\share.wav")); // UNC
CHECK(!isValidEntryName(std::string(kMaxEntryNameBytes + 1, 'x'))); // over cap
}
int main() {
testClassifyReadable();
testClassifyTooNew();
testClassifyMalformed();
testEntryNameAccepts();
testEntryNameRejectsSeparatorsAndDots();
testEntryNameRejectsAbsolutePrefixes();
if (g_fail == 0) {
std::printf("package_format_tests: all passed\n");
return 0;
}
std::printf("package_format_tests: %d failure(s)\n", g_fail);
return 1;
}
+248
View File
@@ -0,0 +1,248 @@
// Standalone tests for reasampler::package's manifest codec — no REAPER, no
// test framework. The round-trip fixture exercises every manifest field and
// every Sample optional in both present and absent states; the rejection suite
// pins the entry-name rule on encode AND decode.
#include "../src/core/package/package_manifest.h"
#include <cstdio>
#include <optional>
#include <string>
using namespace reasampler::package;
using namespace reasampler::model;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- fixtures ----------------------------------------------------------------
// Every Sample field populated, every optional PRESENT.
static Sample fullSample() {
Sample s;
s.id = "smp-full";
s.displayName = "Kick (wet)";
s.relativePath = "reasampler_bank/kick.wav";
s.sourceMode = SourceMode::RazorArea;
s.sourceRange = {1.25, 3.5, 480.0, 1920.0};
s.trackGuids = {"{AAA}", "{BBB}"};
s.wetDry = 0.75;
s.channelCount = 2;
s.sampleRate = 48000;
s.lengthSeconds = 2.25;
s.lengthBeats = 4.5;
s.captureTempo = 120.5;
s.captureTimeSigNum = 7;
s.captureTimeSigDenom = 8;
s.key = "F#m";
s.rootNote = 60;
s.loop = LoopPoints{100, 4800};
s.levels = {-0.3, -12.7, -14.0};
s.clipped = true;
s.tier = Tier::Archive;
s.contentHash = "W0123456789abcdef";
s.provenance = Provenance{"smp-parent", "fx-snapshot"};
s.createdTimestamp = 1754000000;
return s;
}
// Every Sample optional ABSENT (key, rootNote, loop, provenance).
static Sample bareSample() {
Sample s;
s.id = "smp-bare";
s.displayName = "Snare";
s.relativePath = "reasampler_bank/snare.wav";
s.sourceMode = SourceMode::Realtime;
s.contentHash = "Wfedcba9876543210";
s.createdTimestamp = 1754000001;
return s;
}
static PackageManifest fixture() {
PackageManifest m;
m.bankDisplayName = "Drums \"live\""; // escaping exercised
m.exportTimestamp = 1754100000;
m.entries.push_back({"kick.wav", 96000, "1111222233334444", fullSample()});
m.entries.push_back({"snare.wav", 0, "5555666677778888", bareSample()}); // 0-length legal
m.slots.append("smp-full");
m.slots.append("smp-bare");
m.slots.remove("smp-full"); // leaves a gap: slots round-trip must keep it
return m;
}
// --- round trip --------------------------------------------------------------
static void testRoundTripEveryField() {
const PackageManifest m = fixture();
auto json = serializeManifest(m);
CHECK(json.has_value());
auto back = deserializeManifest(*json);
CHECK(back.has_value());
CHECK(*back == m);
// Spot-check both optional states survived (== above proves it; these name
// the claim so a failure reads directly).
CHECK(back->entries[0].sample.loop.has_value());
CHECK(back->entries[0].sample.provenance.has_value());
CHECK(!back->entries[1].sample.key.has_value());
CHECK(!back->entries[1].sample.rootNote.has_value());
CHECK(back->slots.idAt(0).empty()); // the slot gap survived
CHECK(back->slots.slotOf("smp-bare") == 1);
}
static void testEmptyManifestRoundTrips() {
PackageManifest m;
auto json = serializeManifest(m);
CHECK(json.has_value());
auto back = deserializeManifest(*json);
CHECK(back.has_value());
CHECK(*back == m);
}
// --- forward compatibility ---------------------------------------------------
static void testUnknownKeysSkippedAtEveryLevel() {
// A future additive manifest: unknown keys at the root and inside an entry.
const std::string json =
"{\"bankName\":\"B\",\"exported\":7,"
"\"instrumentState\":{\"nested\":[1,2,{\"x\":\"y\"}]},"
"\"entries\":[{\"name\":\"a.wav\",\"length\":10,\"hash\":\"h\","
"\"futureField\":\"ignored\","
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\","
"\"relativePath\":\"bank/a.wav\"}]}}],"
"\"slots\":[],\"trailingUnknown\":null}";
auto m = deserializeManifest(json);
CHECK(m.has_value());
CHECK(m->bankDisplayName == "B");
CHECK(m->exportTimestamp == 7);
CHECK(m->entries.size() == 1);
CHECK(m->entries[0].fileName == "a.wav");
CHECK(m->entries[0].byteLength == 10);
CHECK(m->entries[0].sample.id == "s1");
}
// --- rejection: entry names, both directions ---------------------------------
static void testEncodeRejectsBadEntryName() {
for (const char* bad : {"..\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\a.wav", "", ".."}) {
PackageManifest m = fixture();
m.entries[0].fileName = bad;
CHECK(!serializeManifest(m).has_value());
}
}
static void testDecodeRejectsBadEntryName() {
for (const char* bad : {"..\\\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\\\a.wav", ".."}) {
// Hand-rolled JSON: a hostile package is not limited to what encode emits.
std::string json =
std::string("{\"entries\":[{\"name\":\"") + bad +
"\",\"length\":1,\"hash\":\"h\","
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\","
"\"relativePath\":\"bank/a.wav\"}]}}]}";
CHECK(!deserializeManifest(json).has_value());
}
}
static void testDuplicateEntryNamesRejectedBothWays() {
PackageManifest m = fixture();
m.entries[1].fileName = m.entries[0].fileName;
CHECK(!serializeManifest(m).has_value());
// Decode side, from a hand-built duplicate (a hostile package is not
// limited to what encode emits).
const std::string dup =
"{\"entries\":["
"{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}},"
"{\"name\":\"a.wav\",\"length\":2,\"hash\":\"i\","
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
CHECK(!deserializeManifest(dup).has_value());
}
// --- rejection: structural ---------------------------------------------------
static void testEncodeRejectsUnrepresentableSample() {
PackageManifest m = fixture();
m.entries[0].sample.id.clear(); // BankModel::add rejects an empty id
CHECK(!serializeManifest(m).has_value());
PackageManifest m2 = fixture();
m2.entries[0].sample.relativePath = "C:/abs/kick.wav"; // and an absolute path
CHECK(!serializeManifest(m2).has_value());
}
static void testDecodeRejectsMalformedShapes() {
CHECK(!deserializeManifest("").has_value());
CHECK(!deserializeManifest("not json").has_value());
CHECK(!deserializeManifest("[]").has_value());
CHECK(!deserializeManifest("{\"entries\":[{}]}").has_value()); // entry missing fields
// Missing one required entry field apiece.
CHECK(!deserializeManifest(
"{\"entries\":[{\"length\":1,\"hash\":\"h\","
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
.has_value());
CHECK(!deserializeManifest(
"{\"entries\":[{\"name\":\"a.wav\",\"hash\":\"h\","
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
.has_value());
CHECK(!deserializeManifest(
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,"
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
.has_value());
CHECK(!deserializeManifest(
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\"}]}").has_value());
// Negative length.
CHECK(!deserializeManifest(
"{\"entries\":[{\"name\":\"a.wav\",\"length\":-1,\"hash\":\"h\","
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
.has_value());
// A nested index that is not exactly one sample (zero and two).
CHECK(!deserializeManifest(
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
"\"index\":{\"version\":1,\"samples\":[]}}]}").has_value());
CHECK(!deserializeManifest(
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"},"
"{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}").has_value());
// A nested sample BankModel::add drops (absolute path) fails the entry —
// the silent drop must not half-parse into an empty index.
CHECK(!deserializeManifest(
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\","
"\"relativePath\":\"C:/abs.wav\"}]}}]}").has_value());
// An out-of-range enum inside the nested Sample blob fails the entry: the
// manifest defines no enum of its own, and bank_model's codec REJECTS an
// unknown sourceMode/tier rather than degrading — so growing one of those
// vocabularies is a minReaderVersion bump, not an additive change (see this
// directory's CLAUDE.md).
CHECK(!deserializeManifest(
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\","
"\"sourceMode\":99}]}}]}").has_value());
// Trailing garbage after the root object.
auto json = serializeManifest(fixture());
CHECK(json.has_value());
CHECK(!deserializeManifest(*json + "x").has_value());
// Truncation at a few JSON-level offsets (byte-level truncation of the whole
// package is bank_package's suite).
CHECK(!deserializeManifest(json->substr(0, json->size() / 2)).has_value());
CHECK(!deserializeManifest(json->substr(0, 1)).has_value());
}
int main() {
testRoundTripEveryField();
testEmptyManifestRoundTrips();
testUnknownKeysSkippedAtEveryLevel();
testEncodeRejectsBadEntryName();
testDecodeRejectsBadEntryName();
testDuplicateEntryNamesRejectedBothWays();
testEncodeRejectsUnrepresentableSample();
testDecodeRejectsMalformedShapes();
if (g_fail == 0) {
std::printf("package_manifest_tests: all passed\n");
return 0;
}
std::printf("package_manifest_tests: %d failure(s)\n", g_fail);
return 1;
}