Files
reasampler/tests/test_package_round_trip.cpp
T
daniel 3fd3214ff8 Remediate Ε-W3-T1 package-compat-fixtures review findings
Freeze *.rsbank as binary via .gitattributes; add a truncated additive_forward fixture proving the exact-size proof beats TooNew; enumerate the fixture dir to catch orphaned files; make fixture-size checks fatal instead of just logged; pin fixture version asserts as literals, not build-relative.
2026-08-02 17:19:57 -04:00

276 lines
11 KiB
C++

// 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 a
// size mismatch (empty included): an unreadable OR mangled corpus would otherwise let
// every "must refuse" suite pass, since a garbled fixture still refuses, just not for
// the reason under test.
static bool stageFixture(const Scratch& scratch, const char* fixture,
std::size_t expectedSize) {
const std::vector<std::uint8_t> bytes = packageFixtureBytes(fixture);
if (bytes.size() != expectedSize) {
std::printf("FAIL: fixture %s is %zu bytes, expected %zu (path: %s)\n", fixture,
bytes.size(), expectedSize, 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", 1207)) 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"));
if (sourcePayloads.size() != 1) { CHECK(false); return; }
// 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", 1207)) 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);
// Literal, not kPackageFormatVersion-relative: this is the FIXTURE's frozen pair
// (2/2), not this build's (see test_package_compat.cpp's file header note).
CHECK(landing.header.formatVersion == 2);
CHECK(landing.header.minReaderVersion == 2);
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. Sizes match test_package_compat.cpp's kTruncations.
struct TruncationFixture {
const char* file;
std::size_t size;
};
static const TruncationFixture kTruncationFixtures[] = {
{"trunc_magic.rsbank", 2},
{"trunc_version_pair.rsbank", 10},
{"trunc_writer_semver.rsbank", 18},
{"trunc_manifest_length.rsbank", 23},
{"trunc_manifest_body.rsbank", 466},
{"trunc_payload_start.rsbank", 907},
{"trunc_payload_middle.rsbank", 1057},
{"trunc_one_short.rsbank", 1206},
};
static void testEveryTruncationRefusesTheImportAsMalformed() {
for (const TruncationFixture& fixture : kTruncationFixtures) {
Scratch scratch(std::string("trunc_") + fixture.file);
if (!stageFixture(scratch, fixture.file, fixture.size)) 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.file,
static_cast<int>(landing.outcome));
++g_fail;
}
CHECK(landing.plan.entries.empty());
CHECK(!fs::exists(utf8Path(scratch.bankDir())));
CHECK(session.book() == before);
}
}
// Sizes match test_package_compat.cpp's kHostiles.
struct HostileFixtureFile {
const char* file;
std::size_t size;
};
static const HostileFixtureFile kHostileFixtures[] = {
{"hostile_name_dotdot.rsbank", 624},
{"hostile_name_parent_slash.rsbank", 633},
{"hostile_name_parent_backslash.rsbank", 634},
{"hostile_name_subdir_slash.rsbank", 634},
{"hostile_name_drive_absolute.rsbank", 643},
{"hostile_name_unc_absolute.rsbank", 646},
{"hostile_path_dotdot_slash.rsbank", 641},
{"hostile_path_dotdot_backslash.rsbank", 640},
{"hostile_path_rooted.rsbank", 635},
{"hostile_path_drive_absolute.rsbank", 643},
{"hostile_path_unc_absolute.rsbank", 646},
};
static void testEveryHostileNameIsRefusedBeforeThePlannerRuns() {
for (const HostileFixtureFile& fixture : kHostileFixtures) {
Scratch scratch(std::string("hostile_") + fixture.file);
if (!stageFixture(scratch, fixture.file, fixture.size)) 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.file,
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;
}