LoadConfigStore::load() : les valeurs héritées sont citées échappées au journal

Même défaut que I-1, dans les avertissements de chargement ajoutés par Q7 : l'identifiant hors
règle et l'échéance illisible étaient cités en noquote(). Avant Q7, un client pouvait les
persister avec un saut de ligne (ni l'id ni l'échéance n'étaient bornés), et la lecture du
fichier fabriquait alors une fausse ligne de journal.

Ces valeurs passent par citee() : entre guillemets, échappées par QDebug (même rendu
qu'avant pour une valeur ordinaire). L'avertissement LIBELLÉ TROP LONG, qui cite l'identifiant
dans sa phrase, perd son noquote().

Test : testInheritedValuesAreEscapedInTheJournal charge un fichier dont l'id et l'échéance
valent "x\n[NymeaEnergy] FAUX" : les trois avertissements partent, citent la valeur échappée,
et aucun message n'ajoute de ligne commençant par [NymeaEnergy].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-22 23:22:41 +02:00
parent 2ce74892d3
commit 58380dd7db
3 changed files with 91 additions and 5 deletions

View File

@ -6,6 +6,7 @@
#include <nymeasettings.h> #include <nymeasettings.h>
#include <QDebug>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QSaveFile> #include <QSaveFile>
@ -32,6 +33,15 @@ QString valeurPourMotif(const QString &valeur)
: LoadConfigStore::leftChars(valeur, 16) + QStringLiteral("…"); : LoadConfigStore::leftChars(valeur, 16) + QStringLiteral("…");
} }
//! Une valeur citée au journal, entre guillemets et ÉCHAPPÉE par Qt (\c "\n" pour un saut de
//! ligne) : une valeur persistée par un client ne fabrique pas de fausse ligne de journal.
QString citee(const QString &valeur)
{
QString s;
QDebug(&s).nospace() << valeur;
return s;
}
//! Le motif publiable d'un refus PAR ENTRÉE : la cause, la position, et l'identifiant quand il //! Le motif publiable d'un refus PAR ENTRÉE : la cause, la position, et l'identifiant quand il
//! est bien formé — un identifiant refusé ne se recopie pas. //! est bien formé — un identifiant refusé ne se recopie pas.
QVariantMap motifEntree(const char *cause, int index, const QString &id) QVariantMap motifEntree(const char *cause, int index, const QString &id)
@ -121,19 +131,21 @@ bool LoadConfigStore::load()
// Q7 — même règle pour un libellé trop long : CHARGÉ TEL QUEL, jamais tronqué ni écarté. Le // Q7 — même règle pour un libellé trop long : CHARGÉ TEL QUEL, jamais tronqué ni écarté. Le
// client d'optimiseur refuse d'émettre un GetPlan de plus de 1 Mio ; c'est lui le filet. // client d'optimiseur refuse d'émettre un GetPlan de plus de 1 Mio ; c'est lui le filet.
// Chaque entrée est signalée, pas seulement la première : c'est la liste des réparations. // Chaque entrée est signalée, pas seulement la première : c'est la liste des réparations.
// Les valeurs persistées sont citées ÉCHAPPÉES (citee(), ou `<<` sans noquote()) : avant Q7,
// un client pouvait écrire un identifiant ou une échéance portant un saut de ligne.
for (int i = 0; i < loaded.count(); ++i) { for (int i = 0; i < loaded.count(); ++i) {
QString pourquoi; QString pourquoi;
if (!validateLabels(LoadConfigs(QList<LoadConfig>{loaded.at(i)}), &pourquoi)) if (!validateLabels(LoadConfigs(QList<LoadConfig>{loaded.at(i)}), &pourquoi))
qCWarning(dcNymeaEnergy()).noquote() qCWarning(dcNymeaEnergy())
<< "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi << "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi
<< "— chargé tel quel ; toute écriture cliente sera refusée tant qu'il n'est pas raccourci."; << "— chargé tel quel ; toute écriture cliente sera refusée tant qu'il n'est pas raccourci.";
if (!idIsWellFormed(loaded.at(i).id())) if (!idIsWellFormed(loaded.at(i).id()))
qCWarning(dcNymeaEnergy()).noquote() qCWarning(dcNymeaEnergy()).noquote()
<< QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, IDENTIFIANT HORS RÈGLE : " << QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, IDENTIFIANT HORS RÈGLE : "
"charge \"%1\" (%2 caractères ; attendu 1 à %3 caractères " "charge %1 (%2 caractères ; attendu 1 à %3 caractères "
"[A-Za-z0-9._{}-]) — chargée telle quelle ; toute écriture cliente " "[A-Za-z0-9._{}-]) — chargée telle quelle ; toute écriture cliente "
"sera refusée tant qu'elle porte cet identifiant.") "sera refusée tant qu'elle porte cet identifiant.")
.arg(idPourMotif(loaded.at(i).id())).arg(loaded.at(i).id().toUcs4().size()) .arg(citee(idPourMotif(loaded.at(i).id()))).arg(loaded.at(i).id().toUcs4().size())
.arg(MaxIdChars); .arg(MaxIdChars);
// L'échéance, elle, est RETIRÉE de la mémoire — l'entrée reste. Une échéance illisible // L'échéance, elle, est RETIRÉE de la mémoire — l'entrée reste. Une échéance illisible
// ne se résout en aucun instant ; la garder la ferait partir brute dans GetPlan, où // ne se résout en aucun instant ; la garder la ferait partir brute dans GetPlan, où
@ -144,10 +156,10 @@ bool LoadConfigStore::load()
if (!deadlineIsWellFormed(echeance)) { if (!deadlineIsWellFormed(echeance)) {
qCWarning(dcNymeaEnergy()).noquote() qCWarning(dcNymeaEnergy()).noquote()
<< QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, ÉCHÉANCE ILLISIBLE : " << QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, ÉCHÉANCE ILLISIBLE : "
"charge \"%1\", needs.dailyDeadline \"%2\" (attendu HH:MM, de 00:00 " "charge %1, needs.dailyDeadline %2 (attendu HH:MM, de 00:00 "
"à 23:59) — IGNORÉE : ni l'arbitre ni l'optimiseur ne la reçoivent, " "à 23:59) — IGNORÉE : ni l'arbitre ni l'optimiseur ne la reçoivent, "
"l'obligation n'a plus d'échéance.") "l'obligation n'a plus d'échéance.")
.arg(idPourMotif(loaded.at(i).id()), valeurPourMotif(echeance)); .arg(citee(idPourMotif(loaded.at(i).id())), citee(valeurPourMotif(echeance)));
LoadConfigNeeds besoins = loaded.at(i).needs(); LoadConfigNeeds besoins = loaded.at(i).needs();
besoins.setDailyDeadline(QString()); besoins.setDailyDeadline(QString());
loaded[i].setNeeds(besoins); loaded[i].setNeeds(besoins);

View File

@ -885,6 +885,78 @@ void TestLoadModel::testAppendConfigBoundsOnlyTheAddedEntry()
{"length", 300}, {"max", 256}})); {"length", 300}, {"max", 256}}));
} }
// ── relecture q7-suite — le journal ne recopie jamais une saisie telle quelle ──────────────────
namespace {
//! Ce qu'un client glisse dans un champ pour fabriquer une ligne de journal.
const QString FORGE = QStringLiteral("x\n[NymeaEnergy] FAUX");
//! Capture les messages Qt UN PAR UN : une fausse ligne ne se voit qu'à l'intérieur d'un message.
class Messages
{
public:
Messages() { s_courant = this; s_origine = qInstallMessageHandler(&Messages::collecte); }
~Messages() { qInstallMessageHandler(s_origine); s_courant = nullptr; }
bool contient(const QString &fragment) const
{
for (const QString &m : m_liste)
if (m.contains(fragment))
return true;
return false;
}
//! Les lignes qu'un message AJOUTE au journal en se faisant passer pour un message du moteur.
QStringList faussesLignes() const
{
QStringList f;
for (const QString &m : m_liste) {
const QStringList lignes = m.split(QLatin1Char('\n'));
for (int i = 1; i < lignes.count(); ++i)
if (lignes.at(i).startsWith(QLatin1String("[NymeaEnergy]"), Qt::CaseInsensitive))
f << lignes.at(i);
}
return f;
}
private:
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
{
if (s_courant)
s_courant->m_liste << msg;
if (s_origine)
s_origine(type, ctx, msg);
}
QStringList m_liste;
static inline Messages *s_courant = nullptr;
static inline QtMessageHandler s_origine = nullptr;
};
}
void TestLoadModel::testInheritedValuesAreEscapedInTheJournal()
{
// Avant Q7, un client pouvait persister un identifiant ou une échéance portant un saut de
// ligne. load() les cite : échappés, ils ne fabriquent aucune fausse ligne de journal.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-journal.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); });
QVariantMap idForge = ballonMap();
idForge.insert("id", FORGE);
idForge.insert("label", QString(300, QLatin1Char('L'))); // LIBELLÉ TROP LONG cite l'identifiant
QVariantMap echeanceForgee = routeurMap();
echeanceForgee.insert("needs", QVariantMap{{"dailyDeadline", FORGE}, {"minEnergyWhPerDay", 1000}});
ecrireFichier(cfgPath, {idForge, echeanceForgee});
Messages journal;
LoadConfigStore relu;
QCOMPARE(relu.configs().count(), 2);
// Non vide : les trois avertissements sont partis, et ils citent la valeur ÉCHAPPÉE.
QVERIFY(journal.contient(QStringLiteral("LIBELLÉ TROP LONG")));
QVERIFY(journal.contient(QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"x\\n[NymeaEnergy] FAUX\"")));
QVERIFY(journal.contient(QStringLiteral("needs.dailyDeadline \"x\\n[NymeaEnergy] …\"")));
QVERIFY2(journal.faussesLignes().isEmpty(), qUtf8Printable(journal.faussesLignes().join(QStringLiteral(" | "))));
}
void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite() void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
{ {
// [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est // [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est

View File

@ -94,6 +94,8 @@ private slots:
void testLeftCharsNeverSplitsAPair(); void testLeftCharsNeverSplitsAPair();
//! L'ajout par le moteur ne borne que l'entrée ajoutée — un hérité hors règle ne le bloque pas. //! L'ajout par le moteur ne borne que l'entrée ajoutée — un hérité hors règle ne le bloque pas.
void testAppendConfigBoundsOnlyTheAddedEntry(); void testAppendConfigBoundsOnlyTheAddedEntry();
//! Relecture q7-suite : une valeur héritée citée par load() est échappée — aucune fausse ligne.
void testInheritedValuesAreEscapedInTheJournal();
// ── LM-1006-1 — le plafond de soutirage, étape 1 ─────────────────────── // ── LM-1006-1 — le plafond de soutirage, étape 1 ───────────────────────
//! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait //! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait