Freeze the package compatibility corpus: real .rsbank bytes proving both ladder directions, every truncation site, and the round trip
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
// The corpus driven through both verbs — no REAPER, no framework. Where
|
||||
// test_package_compat asserts what the frozen bytes DECODE to, this file asserts what
|
||||
// the import and export verbs DO with them: the payload bytes survive a full
|
||||
// export -> import -> export, and every refusal in the corpus refuses before a planner
|
||||
// or a filesystem write is reached.
|
||||
|
||||
#include "../src/shell/package/import_landing.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../src/core/package/bank_package.h"
|
||||
#include "../src/shell/package/export_bank.h"
|
||||
#include "../src/shell/package/package_path.h"
|
||||
#include "../src/shell/persist/session.h"
|
||||
#include "package_fixtures.h"
|
||||
|
||||
using namespace reasampler;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static const char* kTag = "1754000000";
|
||||
static const char* kImportedBankId = "bank-imported";
|
||||
|
||||
// --- scratch project ---------------------------------------------------------
|
||||
|
||||
// One project directory per suite, torn down after, so no suite observes another's
|
||||
// bank folder.
|
||||
class Scratch {
|
||||
public:
|
||||
explicit Scratch(const std::string& name) {
|
||||
std::error_code ec;
|
||||
dir_ = pathToUtf8(fs::temp_directory_path(ec) /
|
||||
utf8Path("reasampler_compat_" + name));
|
||||
fs::remove_all(utf8Path(dir_), ec);
|
||||
fs::create_directories(utf8Path(dir_), ec);
|
||||
}
|
||||
~Scratch() {
|
||||
std::error_code ec;
|
||||
fs::remove_all(utf8Path(dir_), ec);
|
||||
}
|
||||
const std::string& projectDir() const { return dir_; }
|
||||
std::string bankDir() const { return package::bankFolderDir(dir_); }
|
||||
std::string importPath() const { return dir_ + "/in.rsbank"; }
|
||||
std::string exportPath() const { return dir_ + "/out.rsbank"; }
|
||||
|
||||
private:
|
||||
std::string dir_;
|
||||
};
|
||||
|
||||
static std::vector<std::uint8_t> readBytes(const std::string& path) {
|
||||
std::ifstream f(utf8Path(path), std::ios::binary);
|
||||
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(f),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
static void writeBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
|
||||
std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc);
|
||||
f.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
|
||||
// Copies a committed fixture to the path the verb will be pointed at. Fails loudly on an
|
||||
// empty read: an unreadable corpus would otherwise let every "must refuse" suite pass.
|
||||
static bool stageFixture(const Scratch& scratch, const char* fixture) {
|
||||
const std::vector<std::uint8_t> bytes = packageFixtureBytes(fixture);
|
||||
if (bytes.empty()) {
|
||||
std::printf("FAIL: fixture %s read as 0 bytes (path: %s)\n", fixture,
|
||||
packageFixturePath(fixture).c_str());
|
||||
++g_fail;
|
||||
return false;
|
||||
}
|
||||
writeBytes(scratch.importPath(), bytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Each entry's payload bytes, sliced out of a whole package file by its own layout.
|
||||
static std::vector<std::vector<std::uint8_t>> payloadsOf(const std::vector<std::uint8_t>& file) {
|
||||
std::vector<std::vector<std::uint8_t>> out;
|
||||
const package::DecodedPackage dec = package::decodePackage(file, file.size());
|
||||
if (dec.status != package::PackageReadability::Readable) return out;
|
||||
for (const package::PackageEntrySpan& span : dec.layout) {
|
||||
const auto begin = file.begin() + static_cast<std::ptrdiff_t>(span.offset);
|
||||
out.emplace_back(begin, begin + static_cast<std::ptrdiff_t>(span.length));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- the sequence the import verb runs, minus REAPER -------------------------
|
||||
|
||||
static ImportLanding runImport(const Scratch& scratch, ReaSamplerSession& session,
|
||||
int* outBirths = nullptr) {
|
||||
LandedFileJournal journal;
|
||||
ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(),
|
||||
session.book(), kTag, journal);
|
||||
if (landing.outcome != ImportOutcome::Landed) return landing;
|
||||
int births = 0;
|
||||
const bool applied = applyImportedBank(session.book(), kImportedBankId, landing.plan,
|
||||
[&births](const model::Sample&) { ++births; });
|
||||
CHECK(applied);
|
||||
if (applied) journal.markIndexCommitted();
|
||||
if (outBirths) *outBirths = births;
|
||||
return landing;
|
||||
}
|
||||
|
||||
// --- the round-trip anchor ---------------------------------------------------
|
||||
|
||||
// The fixture IS the first export (written by the shipping build's own export verb), so
|
||||
// importing and re-exporting it closes export -> import -> export over frozen bytes.
|
||||
static void testV1FixtureReExportsByteIdenticalPayloads() {
|
||||
Scratch scratch("roundtrip");
|
||||
if (!stageFixture(scratch, "v1_shipping.rsbank")) return;
|
||||
|
||||
ReaSamplerSession session;
|
||||
int births = 0;
|
||||
const ImportLanding landing = runImport(scratch, session, &births);
|
||||
CHECK(landing.outcome == ImportOutcome::Landed);
|
||||
CHECK(births == 1);
|
||||
CHECK(landing.plan.landCount == 1);
|
||||
if (landing.plan.entries.size() != 1) { CHECK(false); return; }
|
||||
|
||||
const std::vector<std::vector<std::uint8_t>> sourcePayloads =
|
||||
payloadsOf(packageFixtureBytes("v1_shipping.rsbank"));
|
||||
CHECK(sourcePayloads.size() == 1);
|
||||
|
||||
// The landed file is the package's payload verbatim — the first half of the claim.
|
||||
const std::string landed = scratch.bankDir() + "/" + landing.plan.entries[0].destFileName;
|
||||
CHECK(readBytes(landed) == sourcePayloads[0]);
|
||||
|
||||
ExportRequest req;
|
||||
req.projectDir = scratch.projectDir();
|
||||
req.bankId = kImportedBankId;
|
||||
req.destAbsPath = scratch.exportPath();
|
||||
req.exportTimestamp = 1754200000;
|
||||
const ExportOutcome out = exportBank(session, req);
|
||||
CHECK(out.status == ExportStatus::Written);
|
||||
CHECK(out.entriesWritten == 1);
|
||||
|
||||
// The second half: the re-export's payloads, byte for byte. Entry NAMES may legally
|
||||
// differ across the trip — the importer re-spells a bank file and the exporter mints
|
||||
// its own transport name (src/core/package/CLAUDE.md) — the payload bytes may not.
|
||||
CHECK(payloadsOf(readBytes(scratch.exportPath())) == sourcePayloads);
|
||||
}
|
||||
|
||||
// --- the refusals, at the verb rather than the codec -------------------------
|
||||
|
||||
static void testRefuseFixtureRefusesTheWholeImportAndNamesTheWriter() {
|
||||
Scratch scratch("refuse");
|
||||
if (!stageFixture(scratch, "refuse_structural.rsbank")) return;
|
||||
|
||||
ReaSamplerSession session;
|
||||
const BankBook before = session.book();
|
||||
LandedFileJournal journal;
|
||||
const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(),
|
||||
session.book(), kTag, journal);
|
||||
|
||||
CHECK(landing.outcome == ImportOutcome::TooNew);
|
||||
CHECK(landing.header.formatVersion == package::kPackageFormatVersion + 1);
|
||||
CHECK(landing.header.minReaderVersion == package::kPackageFormatVersion + 1);
|
||||
CHECK(landing.header.writerVersion == "1.9.0");
|
||||
// Nothing planned, nothing on disk, nothing in the index.
|
||||
CHECK(landing.plan.entries.empty());
|
||||
CHECK(!fs::exists(utf8Path(scratch.bankDir())));
|
||||
CHECK(session.book() == before);
|
||||
}
|
||||
|
||||
// The same eight cuts test_package_compat classifies, driven through the verb's
|
||||
// incremental prefix reader — the one caller that can ask requiredPrefixSize for more
|
||||
// bytes than the file holds.
|
||||
static const char* kTruncationFixtures[] = {
|
||||
"trunc_magic.rsbank", "trunc_version_pair.rsbank",
|
||||
"trunc_writer_semver.rsbank", "trunc_manifest_length.rsbank",
|
||||
"trunc_manifest_body.rsbank", "trunc_payload_start.rsbank",
|
||||
"trunc_payload_middle.rsbank", "trunc_one_short.rsbank",
|
||||
};
|
||||
|
||||
static void testEveryTruncationRefusesTheImportAsMalformed() {
|
||||
for (const char* fixture : kTruncationFixtures) {
|
||||
Scratch scratch(std::string("trunc_") + fixture);
|
||||
if (!stageFixture(scratch, fixture)) continue;
|
||||
|
||||
ReaSamplerSession session;
|
||||
const BankBook before = session.book();
|
||||
LandedFileJournal journal;
|
||||
const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(),
|
||||
session.book(), kTag, journal);
|
||||
if (landing.outcome != ImportOutcome::Malformed) {
|
||||
std::printf("FAIL: %s imported as outcome %d, expected Malformed\n", fixture,
|
||||
static_cast<int>(landing.outcome));
|
||||
++g_fail;
|
||||
}
|
||||
CHECK(landing.plan.entries.empty());
|
||||
CHECK(!fs::exists(utf8Path(scratch.bankDir())));
|
||||
CHECK(session.book() == before);
|
||||
}
|
||||
}
|
||||
|
||||
static const char* kHostileFixtures[] = {
|
||||
"hostile_name_dotdot.rsbank", "hostile_name_parent_slash.rsbank",
|
||||
"hostile_name_parent_backslash.rsbank", "hostile_name_subdir_slash.rsbank",
|
||||
"hostile_name_drive_absolute.rsbank", "hostile_name_unc_absolute.rsbank",
|
||||
"hostile_path_dotdot_slash.rsbank", "hostile_path_dotdot_backslash.rsbank",
|
||||
"hostile_path_rooted.rsbank", "hostile_path_drive_absolute.rsbank",
|
||||
"hostile_path_unc_absolute.rsbank",
|
||||
};
|
||||
|
||||
static void testEveryHostileNameIsRefusedBeforeThePlannerRuns() {
|
||||
for (const char* fixture : kHostileFixtures) {
|
||||
Scratch scratch(std::string("hostile_") + fixture);
|
||||
if (!stageFixture(scratch, fixture)) continue;
|
||||
|
||||
ReaSamplerSession session;
|
||||
const BankBook before = session.book();
|
||||
LandedFileJournal journal;
|
||||
const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(),
|
||||
session.book(), kTag, journal);
|
||||
if (landing.outcome != ImportOutcome::Malformed) {
|
||||
std::printf("FAIL: %s imported as outcome %d, expected Malformed\n", fixture,
|
||||
static_cast<int>(landing.outcome));
|
||||
++g_fail;
|
||||
}
|
||||
// planImport is the ONLY producer of a non-empty plan and it runs after the
|
||||
// decode — an empty one is how "refused before any planner" is observed here.
|
||||
CHECK(landing.plan.entries.empty());
|
||||
CHECK(landing.plan.bankDisplayName.empty());
|
||||
CHECK(!fs::exists(utf8Path(scratch.bankDir())));
|
||||
CHECK(session.book() == before);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testV1FixtureReExportsByteIdenticalPayloads();
|
||||
testRefuseFixtureRefusesTheWholeImportAndNamesTheWriter();
|
||||
testEveryTruncationRefusesTheImportAsMalformed();
|
||||
testEveryHostileNameIsRefusedBeforeThePlannerRuns();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("package_round_trip_tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("package_round_trip_tests: %d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user