diff --git a/energyplugin/etm/config/loadconfigstore.cpp b/energyplugin/etm/config/loadconfigstore.cpp index 63bd498..aa26f23 100644 --- a/energyplugin/etm/config/loadconfigstore.cpp +++ b/energyplugin/etm/config/loadconfigstore.cpp @@ -6,6 +6,7 @@ #include +#include #include #include #include @@ -32,6 +33,15 @@ QString valeurPourMotif(const QString &valeur) : 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 //! est bien formé — un identifiant refusé ne se recopie pas. 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 // 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. + // 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) { QString pourquoi; if (!validateLabels(LoadConfigs(QList{loaded.at(i)}), &pourquoi)) - qCWarning(dcNymeaEnergy()).noquote() + qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi << "— chargé tel quel ; toute écriture cliente sera refusée tant qu'il n'est pas raccourci."; if (!idIsWellFormed(loaded.at(i).id())) qCWarning(dcNymeaEnergy()).noquote() << 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 " "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); // 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ù @@ -144,10 +156,10 @@ bool LoadConfigStore::load() if (!deadlineIsWellFormed(echeance)) { qCWarning(dcNymeaEnergy()).noquote() << 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, " "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(); besoins.setDailyDeadline(QString()); loaded[i].setNeeds(besoins); diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index bd3073f..bb51fa3 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -885,6 +885,78 @@ void TestLoadModel::testAppendConfigBoundsOnlyTheAddedEntry() {"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() { // [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index b424faa..6c21e24 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -94,6 +94,8 @@ private slots: 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. 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 ─────────────────────── //! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait