Preserve installed card databases when Oracle refresh fails (#7739)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / vcpkg baseline (push) Waiting to run
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 sanitize (push) Blocked by required conditions
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 27 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions

* Preserve installed card databases when Oracle refresh fails

* Fix the Oracle refresh verdict and cover the parser write path

A background refresh that already installed cards.xml exited 1 when the
token save afterwards failed, so the client kept its old in-memory
database and reported the update as failed. Return the same verdict
main() already computes: a reject after the database was written is a
successful update.

Cover the writer Oracle actually uses: add a short-write death test for
CockatriceXml4Parser::saveToFile, which reverted to QFile would truncate
an installed database without any test noticing.

Route a failed background token download through reject() instead of a
modal box on a hidden wizard, which left the client waiting with no
timeout and blocked later updates. Test the first non-space byte in
place rather than copying the payload, and report an import refusal with
the importer's own message.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
Bryce! 2026-11-04 11:33:49 -05:00 • committed by GitHub
parent e558c0dffa
commit 3a656c9c4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 517 additions and 72 deletions

View file

@ -100,6 +100,10 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s
bool OracleImporter::readSetsFromByteArray(QByteArray data)
{
importSuccessful = false;
importError.clear();
allSets.clear();
rawSetsData.clear();
const RawJson::ScanProgressCallback progress =
progressReporting
? [this](
@ -764,7 +768,15 @@ int OracleImporter::startImport()
{
static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController();
importCancelled.storeRelease(0);
importSuccessful = false;
importError.clear();
sets.clear();
cards.clear();
localizedEntries.clear();
splitLocalizedTexts.clear();
if (allSets.isEmpty()) {
return failImport(tr("No set has been selected for import."));
}
// Pre-allocate the cards hash to avoid rehashing during import. Keys are
// distinct card names while raw ranges only count printings (AllPrintings
@ -786,9 +798,7 @@ int OracleImporter::startImport()
for (const SetToDownload &curSetToParse : allSets) {
if (importCancelled.loadAcquire()) {
// The wizard was closed mid-import: stop at the next set boundary so
// the caller can wait for this future without processing every set.
break;
return failImport(tr("The card database import was cancelled."));
}
CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(),
@ -798,30 +808,20 @@ int OracleImporter::startImport()
// parse only this set's slice of the raw document so the whole JSON tree is
// never kept in memory at once
const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange();
const qsizetype rangeEnd = rawRange.start + rawRange.length;
if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) {
// rawSetsData is cleared by releaseSetData() while SetToDownload copies
// taken from getSets() keep their ranges, and nothing else enforces the
// pairing — so never index past the buffer on stale/mismatched ranges.
qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping";
++setIndex;
emit setIndexChanged(0, setIndex, curSetToParse.getLongName());
continue;
if (rawRange.start < 0 || rawRange.length <= 0 || rawRange.start > rawSetsData.size() ||
rawRange.length > rawSetsData.size() - rawRange.start) {
return failImport(tr("The source data for set %1 is unavailable. Load the sets file again.")
.arg(curSetToParse.getShortName()));
}
// sliced() shares the buffer instead of deep-copying the slice; the largest
// sets in AllPrintings are tens of MB, so the copy is worth avoiding here.
const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length);
QJsonParseError parseError;
const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError);
if (parseError.error != QJsonParseError::NoError) {
qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":"
<< parseError.errorString();
++setIndex;
// Keep the progress accounting honest: a set that failed to parse
// still advanced the index, so report it (with zero imported cards)
// rather than letting SaveSetsPage's bar stall per failed set.
emit setIndexChanged(0, setIndex, curSetToParse.getLongName());
continue;
return failImport(
tr("Could not import set %1: %2").arg(curSetToParse.getShortName(), parseError.errorString()));
}
if (!setDoc.isObject() || !setDoc.object().value("cards").isArray()) {
return failImport(tr("Set %1 does not contain a card list.").arg(curSetToParse.getShortName()));
}
// Only add the set to the database once its slice parsed cleanly;
@ -838,7 +838,14 @@ int OracleImporter::startImport()
emit setIndexChanged(numCardsInSet, setIndex, curSetToParse.getLongName());
}
if (importCancelled.loadAcquire()) {
return failImport(tr("The card database import was cancelled."));
}
if (cards.isEmpty()) {
return failImport(tr("No cards were imported. The existing card database has been kept."));
}
applyLocalizedData();
importSuccessful = true;
emit setIndexChanged(0, setIndex, QString());
@ -846,8 +853,23 @@ int OracleImporter::startImport()
return setIndex;
}
int OracleImporter::failImport(const QString &message)
{
importSuccessful = false;
importError = message;
sets.clear();
cards.clear();
localizedEntries.clear();
splitLocalizedTexts.clear();
qWarning() << message;
return 0;
}
bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion)
{
if (!importSuccessful) {
return false;
}
CockatriceXml4Parser parser(new NoopCardSetPriorityController());
return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion);
@ -861,6 +883,7 @@ void OracleImporter::releaseSetData()
void OracleImporter::clear()
{
prepareImport();
sets.clear();
cards.clear();
allSets.clear();