Close bank-export review findings: name-cap underflow, double overwrite prompt, test scope

Clamps insertSuffix's underflow, floors uniqueEntryName's validity guard, suppresses
the redundant overwrite confirm via a picker out-param, adds a PayloadBuffer
high-water mark, and corrects stale CLAUDE.md/CMake claims.
This commit is contained in:
2026-08-02 14:02:01 -04:00
parent 081b6f1028
commit 454f67b3bc
18 changed files with 291 additions and 91 deletions
+122 -26
View File
@@ -86,10 +86,11 @@ struct Fixture {
}
void addSample(const std::string& id, const std::string& fileName,
std::size_t bytes, std::uint8_t seed, bool writeToDisk = true) {
std::size_t bytes, std::uint8_t seed, bool writeToDisk = true,
const std::string& displayName = "") {
model::Sample s;
s.id = id;
s.displayName = id;
s.displayName = displayName.empty() ? id : displayName;
s.relativePath = std::string("reasampler_bank/") + fileName;
s.sampleRate = 48000;
s.channelCount = 2;
@@ -146,6 +147,11 @@ static void testHealthyExportCarriesEveryPayloadByteExact() {
CHECK(out.entriesWritten == 3);
CHECK(out.excluded.empty());
CHECK(PayloadBuffer::alive() == 0);
// Point-in-time alive() == 0 alone cannot fail on a whole-package-in-memory
// shape (N buffers allocated and freed one at a time still ends at 0); the
// high-water mark can, across this three-entry export and everything the test
// binary ran before it — it must never exceed the "at most one payload" claim.
CHECK(PayloadBuffer::highWaterMark() == 1);
const package::DecodedPackage decoded = decodeFromDisk(fx.destPath());
CHECK(decoded.status == package::PackageReadability::Readable);
@@ -176,17 +182,51 @@ static void testHealthyExportCarriesEveryPayloadByteExact() {
CHECK(PayloadBuffer::alive() == 0);
}
// Every occurrence of `"key":"value"` in `json`, value returned raw (escape-aware
// only enough to not stop early on an escaped quote — this file's own writer output
// never nests an unescaped quote, so that is sufficient here).
static std::vector<std::string> jsonStringValuesForKey(const std::string& json,
const std::string& key) {
std::vector<std::string> values;
const std::string marker = "\"" + key + "\":\"";
std::size_t pos = 0;
while ((pos = json.find(marker, pos)) != std::string::npos) {
std::size_t i = pos + marker.size();
while (i < json.size() && json[i] != '"') {
if (json[i] == '\\') ++i; // skip the escaped char too
++i;
}
values.push_back(json.substr(pos + marker.size(), i - (pos + marker.size())));
pos = i;
}
return values;
}
// True for a value opening with an <alpha>':' drive-relative prefix (":" alone is
// JSON's own key separator, so this is checked on isolated VALUES, never on raw text).
static bool looksLikeDriveForm(const std::string& value) {
return value.size() >= 2 && std::isalpha(static_cast<unsigned char>(value[0])) &&
value[1] == ':';
}
static void testEmittedManifestBytesCarryNoPath() {
Fixture fx("nopath");
// The bank's own display name AND a sample's displayName each carry a literal
// '/' — free text, unlike the entry `name` / nested `relativePath` fields this
// test actually polices (docs/product/bank-package.md:282-289: the destination is
// derived from the entry name, never from free text). Present in the fixture so
// the scan below proves it is scoped correctly rather than merely holding by
// accident on names that happen not to collide with the rule.
CHECK(fx.session.book().renameBank(fx.bankId, "Drums/Bus"));
// Both source records carry a directory component; neither may reach the file.
fx.addSample("s1", "kick.wav", 200, 3);
fx.addSample("s1", "kick.wav", 200, 3, /*writeToDisk=*/true, "Kick / alt take");
fx.addSample("s2", "snare take 2.wav", 200, 9);
CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written);
// Scan the MANIFEST REGION of the emitted file, located by walking the frozen
// header the way a reader does: magic | fv | minReader | len+semver | len+JSON.
// The binary length fields are deliberately excluded — a length whose byte
// happens to be 0x2F is not a separator.
// Locate the MANIFEST REGION of the emitted file by walking the frozen header the
// way a reader does: magic | fv | minReader | len+semver | len+JSON. The binary
// length fields are deliberately excluded — a length whose byte happens to be
// 0x2F is not a separator.
const std::vector<std::uint8_t> file = readFile(fx.destPath());
CHECK(file.size() > 20);
auto le32 = [&](std::size_t at) {
@@ -202,22 +242,31 @@ static void testEmittedManifestBytesCarryNoPath() {
CHECK(manifestAt + manifestLen <= file.size());
const std::string manifest(reinterpret_cast<const char*>(file.data() + manifestAt),
manifestLen);
CHECK(manifest.find("kick.wav") != std::string::npos); // the scan is looking at the manifest
CHECK(manifest.find('/') == std::string::npos);
CHECK(manifest.find('\\') == std::string::npos);
CHECK(manifest.find("..") == std::string::npos);
CHECK(manifest.find("reasampler_bank") == std::string::npos);
// ':' cannot be banned outright — it is JSON's own key separator — so the check
// is for the drive form specifically: a string value opening with <alpha>':'.
// With '/' and '\\' already absent, that covers the drive-relative spelling too.
bool driveForm = false;
for (std::size_t i = 0; i + 2 < manifest.size(); ++i)
if (manifest[i] == '"' &&
std::isalpha(static_cast<unsigned char>(manifest[i + 1])) &&
manifest[i + 2] == ':')
driveForm = true;
CHECK(!driveForm);
// The boundary, pinned rather than assumed: free text legitimately carries '/'.
CHECK(manifest.find("Drums/Bus") != std::string::npos);
CHECK(manifest.find("Kick / alt take") != std::string::npos);
// The rule itself: scoped to the two fields the importer derives a destination
// from — the entry `name` and the nested Sample's own `relativePath` — never to
// `displayName` or the manifest's `bankDisplayName`.
const std::vector<std::string> names = jsonStringValuesForKey(manifest, "name");
const std::vector<std::string> relPaths = jsonStringValuesForKey(manifest, "relativePath");
CHECK(!names.empty());
CHECK(!relPaths.empty());
for (const std::string& v : names) {
CHECK(v.find('/') == std::string::npos);
CHECK(v.find('\\') == std::string::npos);
CHECK(v.find("..") == std::string::npos);
CHECK(!looksLikeDriveForm(v));
}
for (const std::string& v : relPaths) {
CHECK(v.find('/') == std::string::npos);
CHECK(v.find('\\') == std::string::npos);
CHECK(v.find("..") == std::string::npos);
CHECK(!looksLikeDriveForm(v));
}
}
static void testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile() {
@@ -252,6 +301,39 @@ static void testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile() {
CHECK(PayloadBuffer::alive() == 0);
}
static void testCommitFailureIsAGenuineMidWriteAbandon() {
// The mid-READ injection above (a source vanishing between digest and stream) is
// not what "mid-write" names in export_bank.cpp:115-118/122-125 — those guard a
// failure IN the write itself: appendPayload's stream going bad, or commit's
// rename failing. A directory squatting on the destination (test_package_io.cpp's
// own precedent for a real, not simulated, commit failure) makes every payload
// stream fine and only the final rename fail.
Fixture fx("commitfail");
fx.addSample("s1", "kick.wav", 300, 1);
fx.addSample("s2", "snare.wav", 300, 2);
std::error_code ec;
fs::create_directory(utf8Path(fx.destPath()), ec);
CHECK(!ec);
ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId);
CHECK(survey.plan.verdict == package::ExportVerdict::Ready);
package::PackageManifest manifest = survey.plan.manifest;
const std::vector<std::string> sources = {fx.absPathOf("kick.wav"), fx.absPathOf("snare.wav")};
std::string failed;
CHECK(digestSources(manifest, sources, failed));
const std::optional<package::EncodedPackage> encoded = package::encodePackage(manifest);
CHECK(encoded.has_value());
const ExportOutcome out = writePackageFile(*encoded, manifest, sources, fx.destPath());
CHECK(out.status == ExportStatus::WriteFailed);
CHECK(fs::is_directory(utf8Path(fx.destPath()))); // the squatting dir is untouched
CHECK(!exists(fx.destPath() + ".rsbanktmp")); // commit()'s own self-clean ran
CHECK(PayloadBuffer::alive() == 0);
fs::remove(utf8Path(fx.destPath()), ec);
}
static void testPayloadChangedBetweenDigestAndStreamAborts() {
Fixture fx("changed");
fx.addSample("s1", "kick.wav", 500, 1);
@@ -280,14 +362,27 @@ static void testExportTouchesNoProjectState() {
fx.addSample("s1", "kick.wav", 400, 5);
fx.addSample("s2", "snare.wav", 400, 6);
// The ext-state blob IS the serialized book (shell/persist/ext_state_io), so
// byte-identity of that string is byte-identity of what a persist would write.
const std::string extStateBefore = fx.session.book().serialize();
// saveToActiveProject writes seven keys (shell/persist/ext_state_io.cpp): `banks`,
// the legacy-key clear, `view_state`, the tail setting, the tracking ledger, the
// version stamp, and the bank-generation counter. This asserts byte-identity of
// the three that have an in-memory string to diff (`banks`, `view_state`, the tail
// setting) plus bankGeneration() (the bank-generation-counter key IS its
// serialization). The legacy-key clear and the version stamp are session-external,
// nothing here to diff against. The tracking ledger has no public accessor to diff
// either, but needs none: exportBank/digestSources/writePackageFile all take the
// session by `const&`, and ReaSamplerSession::recordCreated — the ledger's one
// writer (session.h) — is non-const, so it is not reachable through this call at
// all; the compiler enforces "untouched" here rather than a runtime check proving it.
const std::string bankBookBefore = fx.session.book().serialize();
const std::string viewBefore = fx.session.view().serialize();
const std::string tailBefore = capture::serializeTailSetting(fx.session.tail());
const std::int64_t generationBefore = fx.session.bankGeneration();
CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written);
CHECK(fx.session.book().serialize() == extStateBefore);
CHECK(fx.session.book().serialize() == bankBookBefore);
CHECK(fx.session.view().serialize() == viewBefore);
CHECK(capture::serializeTailSetting(fx.session.tail()) == tailBefore);
CHECK(fx.session.bankGeneration() == generationBefore);
}
@@ -359,6 +454,7 @@ int main() {
testHealthyExportCarriesEveryPayloadByteExact();
testEmittedManifestBytesCarryNoPath();
testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile();
testCommitFailureIsAGenuineMidWriteAbandon();
testPayloadChangedBetweenDigestAndStreamAborts();
testExportTouchesNoProjectState();
testEmptyBankExportsAsAValidZeroEntryPackage();
+22
View File
@@ -167,6 +167,27 @@ static void testHostileNamesAreRepairedNotRelayed() {
}
}
static void testUniqueNameSurvivesLongExtensionUnderflow() {
// insertSuffix computes room = kMaxEntryNameBytes - suffix.size() - ext.size() in
// size_t; an extension long enough that even a two-digit "_10" suffix pushes the
// sum past the cap must not wrap that subtraction. Ten same-named entries force
// the tenth collision into double digits against a 253-byte extension (253 + 3 =
// 256, one over kMaxEntryNameBytes).
const std::string hostileName = "a." + std::string(252, 'x'); // 254 bytes, otherwise valid
std::vector<ExportCandidate> candidates;
for (int i = 0; i < 10; ++i)
candidates.push_back(present("s" + std::to_string(i), "reasampler_bank/" + hostileName));
const ExportPlan p = planExport(bankOf(candidates));
CHECK(p.verdict == ExportVerdict::Ready);
CHECK(p.manifest.entries.size() == 10);
for (const PackageEntry& e : p.manifest.entries) CHECK(isValidEntryName(e.fileName));
for (std::size_t i = 0; i < p.manifest.entries.size(); ++i)
for (std::size_t j = i + 1; j < p.manifest.entries.size(); ++j)
CHECK(!sameEntryName(p.manifest.entries[i].fileName,
p.manifest.entries[j].fileName));
}
static void testCaseFoldedCollisionsAreDisambiguated() {
const ExportPlan p = planExport(bankOf({
present("s1", "reasampler_bank/Kick.wav"),
@@ -253,6 +274,7 @@ int main() {
testUnreadableFileStaysDistinctFromMissing();
testUnrepresentableRecordRefusesWholeExport();
testHostileNamesAreRepairedNotRelayed();
testUniqueNameSurvivesLongExtensionUnderflow();
testCaseFoldedCollisionsAreDisambiguated();
testSanitizeNeverReturnsANameTheCodecRefuses();
testPlannedManifestSatisfiesTheCodec();
+3 -1
View File
@@ -72,7 +72,9 @@ static void testPayloadCounterTracksMovesNotCopies() {
}
static void testStreamingRoundTripHoldsOnePayload() {
const std::string dest = "pkg_io_scratch.rsbank";
std::error_code destEc;
const std::string dest =
(fs::temp_directory_path(destEc) / "pkg_io_scratch.rsbank").generic_string();
const std::vector<std::uint8_t> header = patternBytes(16, 0xA0);
const std::vector<std::vector<std::uint8_t>> entries = {
patternBytes(1000, 1), patternBytes(500, 2), patternBytes(1, 3)};