Relabel post-manifest-parse failure as TooNew; refuse zero-length package entries at encode

An additively-tagged newer package that fails to parse now reports TooNew (with
writer semver) instead of unactionable Malformed. Format layer also refuses encoding
a zero-length entry, honoring the shell's appendPayload contract; both test-covered.
This commit is contained in:
2026-08-02 08:14:55 -04:00
parent e0b4ec2e21
commit 1aebf51938
6 changed files with 77 additions and 9 deletions
+35 -3
View File
@@ -110,7 +110,7 @@ static std::string handManifest(const std::string& name, int length,
// --- encode / decode round trip ----------------------------------------------
static void testEncodeDecodeRoundTrip() {
const PackageManifest m = fixture(96000, 0); // a zero-length payload is legal
const PackageManifest m = fixture(96000, 48000);
auto enc = encodePackage(m);
CHECK(enc.has_value());
@@ -120,8 +120,8 @@ static void testEncodeDecodeRoundTrip() {
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);
CHECK(enc->layout[1].length == 48000);
CHECK(enc->totalSize == enc->prefix.size() + 96000 + 48000);
// decodePackage(encodePackage(x)) == x — payloads are never read by the
// codec, so the prefix plus the true total size is the whole input.
@@ -141,6 +141,13 @@ static void testEncodeRefusesWhatManifestRefuses() {
PackageManifest m = fixture(1, 1);
m.entries[0].fileName = "../evil.wav";
CHECK(!encodePackage(m).has_value());
// A zero-length entry cannot round-trip through the shell's filesystem
// seam (src/shell/package's appendPayload refuses an empty payload) — the
// format layer must never produce one.
PackageManifest zeroLen = fixture(1, 1);
zeroLen.entries[1].byteLength = 0;
CHECK(!encodePackage(zeroLen).has_value());
}
// --- truncation: every byte offset -------------------------------------------
@@ -209,6 +216,30 @@ static void testTooNewProducesNoManifest() {
CHECK(decodePackage(cut, cut.size()).status == PackageReadability::Malformed);
}
// An additively-tagged package (fv > ours, minReader still within reach) whose
// manifest fails to parse: the header classifies Readable, so decode reads
// into the manifest and fails there. That failure must still report TooNew —
// the header is valid and already carries the writer's semver — not the
// unactionable Malformed a genuinely corrupt header produces.
static void testAdditiveUnparseableManifestIsTooNew() {
std::vector<std::uint8_t> bytes = rawHeader(kPackageFormatVersion + 1, kPackageMinReaderVersion, "1.9.0");
appendManifest(bytes, "not json");
const DecodedPackage dec = decodePackage(bytes, bytes.size());
CHECK(dec.status == PackageReadability::TooNew);
CHECK(dec.header.formatVersion == kPackageFormatVersion + 1);
CHECK(dec.header.minReaderVersion == kPackageMinReaderVersion);
CHECK(dec.header.writerVersion == "1.9.0");
CHECK(dec.manifest.entries.empty());
CHECK(dec.layout.empty());
// Same-version unparseable manifest stays Malformed: nothing "newer"
// excuses it, so this is not a blanket "unparseable == TooNew" rule.
std::vector<std::uint8_t> sameVersion =
rawHeader(kPackageFormatVersion, kPackageMinReaderVersion, "1.0.0");
appendManifest(sameVersion, "not json");
CHECK(decodePackage(sameVersion, sameVersion.size()).status == PackageReadability::Malformed);
}
// --- version ladder: additive forward compatibility --------------------------
// The reason two integers exist: a NEWER formatVersion whose minReaderVersion
@@ -335,6 +366,7 @@ int main() {
testEncodeRefusesWhatManifestRefuses();
testTruncationAtEveryByteOffsetIsMalformed();
testTooNewProducesNoManifest();
testAdditiveUnparseableManifestIsTooNew();
testNewerAdditiveFormatReads();
testHostileHeadersAreMalformed();
testRequiredPrefixSizeRefusals();
+12 -1
View File
@@ -64,7 +64,7 @@ static PackageManifest fixture() {
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.entries.push_back({"snare.wav", 48000, "5555666677778888", bareSample()});
m.slots.append("smp-full");
m.slots.append("smp-bare");
m.slots.remove("smp-full"); // leaves a gap: slots round-trip must keep it
@@ -173,6 +173,16 @@ static void testDuplicateEntriesKeyRejected() {
// --- rejection: structural ---------------------------------------------------
// A zero-length entry cannot round-trip through the shell's filesystem seam
// (src/shell/package's appendPayload refuses an empty payload) — the format
// layer must never produce one, so encode refuses it. Decode does not enforce
// this (a hostile/older package declaring one is not this codec's concern).
static void testEncodeRejectsZeroLengthEntry() {
PackageManifest m = fixture();
m.entries[0].byteLength = 0;
CHECK(!serializeManifest(m).has_value());
}
static void testEncodeRejectsUnrepresentableSample() {
PackageManifest m = fixture();
m.entries[0].sample.id.clear(); // BankModel::add rejects an empty id
@@ -253,6 +263,7 @@ int main() {
testDecodeRejectsBadEntryName();
testDuplicateEntryNamesRejectedBothWays();
testDuplicateEntriesKeyRejected();
testEncodeRejectsZeroLengthEntry();
testEncodeRejectsUnrepresentableSample();
testDecodeRejectsMalformedShapes();