Dette passe 1, lot L3 : provisioning non destructif et identifiant validé à l'entrée MQTT #3

Open
Thomas wants to merge 3 commits from pr/dette-l3 into pr/dette-l2
Member

Objet

Lot L3 de la passe 1 de dette : un firstco ne réécrit plus jamais un boîtier déjà provisionné, et l'identifiant de boîtier est validé une seule fois, à l'entrée MQTT, par aquashared::Topic::parse.

Dettes traitées et preuves

N1, V2, Q6, recommandation 5 (partie firstco), décision D4

Avant : handle_first_connect faisait un UPSERT complet de boitiers puis des capteurs ; un firstco forgé réécrivait la fiche et faisait pousser par le poller les commandes forgées au vrai boîtier.

Étape Comportement Preuve
Contrôles sans base id canonique non nul, id du topic firstco-{id} égal à celui du payload, numéro de série non vide aquaserveur/src/provisioning/handler.rs, error.rs
Fiche présente, même numéro de série AlreadyProvisioned : aucune écriture (fiche, updated_at, capteurs, seuils) handler.rs
Fiche présente, autre numéro ou numéro absent SerialMismatch : refus, aucune écriture handler.rs
Fiche absente provisionnement complet, INSERT simple au lieu de l'UPSERT handler.rs

Côté main.rs : succès (nouveau ou déjà provisionné) suivi de l'ACK {"status":"ok","id_boitier":N} sur firstco_ACK-{id} (format inchangé) ; boîtier déjà provisionné : republication de la configuration du serveur sur maj_serv-{id} ; refus : journal d'avertissement, aucun ACK, sans numéro de série ni donnée client.

Décision D3 : firstco-{id}

Routage de firstco-{id} avec contrôle d'égalité entre l'id du topic et celui du payload avant toute requête ; le topic historique firstco reste accepté ; le simulateur de boîtier publie sur firstco-{id}.

Recommandation 5, N9, Q7 : routage et id

Point Avant Après
Routage préfixes et split_once, id en String (data-4000-extra accepté) Topic::parse, BoitierId validé une fois (mqtt/subscriber.rs)
data-04000, data-foo, data-0 stockage refusé mais alertes écrites pour 4000 InvalidTopic, aucun handler appelé
store_frame, handle_config_ack &str revalidé, parse::<u64>, repli unwrap_or(0) BoitierId
Boîtier inconnu aucune vérification boitier_exists avant analyse, stockage et alertes (database/store.rs, data_handler.rs)

La branche data sort de main.rs vers aquaserveur/src/data_handler.rs pour être testable ; contenu déplacé tel quel, seules la clé (BoitierId) et la vérification d'existence changent.

Tests et résultats

  • TDD : firstco_non_destructive.rs 6 échecs sur 7 avant implémentation ; routeur, contrat et data_canonical_id.rs vus rouges ; contre-épreuve : contrôle d'existence neutralisé, le test du boîtier inconnu échoue.
  • Ajouts : aquaserveur/tests/firstco_non_destructive.rs (7 + 4 ignorés), aquaserveur/tests/data_canonical_id.rs (2 + 2 ignorés), tests du routeur, test de contrat sur firstco-4990.
  • cargo fmt --check, cargo clippy ... -D warnings : OK.
  • cargo test --workspace : 242 passés, 0 échec, 45 ignorés (L2 : 233 et 39).
  • Tests ignorés sur MariaDB 10.11 jetable : 45 passés, 0 échec, deux passages consécutifs.
  • Vérification réelle (Mosquitto et serveur jetables) : nouveau boîtier provisionné ; rejeu forgé avec le même numéro sans aucune écriture, ACK et configuration de l'opérateur republiée ; autre numéro et id croisé refusés sans ACK ; data d'un boîtier inconnu sans écriture ni alerte.

Migrations et actions de déploiement

  • Aucune migration de base.
  • Serveur et simulateur de boîtier à déployer ensemble (le simulateur publie sur firstco-{id}, que l'ancien serveur ne route pas).
  • Reprovisionnement désormais par action opérateur (documenté dans handle_first_connect) : carte remplacée, mettre à jour boitiers.noSerie ; capteurs modifiés, mettre à jour {id}_boitier_capteurs et les seuils ; reprise complète, supprimer la fiche et ses dépendances.

Choix faits

  • Refus sans ACK plutôt qu'un ACK status: error : le comportement historique (passerelle Node.js) traite tout message reçu comme un succès ; l'absence d'ACK est le seul format qu'aucun récepteur ne peut confondre avec un succès.
  • Republication de la configuration du serveur après un firstco idempotent.
  • Numéro de série vide refusé même pour un nouveau boîtier ; fiche existante avec noSerie NULL refusée.
  • Une requête SELECT 1 FROM boitiers WHERE id = ? par trame, sans cache, pour qu'une fiche supprimée cesse d'être alimentée immédiatement.

Hors périmètre

Journalisation du payload (L5) ; authentification de l'émetteur et ACL firstco-%u (L6) ; poller et suivi de version jusqu'à l'ACK (L5) ; documentation générale (L9).

Retour arrière

Revenir les 3 commits du lot (git revert) et redéployer ensemble serveur et simulateur : aucun schéma ni donnée n'est modifié.


Pile de PR

Base de cette PR : pr/dette-l2. Fusion dans l'ordre de la pile ; apres chaque fusion, vigie.py retarget rebase la PR suivante sur main.

  1. #2 pr/dette-l2 : Dette passe 1, lot L2 : contrats partagés dans aquashared et client MQTT du boîtier
  2. #3 pr/dette-l3 : Dette passe 1, lot L3 : provisioning non destructif et identifiant validé à l'entrée MQTT (cette PR)
  3. #4 pr/dette-l4 : Dette passe 1, lot L4 : mesures, alertes et déduplication
  4. #5 pr/dette-l5 : Dette passe 1, lot L5 : boucle MQTT du serveur, livraison de la configuration et journaux
  5. #6 pr/dette-l4bis : Dette passe 1, lot L4-bis : alertes même si l'écriture échoue, délai unique de 600 s, typage des capteurs, ordre des accusés MQTT
  6. #7 pr/dette-l7 : Dette passe 1, lot L7 : builds reproductibles, dépendances, durcissement des conteneurs
  7. #8 pr/dette-l6 : Dette passe 1, lot L6 : authentification Mosquitto, ACL par compte, healthchecks réels, pile e2e isolée
  8. #9 pr/dette-l8 : Dette passe 1, lot L8 : tests non destructifs, fixtures synthétiques, script de tests sur base jetable

Commits du lot

  • 2d7ae74 fix: stop firstco from rewriting a provisioned boitier
  • bec3b01 fix: validate the boitier id once at the MQTT entry
  • 3c8c48e feat: publish firstco on firstco-{id} from the boitier simulator
## Objet Lot L3 de la passe 1 de dette : un `firstco` ne réécrit plus jamais un boîtier déjà provisionné, et l'identifiant de boîtier est validé une seule fois, à l'entrée MQTT, par `aquashared::Topic::parse`. ## Dettes traitées et preuves ### N1, V2, Q6, recommandation 5 (partie `firstco`), décision D4 Avant : `handle_first_connect` faisait un UPSERT complet de `boitiers` puis des capteurs ; un `firstco` forgé réécrivait la fiche et faisait pousser par le poller les commandes forgées au vrai boîtier. | Étape | Comportement | Preuve | |---|---|---| | Contrôles sans base | id canonique non nul, id du topic `firstco-{id}` égal à celui du payload, numéro de série non vide | `aquaserveur/src/provisioning/handler.rs`, `error.rs` | | Fiche présente, même numéro de série | `AlreadyProvisioned` : aucune écriture (fiche, `updated_at`, capteurs, seuils) | `handler.rs` | | Fiche présente, autre numéro ou numéro absent | `SerialMismatch` : refus, aucune écriture | `handler.rs` | | Fiche absente | provisionnement complet, `INSERT` simple au lieu de l'UPSERT | `handler.rs` | Côté `main.rs` : succès (nouveau ou déjà provisionné) suivi de l'ACK `{"status":"ok","id_boitier":N}` sur `firstco_ACK-{id}` (format inchangé) ; boîtier déjà provisionné : republication de la configuration du serveur sur `maj_serv-{id}` ; refus : journal d'avertissement, aucun ACK, sans numéro de série ni donnée client. ### Décision D3 : `firstco-{id}` Routage de `firstco-{id}` avec contrôle d'égalité entre l'id du topic et celui du payload avant toute requête ; le topic historique `firstco` reste accepté ; le simulateur de boîtier publie sur `firstco-{id}`. ### Recommandation 5, N9, Q7 : routage et id | Point | Avant | Après | |---|---|---| | Routage | préfixes et `split_once`, id en `String` (`data-4000-extra` accepté) | `Topic::parse`, `BoitierId` validé une fois (`mqtt/subscriber.rs`) | | `data-04000`, `data-foo`, `data-0` | stockage refusé mais alertes écrites pour 4000 | `InvalidTopic`, aucun handler appelé | | `store_frame`, `handle_config_ack` | `&str` revalidé, `parse::<u64>`, repli `unwrap_or(0)` | `BoitierId` | | Boîtier inconnu | aucune vérification | `boitier_exists` avant analyse, stockage et alertes (`database/store.rs`, `data_handler.rs`) | La branche `data` sort de `main.rs` vers `aquaserveur/src/data_handler.rs` pour être testable ; contenu déplacé tel quel, seules la clé (`BoitierId`) et la vérification d'existence changent. ## Tests et résultats - TDD : `firstco_non_destructive.rs` 6 échecs sur 7 avant implémentation ; routeur, contrat et `data_canonical_id.rs` vus rouges ; contre-épreuve : contrôle d'existence neutralisé, le test du boîtier inconnu échoue. - Ajouts : `aquaserveur/tests/firstco_non_destructive.rs` (7 + 4 ignorés), `aquaserveur/tests/data_canonical_id.rs` (2 + 2 ignorés), tests du routeur, test de contrat sur `firstco-4990`. - `cargo fmt --check`, `cargo clippy ... -D warnings` : OK. - `cargo test --workspace` : 242 passés, 0 échec, 45 ignorés (L2 : 233 et 39). - Tests ignorés sur MariaDB 10.11 jetable : 45 passés, 0 échec, deux passages consécutifs. - Vérification réelle (Mosquitto et serveur jetables) : nouveau boîtier provisionné ; rejeu forgé avec le même numéro sans aucune écriture, ACK et configuration de l'opérateur republiée ; autre numéro et id croisé refusés sans ACK ; `data` d'un boîtier inconnu sans écriture ni alerte. ## Migrations et actions de déploiement - Aucune migration de base. - Serveur et simulateur de boîtier à déployer ensemble (le simulateur publie sur `firstco-{id}`, que l'ancien serveur ne route pas). - Reprovisionnement désormais par action opérateur (documenté dans `handle_first_connect`) : carte remplacée, mettre à jour `boitiers.noSerie` ; capteurs modifiés, mettre à jour `{id}_boitier_capteurs` et les seuils ; reprise complète, supprimer la fiche et ses dépendances. ## Choix faits - Refus sans ACK plutôt qu'un ACK `status: error` : le comportement historique (passerelle Node.js) traite tout message reçu comme un succès ; l'absence d'ACK est le seul format qu'aucun récepteur ne peut confondre avec un succès. - Republication de la configuration du serveur après un `firstco` idempotent. - Numéro de série vide refusé même pour un nouveau boîtier ; fiche existante avec `noSerie` NULL refusée. - Une requête `SELECT 1 FROM boitiers WHERE id = ?` par trame, sans cache, pour qu'une fiche supprimée cesse d'être alimentée immédiatement. ## Hors périmètre Journalisation du payload (L5) ; authentification de l'émetteur et ACL `firstco-%u` (L6) ; poller et suivi de version jusqu'à l'ACK (L5) ; documentation générale (L9). ## Retour arrière Revenir les 3 commits du lot (`git revert`) et redéployer ensemble serveur et simulateur : aucun schéma ni donnée n'est modifié. --- ### Pile de PR Base de cette PR : `pr/dette-l2`. Fusion dans l'ordre de la pile ; apres chaque fusion, `vigie.py retarget` rebase la PR suivante sur `main`. 1. #2 `pr/dette-l2` : Dette passe 1, lot L2 : contrats partagés dans aquashared et client MQTT du boîtier 2. #3 `pr/dette-l3` : Dette passe 1, lot L3 : provisioning non destructif et identifiant validé à l'entrée MQTT (cette PR) 3. #4 `pr/dette-l4` : Dette passe 1, lot L4 : mesures, alertes et déduplication 4. #5 `pr/dette-l5` : Dette passe 1, lot L5 : boucle MQTT du serveur, livraison de la configuration et journaux 5. #6 `pr/dette-l4bis` : Dette passe 1, lot L4-bis : alertes même si l'écriture échoue, délai unique de 600 s, typage des capteurs, ordre des accusés MQTT 6. #7 `pr/dette-l7` : Dette passe 1, lot L7 : builds reproductibles, dépendances, durcissement des conteneurs 7. #8 `pr/dette-l6` : Dette passe 1, lot L6 : authentification Mosquitto, ACL par compte, healthchecks réels, pile e2e isolée 8. #9 `pr/dette-l8` : Dette passe 1, lot L8 : tests non destructifs, fixtures synthétiques, script de tests sur base jetable ### Commits du lot - `2d7ae74` fix: stop firstco from rewriting a provisioned boitier - `bec3b01` fix: validate the boitier id once at the MQTT entry - `3c8c48e` feat: publish firstco on firstco-{id} from the boitier simulator <!-- vigie:stack -->
A firstco for an id already present in boitiers no longer writes
anything: client fields, generator commands, versionSoftware,
updated_at, sensors and alert seeds stay as the operator left them.
The same serial number is an idempotent success: the server sends the
historical ACK and republishes its own configuration on maj_serv. A
different or missing serial number is refused with a warning that
carries no serial number nor client data, and no ACK.

The server also accepts firstco-{id} and refuses a payload whose id
differs from the topic id, before any query. The historical firstco
topic stays accepted. A new boitier is still provisioned, with a plain
INSERT that cannot overwrite a concurrent row.

Fixes N1, V2 and Q6 (decisions D3 and D4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UH3JcaVWsmFLC6kyEsVL18
The router now parses every topic with aquashared::Topic::parse, so the
id goes through BoitierId::parse exactly once. data-04000, data-+4000,
data-foo and non canonical maj_boitier_ACK ids are rejected before any
handler; store_frame and handle_config_ack receive a BoitierId instead
of re-parsing text.

A data frame is processed only if the boitier has a row in boitiers:
an unknown id produces no measure, no alert and no in-memory
evaluation. The data branch moves out of main.rs into data_handler,
whose legionella cooldown and threshold cache are keyed by the
canonical id, so aliases can no longer bypass the cooldown.

Fixes recommendation 5, N9 and Q7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UH3JcaVWsmFLC6kyEsVL18
feat: publish firstco on firstco-{id} from the boitier simulator
Some checks failed
aquaprocess/revue-statique echec : emoji dans le code ajoute, 1 constat(s) bloquant(s) de la revue
3c8c48e78b
The Rust boitier now publishes its firstco payload on firstco-{id}, so
the server can check that the topic id matches the payload id
(decision D3) and a future ACL can bind the topic to the account. The
global firstco topic stays accepted by the server for the ESP32
firmware kept outside the repository.

The contract test checks the whole path: the topic published by the
boitier session is routed with its canonical id and accepted by the
server checks. The S7 E2E scripts, the S7 plan and the boitier README
listen on and document the new topic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UH3JcaVWsmFLC6kyEsVL18
Thomas left a comment

Revue statique Vigie : PR #3

Tete 3c8c48e78b, base pr/dette-l2 (55fc8050c3), 3 commit(s), 21 fichier(s) ajoutes ou modifies.

Statut aquaprocess/revue-statique : failure (emoji dans le code ajoute; 1 constat(s) bloquant(s) de la revue)

Constats : 1 bloquant, 4 majeur, 11 mineur, 0 style. Commentaires en ligne : 4 (mode important, plafond 10).

Verifications automatiques

Verification Resultat Detail
cargo fmt --check success aucune difference
cargo clippy -D warnings success aucun avertissement
cargo test --workspace success 242 passes, 0 echec(s), 45 ignores
tests ignores (base jetable) success 45 passes, 0 echec(s) ; pas de broker MQTT (tests sans TEST_MQTT_URL) ; dump charge en 27 s ; 1 fichier(s) de fixtures ; migrations du depot : aucune
cargo audit attention introduite(s) : aucune ; deja presente(s) sur la base : RUSTSEC-2026-0049 (rustls-webpki), RUSTSEC-2026-0098 (rustls-webpki), RUSTSEC-2026-0099 (rustls-webpki), RUSTSEC-2026-0104 (rustls-webpki), RUSTSEC-2026-0258 (h2), RUSTSEC-2026-0285 (rustls), 2 ignoree(s) par audit.toml
semgrep (regles du depot) success 17 fichier(s) ; lignes ajoutees : 7 resultat(s) hors tests, tests : no-unwrap-in-production x41, sqlx-no-format-in-query x8 ; 146 sur lignes inchangees
gitleaks (plage de commits) success 3 commit(s) analyses, aucune fuite
format des commits success 3 commit(s) conformes
emoji dans le code ajoute failure 1 ligne(s) ajoutee(s) avec emoji

Analyse qualitative

Verdict : 1 bloquant, 3 majeurs. Le lot ferme bien la réécriture d'une fiche provisionnée par firstco et valide l'id une seule fois à l'entrée MQTT, mais le passage de l'UPSERT à un INSERT suivi d'un « déjà provisionné » sans écriture rend définitif tout provisionnement interrompu.

Sécurité

  • Routage : aquashared::Topic::parse et BoitierId::parse (chiffres seuls, ni signe, ni zéro de tête, non nul, borne u32) remplacent split_once et parse::<u64>. Le nom de table {id}_boitier_* ne peut plus recevoir que des chiffres par construction ; fetch_boitier_config n'assemble que des littéraux et lie l'id. Aucune injection relevée.
  • firstco ne réécrit plus une fiche existante : aucune requête avant check_first_connect (handler.rs:250-268).
  • Effet de bord de D4 (aquaserveur/src/provisioning/handler.rs:264) : un firstco forgé sur un id encore libre crée la fiche avec un numéro de série choisi par l'émetteur, et le vrai boîtier est ensuite refusé définitivement. Avant L3, il écrasait la fiche. Sans authentification (L6), c'est une prise de possession d'id à traiter avant la production.
  • Le contrôle D3 est contournable par le topic historique firstco, routé sans id (aquaserveur/src/mqtt/subscriber.rs:66). C'est assumé par le compte rendu, mais la protection ne vaut que pour des clients qui utilisent firstco-{id}.
  • Journaux : aquaserveur/src/provisioning/error.rs:7 promet des messages sans donnée client, ce que ParseError (texte serde) ne garantit pas. Le payload complet reste journalisé à main.rs:143 (renvoyé à L5 par le compte rendu).
  • Mémoire : main.rs:184 recharge les règles d'alerte à chaque firstco accepté et register_rule les ajoute sans dédoublonner : tout rejeu avec le bon numéro de série fait croître la mémoire sans borne et duplique les alertes (corrigé ensuite par replace_rules en L4).

Correction

  • Bloquant aquaserveur/src/provisioning/handler.rs:335 : la fiche boitiers est écrite en autocommit avant les DDL, la transaction des capteurs et les seuils. Un échec ou un redémarrage entre ces étapes laisse une fiche sans tables ; le rejeu du boîtier obtient alors AlreadyProvisioned et l'ACK de succès, puis chaque trame échoue dans store_frame (le contrôle boitier_exists passe). L'ancien UPSERT rendait ce rejeu réparateur. Piste : écrire la fiche en dernier, ou compléter les dépendances manquantes (DDL idempotents) sur le chemin « même numéro ».
  • main.rs:201 ajoute une seconde publication dans la boucle select!, alors que main.rs:93-96 explique que publier depuis cette boucle peut la bloquer (canal borné à 100, vidé par eventloop.poll()). Le risque existait pour l'ACK ; il est doublé ici (la boucle est découplée en L5).
  • main.rs:212 confond refus et panne de base dans un même avertissement ; main.rs:205 ignore sans trace l'absence de configuration à renvoyer.
  • handler.rs:203 compare les numéros de série à l'octet près alors que le contrôle de présence rogne les espaces.
  • data_handler.rs reprend fidèlement l'ancienne branche (ordre, TTL de 300 s, repli (5000, 10000)). Les tables d'état sont indexées par BoitierId et ne grossissent plus que pour des boîtiers existants. Le SELECT d'existence précède l'analyse de la trame (data_handler.rs:109) : une requête par message, même illisible.
  • Aucun unwrap ni expect ajouté dans le code de production du diff.

Cohérence avec le compte rendu

Affirmations vérifiées à la tête 3c8c48e : les références handler.rs, error.rs, main.rs, config_poller.rs:155, subscriber.rs:63-81, store.rs:90 et :112, config_ack_handler.rs:74 et :96, data_handler.rs:41, :43, :109, session.rs:181 pointent sur le code décrit ; MissingNoSerie n'était levée nulle part avant le lot ; l'UPSERT boitiers est remplacé par un INSERT simple ; extract_boitier_id est supprimée ; décomptes de tests exacts (7 + 4 ignorés dans firstco_non_destructive.rs, 2 + 2 dans data_canonical_id.rs) ; simulateur, README, scripts S7 et plan E2E publiés sur firstco-{id}.

Écarts :

  • « Provisionnement complet inchangé » : vrai sur le chemin nominal, faux en cas d'échec intermédiaire (constat bloquant).
  • « AlreadyProvisioned : aucune écriture » : vrai en base, mais l'état en mémoire est modifié (règles dupliquées, main.rs:184).
  • « Messages sans numéro de série ni donnée client (test dédié) » : le test ne couvre que SerialMismatch ; le compte rendu reconnaît lui-même que serde peut citer un fragment.

Tests

  • Les refus sans base utilisent un pool injoignable : l'erreur métier obtenue prouve qu'aucune requête n'a été tentée. Bonne technique, reprise dans data_canonical_id.rs.
  • firstco_non_destructive.rs vérifie la fiche colonne par colonne, les capteurs et le nombre d'alertes. Il ne couvre ni le provisionnement interrompu, ni la fiche insérée entre la lecture et l'INSERT (firstco_non_destructive.rs:363), ni la branche de main.rs (ACK, republication, rechargement des règles).
  • e2e_t3.rs:74 est devenu une assertion unitaire tautologique dans un test qui demande MariaDB.
  • s7-firstco-skip.sh:34 n'écoute plus le topic historique.

Questions ouvertes

  • Les boîtiers existants en production ont-ils tous un noSerie renseigné et identique à celui de leur firmware ? Sinon, chacun sera refusé sans ACK et republiera toutes les 300 s.
  • Au redémarrage du serveur, les règles d'alerte des boîtiers existants ne sont chargées qu'à réception d'un firstco (comportement antérieur au lot, corrigé en L4 par un chargement global).
  • « Aucun ACK sur refus » se défend pour le firmware historique, mais un boîtier légitime refusé ne le sait pas : faut-il un signal opérateur (alerte en base) ?
  • Le firmware ESP32 hors dépôt publie-t-il sur firstco ou firstco-{id} ? Cela fixe la date de fermeture du topic historique.

Autres constats (non publies en ligne)

Gravite Emplacement Source Constat
majeur aquaserveur/src/main.rs:201 revue Nouvelle publication (maj_serv-{id}) faite dans la branche du select! principal, après celle de l'ACK. client.publish attend une place dans le canal borné (capacité 100) que seul eventloop.poll() vide ; main.rs:93-96 documente précisément ce blocage, que le poller évite en tournant dans sa propre tâche. Si le canal est plein (poller au démarrage avec beaucoup de boîtiers V4), la boucle se bloque définitivement. Piste : try_publish, ou déléguer les publications à une tâche ou un canal dédié. (non publie en ligne : Forgejo l'ancrerait a la ligne 237 du commit 2d7ae74, decalee dans la vue de la PR)
mineur aquaserveur/src/data_handler.rs:109 revue Le contrôle d'existence (une requête SQL) précède l'analyse : une trame illisible sur data-{id connu} coûte quand même un aller-retour base dans la boucle unique, ce qui amplifie un flot de trames invalides. Piste : séparer l'analyse (pure) de l'évaluation des alertes et analyser avant le SELECT, éventuellement avec un cache court des ids existants.
mineur aquaserveur/src/database/store.rs:413 semgrep semgrep no-unwrap-in-production (WARNING) : .unwrap() ou .expect() peut paniquer en production. 6 occurrence(s) sur lignes ajoutees (413, 413, 450, 450, 494, 494), y compris d'eventuels modules de test internes.
mineur aquaserveur/src/main.rs:205 revue Ok(None) est ignoré sans journal : un boîtier déjà provisionné hors du filtre de fetch_boitier_config (id < 4000 ou updated_at NULL) reçoit l'ACK mais aucune configuration, sans trace. Piste : journaliser ce cas.
mineur aquaserveur/src/main.rs:212 revue Refus métier (TopicIdMismatch, SerialMismatch) et incidents (DbError) sont journalisés de la même façon, en avertissement « non accepté ». Une panne base passe pour un refus, et un SerialMismatch légitime (carte remplacée) ne laisse aucune trace exploitable par l'opérateur alors que le boîtier republie indéfiniment. Piste : distinguer les niveaux et signaler le SerialMismatch à l'opérateur (alerte en base sans numéro de série).
mineur aquaserveur/src/mqtt/subscriber.rs:66 revue Le topic historique firstco reste routé avec topic_id = None, donc sans contrôle D3 : un émetteur malveillant contourne la vérification id du topic / id du payload simplement en publiant sur firstco. Le contrôle D3 ne protège donc que des clients bien élevés tant que l'ACL L6 n'est pas en place ; à documenter comme tel ou à prévoir une date de fermeture du topic historique.
mineur aquaserveur/src/mqtt/subscriber.rs:90 semgrep semgrep no-unwrap-in-production (WARNING) : .unwrap() ou .expect() peut paniquer en production. 1 occurrence(s) sur lignes ajoutees (90), y compris d'eventuels modules de test internes.
mineur aquaserveur/src/provisioning/error.rs:7 revue La documentation affirme que les messages d'erreur ne contiennent ni numéro de série ni donnée client, mais ParseError(String) reprend le texte de serde_json, qui peut citer une valeur du payload (par exemple « invalid type: string "..." »), et ce message est journalisé à main.rs:212. Le test dédié ne couvre que SerialMismatch. Piste : réduire ParseError à la catégorie et à la position de l'erreur, et tester ce cas.
mineur aquaserveur/src/provisioning/handler.rs:203 revue Comparaison exacte des numéros de série alors que check_first_connect (handler.rs:189) raisonne sur la valeur rognée : un numéro saisi par l'opérateur avec un espace final ou une casse différente provoque un refus définitif. Piste : normaliser de la même manière à l'écriture et à la comparaison, ou refuser explicitement les espaces en bordure.
mineur aquaserveur/tests/e2e_t3.rs:74 revue Le test vérifie seulement que BoitierId::parse refuse la chaîne, ce qui ne demande pas de base, puis contrôle une table que rien n'a pu toucher : il reste marqué ignoré (MariaDB requise) pour une assertion purement unitaire. Piste : le remplacer par une assertion sur route_message("data-4000; DROP ...") dans les tests non ignorés et supprimer la partie base.
mineur aquaserveur/tests/firstco_non_destructive.rs:363 revue Aucun test ne couvre l'échec en cours de provisionnement (fiche insérée, tables ou capteurs manquants, puis rejeu) ni le cas d'une fiche apparue entre la lecture et l'INSERT, présenté comme sûr dans la documentation (handler.rs:275-276). Ce sont précisément les chemins où le nouveau comportement diffère de l'UPSERT. Piste : test ignoré qui supprime une table après provisionnement puis rejoue firstco, et test qui insère la fiche entre les deux étapes.
mineur e2e/boitier/s7-firstco-skip.sh:34 revue Le test de non-publication n'écoute que firstco-{id} : une régression qui republierait sur le topic historique firstco passerait inaperçue. Piste : écouter aussi firstco (deux souscriptions) pendant la fenêtre de 5 s.

Limites

Revue publiee en COMMENT : l'auteur des PR et le relecteur sont le meme compte, Forgejo refuse APPROVE et REQUEST_CHANGES. Le blocage s'exprime par le statut de commit aquaprocess/revue-statique. Analyse statique et tests automatises seulement, sans fusion ni deploiement.

## Revue statique Vigie : PR #3 Tete `3c8c48e78b`, base `pr/dette-l2` (`55fc8050c3`), 3 commit(s), 21 fichier(s) ajoutes ou modifies. **Statut `aquaprocess/revue-statique` : failure** (emoji dans le code ajoute; 1 constat(s) bloquant(s) de la revue) Constats : 1 bloquant, 4 majeur, 11 mineur, 0 style. Commentaires en ligne : 4 (mode `important`, plafond 10). ### Verifications automatiques | Verification | Resultat | Detail | |---|---|---| | cargo fmt --check | success | aucune difference | | cargo clippy -D warnings | success | aucun avertissement | | cargo test --workspace | success | 242 passes, 0 echec(s), 45 ignores | | tests ignores (base jetable) | success | 45 passes, 0 echec(s) ; pas de broker MQTT (tests sans TEST_MQTT_URL) ; dump charge en 27 s ; 1 fichier(s) de fixtures ; migrations du depot : aucune | | cargo audit | attention | introduite(s) : aucune ; deja presente(s) sur la base : RUSTSEC-2026-0049 (rustls-webpki), RUSTSEC-2026-0098 (rustls-webpki), RUSTSEC-2026-0099 (rustls-webpki), RUSTSEC-2026-0104 (rustls-webpki), RUSTSEC-2026-0258 (h2), RUSTSEC-2026-0285 (rustls), 2 ignoree(s) par audit.toml | | semgrep (regles du depot) | success | 17 fichier(s) ; lignes ajoutees : 7 resultat(s) hors tests, tests : no-unwrap-in-production x41, sqlx-no-format-in-query x8 ; 146 sur lignes inchangees | | gitleaks (plage de commits) | success | 3 commit(s) analyses, aucune fuite | | format des commits | success | 3 commit(s) conformes | | emoji dans le code ajoute | failure | 1 ligne(s) ajoutee(s) avec emoji | ### Analyse qualitative Verdict : 1 bloquant, 3 majeurs. Le lot ferme bien la réécriture d'une fiche provisionnée par `firstco` et valide l'id une seule fois à l'entrée MQTT, mais le passage de l'UPSERT à un INSERT suivi d'un « déjà provisionné » sans écriture rend définitif tout provisionnement interrompu. ### Sécurité - Routage : `aquashared::Topic::parse` et `BoitierId::parse` (chiffres seuls, ni signe, ni zéro de tête, non nul, borne u32) remplacent `split_once` et `parse::<u64>`. Le nom de table `{id}_boitier_*` ne peut plus recevoir que des chiffres par construction ; `fetch_boitier_config` n'assemble que des littéraux et lie l'id. Aucune injection relevée. - `firstco` ne réécrit plus une fiche existante : aucune requête avant `check_first_connect` (`handler.rs:250-268`). - Effet de bord de D4 (`aquaserveur/src/provisioning/handler.rs:264`) : un `firstco` forgé sur un id encore libre crée la fiche avec un numéro de série choisi par l'émetteur, et le vrai boîtier est ensuite refusé définitivement. Avant L3, il écrasait la fiche. Sans authentification (L6), c'est une prise de possession d'id à traiter avant la production. - Le contrôle D3 est contournable par le topic historique `firstco`, routé sans id (`aquaserveur/src/mqtt/subscriber.rs:66`). C'est assumé par le compte rendu, mais la protection ne vaut que pour des clients qui utilisent `firstco-{id}`. - Journaux : `aquaserveur/src/provisioning/error.rs:7` promet des messages sans donnée client, ce que `ParseError` (texte serde) ne garantit pas. Le payload complet reste journalisé à `main.rs:143` (renvoyé à L5 par le compte rendu). - Mémoire : `main.rs:184` recharge les règles d'alerte à chaque `firstco` accepté et `register_rule` les ajoute sans dédoublonner : tout rejeu avec le bon numéro de série fait croître la mémoire sans borne et duplique les alertes (corrigé ensuite par `replace_rules` en L4). ### Correction - Bloquant `aquaserveur/src/provisioning/handler.rs:335` : la fiche `boitiers` est écrite en autocommit avant les DDL, la transaction des capteurs et les seuils. Un échec ou un redémarrage entre ces étapes laisse une fiche sans tables ; le rejeu du boîtier obtient alors `AlreadyProvisioned` et l'ACK de succès, puis chaque trame échoue dans `store_frame` (le contrôle `boitier_exists` passe). L'ancien UPSERT rendait ce rejeu réparateur. Piste : écrire la fiche en dernier, ou compléter les dépendances manquantes (DDL idempotents) sur le chemin « même numéro ». - `main.rs:201` ajoute une seconde publication dans la boucle `select!`, alors que `main.rs:93-96` explique que publier depuis cette boucle peut la bloquer (canal borné à 100, vidé par `eventloop.poll()`). Le risque existait pour l'ACK ; il est doublé ici (la boucle est découplée en L5). - `main.rs:212` confond refus et panne de base dans un même avertissement ; `main.rs:205` ignore sans trace l'absence de configuration à renvoyer. - `handler.rs:203` compare les numéros de série à l'octet près alors que le contrôle de présence rogne les espaces. - `data_handler.rs` reprend fidèlement l'ancienne branche (ordre, TTL de 300 s, repli `(5000, 10000)`). Les tables d'état sont indexées par `BoitierId` et ne grossissent plus que pour des boîtiers existants. Le SELECT d'existence précède l'analyse de la trame (`data_handler.rs:109`) : une requête par message, même illisible. - Aucun `unwrap` ni `expect` ajouté dans le code de production du diff. ### Cohérence avec le compte rendu Affirmations vérifiées à la tête `3c8c48e` : les références `handler.rs`, `error.rs`, `main.rs`, `config_poller.rs:155`, `subscriber.rs:63-81`, `store.rs:90` et `:112`, `config_ack_handler.rs:74` et `:96`, `data_handler.rs:41`, `:43`, `:109`, `session.rs:181` pointent sur le code décrit ; `MissingNoSerie` n'était levée nulle part avant le lot ; l'UPSERT `boitiers` est remplacé par un INSERT simple ; `extract_boitier_id` est supprimée ; décomptes de tests exacts (7 + 4 ignorés dans `firstco_non_destructive.rs`, 2 + 2 dans `data_canonical_id.rs`) ; simulateur, README, scripts S7 et plan E2E publiés sur `firstco-{id}`. Écarts : - « Provisionnement complet inchangé » : vrai sur le chemin nominal, faux en cas d'échec intermédiaire (constat bloquant). - « AlreadyProvisioned : aucune écriture » : vrai en base, mais l'état en mémoire est modifié (règles dupliquées, `main.rs:184`). - « Messages sans numéro de série ni donnée client (test dédié) » : le test ne couvre que `SerialMismatch` ; le compte rendu reconnaît lui-même que serde peut citer un fragment. ### Tests - Les refus sans base utilisent un pool injoignable : l'erreur métier obtenue prouve qu'aucune requête n'a été tentée. Bonne technique, reprise dans `data_canonical_id.rs`. - `firstco_non_destructive.rs` vérifie la fiche colonne par colonne, les capteurs et le nombre d'alertes. Il ne couvre ni le provisionnement interrompu, ni la fiche insérée entre la lecture et l'INSERT (`firstco_non_destructive.rs:363`), ni la branche de `main.rs` (ACK, republication, rechargement des règles). - `e2e_t3.rs:74` est devenu une assertion unitaire tautologique dans un test qui demande MariaDB. - `s7-firstco-skip.sh:34` n'écoute plus le topic historique. ### Questions ouvertes - Les boîtiers existants en production ont-ils tous un `noSerie` renseigné et identique à celui de leur firmware ? Sinon, chacun sera refusé sans ACK et republiera toutes les 300 s. - Au redémarrage du serveur, les règles d'alerte des boîtiers existants ne sont chargées qu'à réception d'un `firstco` (comportement antérieur au lot, corrigé en L4 par un chargement global). - « Aucun ACK sur refus » se défend pour le firmware historique, mais un boîtier légitime refusé ne le sait pas : faut-il un signal opérateur (alerte en base) ? - Le firmware ESP32 hors dépôt publie-t-il sur `firstco` ou `firstco-{id}` ? Cela fixe la date de fermeture du topic historique. ### Autres constats (non publies en ligne) | Gravite | Emplacement | Source | Constat | |---|---|---|---| | majeur | `aquaserveur/src/main.rs:201` | revue | Nouvelle publication (maj_serv-{id}) faite dans la branche du select! principal, après celle de l'ACK. client.publish attend une place dans le canal borné (capacité 100) que seul eventloop.poll() vide ; main.rs:93-96 documente précisément ce blocage, que le poller évite en tournant dans sa propre tâche. Si le canal est plein (poller au démarrage avec beaucoup de boîtiers V4), la boucle se bloque définitivement. Piste : try_publish, ou déléguer les publications à une tâche ou un canal dédié. (non publie en ligne : Forgejo l'ancrerait a la ligne 237 du commit 2d7ae74, decalee dans la vue de la PR) | | mineur | `aquaserveur/src/data_handler.rs:109` | revue | Le contrôle d'existence (une requête SQL) précède l'analyse : une trame illisible sur data-{id connu} coûte quand même un aller-retour base dans la boucle unique, ce qui amplifie un flot de trames invalides. Piste : séparer l'analyse (pure) de l'évaluation des alertes et analyser avant le SELECT, éventuellement avec un cache court des ids existants. | | mineur | `aquaserveur/src/database/store.rs:413` | semgrep | semgrep `no-unwrap-in-production` (WARNING) : `.unwrap()` ou `.expect()` peut paniquer en production. 6 occurrence(s) sur lignes ajoutees (413, 413, 450, 450, 494, 494), y compris d'eventuels modules de test internes. | | mineur | `aquaserveur/src/main.rs:205` | revue | Ok(None) est ignoré sans journal : un boîtier déjà provisionné hors du filtre de fetch_boitier_config (id < 4000 ou updated_at NULL) reçoit l'ACK mais aucune configuration, sans trace. Piste : journaliser ce cas. | | mineur | `aquaserveur/src/main.rs:212` | revue | Refus métier (TopicIdMismatch, SerialMismatch) et incidents (DbError) sont journalisés de la même façon, en avertissement « non accepté ». Une panne base passe pour un refus, et un SerialMismatch légitime (carte remplacée) ne laisse aucune trace exploitable par l'opérateur alors que le boîtier republie indéfiniment. Piste : distinguer les niveaux et signaler le SerialMismatch à l'opérateur (alerte en base sans numéro de série). | | mineur | `aquaserveur/src/mqtt/subscriber.rs:66` | revue | Le topic historique firstco reste routé avec topic_id = None, donc sans contrôle D3 : un émetteur malveillant contourne la vérification id du topic / id du payload simplement en publiant sur firstco. Le contrôle D3 ne protège donc que des clients bien élevés tant que l'ACL L6 n'est pas en place ; à documenter comme tel ou à prévoir une date de fermeture du topic historique. | | mineur | `aquaserveur/src/mqtt/subscriber.rs:90` | semgrep | semgrep `no-unwrap-in-production` (WARNING) : `.unwrap()` ou `.expect()` peut paniquer en production. 1 occurrence(s) sur lignes ajoutees (90), y compris d'eventuels modules de test internes. | | mineur | `aquaserveur/src/provisioning/error.rs:7` | revue | La documentation affirme que les messages d'erreur ne contiennent ni numéro de série ni donnée client, mais ParseError(String) reprend le texte de serde_json, qui peut citer une valeur du payload (par exemple « invalid type: string "..." »), et ce message est journalisé à main.rs:212. Le test dédié ne couvre que SerialMismatch. Piste : réduire ParseError à la catégorie et à la position de l'erreur, et tester ce cas. | | mineur | `aquaserveur/src/provisioning/handler.rs:203` | revue | Comparaison exacte des numéros de série alors que check_first_connect (handler.rs:189) raisonne sur la valeur rognée : un numéro saisi par l'opérateur avec un espace final ou une casse différente provoque un refus définitif. Piste : normaliser de la même manière à l'écriture et à la comparaison, ou refuser explicitement les espaces en bordure. | | mineur | `aquaserveur/tests/e2e_t3.rs:74` | revue | Le test vérifie seulement que BoitierId::parse refuse la chaîne, ce qui ne demande pas de base, puis contrôle une table que rien n'a pu toucher : il reste marqué ignoré (MariaDB requise) pour une assertion purement unitaire. Piste : le remplacer par une assertion sur route_message("data-4000; DROP ...") dans les tests non ignorés et supprimer la partie base. | | mineur | `aquaserveur/tests/firstco_non_destructive.rs:363` | revue | Aucun test ne couvre l'échec en cours de provisionnement (fiche insérée, tables ou capteurs manquants, puis rejeu) ni le cas d'une fiche apparue entre la lecture et l'INSERT, présenté comme sûr dans la documentation (handler.rs:275-276). Ce sont précisément les chemins où le nouveau comportement diffère de l'UPSERT. Piste : test ignoré qui supprime une table après provisionnement puis rejoue firstco, et test qui insère la fiche entre les deux étapes. | | mineur | `e2e/boitier/s7-firstco-skip.sh:34` | revue | Le test de non-publication n'écoute que firstco-{id} : une régression qui republierait sur le topic historique firstco passerait inaperçue. Piste : écouter aussi firstco (deux souscriptions) pendant la fenêtre de 5 s. | ### Limites Revue publiee en `COMMENT` : l'auteur des PR et le relecteur sont le meme compte, Forgejo refuse APPROVE et REQUEST_CHANGES. Le blocage s'exprime par le statut de commit `aquaprocess/revue-statique`. Analyse statique et tests automatises seulement, sans fusion ni deploiement. <!-- vigie:review -->
@ -197,0 +160,4 @@
" [WARN] data-{} : boîtier inconnu, trame ignorée (ni écriture ni alerte)",
boitier_id
),
Err(e) => eprintln!(" ❌ data-{}: {}", boitier_id, e),
Author
Member

[majeur] Caractere emoji U+274C dans une ligne ajoutee (regle IA-23).

Source : emoji.

**[majeur]** Caractere emoji U+274C dans une ligne ajoutee (regle IA-23). _Source : emoji._
@ -210,2 +182,3 @@
// chargées en mémoire pour evaluate_sensor_value.
let device_key = id.to_string();
match load_alert_rules_from_db(id as i64, &pool, &mut alert_manager, &device_key).await {
match load_alert_rules_from_db(i64::from(id.get()), &pool, &mut data_ctx.alert_manager, &device_key).await {
Author
Member

[majeur] Chaque firstco accepté, y compris le chemin idempotent AlreadyProvisioned, recharge les règles d'alerte via load_alert_rules_from_db, qui appelle register_rule ; celle-ci ajoute au vecteur sans dédoublonner (alert_manager.rs:103-106). Chaque rejeu duplique donc les règles en mémoire (croissance non bornée, alertes multipliées dans evaluate_sensor_value). Piste : remplacer les règles du boîtier (clear puis chargement) ou ne charger que sur Provisioned.

Source : revue.

**[majeur]** Chaque firstco accepté, y compris le chemin idempotent AlreadyProvisioned, recharge les règles d'alerte via load_alert_rules_from_db, qui appelle register_rule ; celle-ci ajoute au vecteur sans dédoublonner (alert_manager.rs:103-106). Chaque rejeu duplique donc les règles en mémoire (croissance non bornée, alertes multipliées dans evaluate_sensor_value). Piste : remplacer les règles du boîtier (clear puis chargement) ou ne charger que sur Provisioned. _Source : revue._
@ -185,0 +261,4 @@
Some((stored_serial,)) => {
check_existing_serial(id, stored_serial.as_deref(), &payload.boitier.no_serie)
}
None => {
Author
Member

[majeur] Premier arrivé, premier servi : tout client MQTT peut publier un firstco forgé pour un id pas encore provisionné (ids matériels prévisibles) et créer la fiche avec son propre numéro de série. Avec D4, le vrai boîtier est ensuite refusé définitivement (SerialMismatch, aucun ACK) jusqu'à une action opérateur, alors qu'avant L3 il écrasait la fiche. Piste : préenregistrement des ids et numéros attendus par l'opérateur (firstco ne crée plus), ou état « en attente de validation », en plus de l'ACL prévue en L6.

Source : revue.

**[majeur]** Premier arrivé, premier servi : tout client MQTT peut publier un firstco forgé pour un id pas encore provisionné (ids matériels prévisibles) et créer la fiche avec son propre numéro de série. Avec D4, le vrai boîtier est ensuite refusé définitivement (SerialMismatch, aucun ACK) jusqu'à une action opérateur, alors qu'avant L3 il écrasait la fiche. Piste : préenregistrement des ids et numéros attendus par l'opérateur (firstco ne crée plus), ou état « en attente de validation », en plus de l'ACL prévue en L6. _Source : revue._
@ -231,3 +335,1 @@
// ── 4. UPSERT boitiers ────────────────────────────────────────────────────
// INSERT avec id EXPLICITE (pré-programmé hardware, NON AUTO_INCREMENT).
// ON DUPLICATE KEY UPDATE préserve la PK et les FK — PAS de REPLACE INTO !
// ── INSERT boitiers ───────────────────────────────────────────────────────
Author
Member

[bloquant] Provisionnement partiel devenu définitif : l'INSERT de la fiche boitiers est validé seul (autocommit), avant les DDL, la transaction des capteurs et les seuils d'alerte. Si une étape suivante échoue (erreur base transitoire, redémarrage du serveur, DDL refusé), le firstco rejoué par le boîtier trouve la fiche, rend AlreadyProvisioned (handler.rs:260-262) et reçoit l'ACK de succès, sans tables {id}boitier* ni capteurs : toutes les trames suivantes échouent dans store_frame. Avant L3, l'UPSERT rendait le rejeu réparateur. Piste : DDL d'abord, puis fiche, capteurs et seuils dans une seule transaction, ou compléter de façon idempotente les structures manquantes sur le chemin « même numéro » sans toucher à la fiche.

Source : revue.

**[bloquant]** Provisionnement partiel devenu définitif : l'INSERT de la fiche boitiers est validé seul (autocommit), avant les DDL, la transaction des capteurs et les seuils d'alerte. Si une étape suivante échoue (erreur base transitoire, redémarrage du serveur, DDL refusé), le firstco rejoué par le boîtier trouve la fiche, rend AlreadyProvisioned (handler.rs:260-262) et reçoit l'ACK de succès, sans tables {id}_boitier_* ni capteurs : toutes les trames suivantes échouent dans store_frame. Avant L3, l'UPSERT rendait le rejeu réparateur. Piste : DDL d'abord, puis fiche, capteurs et seuils dans une seule transaction, ou compléter de façon idempotente les structures manquantes sur le chemin « même numéro » sans toucher à la fiche. _Source : revue._
Some checks failed
aquaprocess/revue-statique echec : emoji dans le code ajoute, 1 constat(s) bloquant(s) de la revue
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin pr/dette-l3:pr/dette-l3
git switch pr/dette-l3

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch pr/dette-l2
git merge --no-ff pr/dette-l3
git switch pr/dette-l3
git rebase pr/dette-l2
git switch pr/dette-l2
git merge --ff-only pr/dette-l3
git switch pr/dette-l3
git rebase pr/dette-l2
git switch pr/dette-l2
git merge --no-ff pr/dette-l3
git switch pr/dette-l2
git merge --squash pr/dette-l3
git switch pr/dette-l2
git merge --ff-only pr/dette-l3
git switch pr/dette-l2
git merge pr/dette-l3
git push origin pr/dette-l2
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
AcadeNice/Acquarefactoring-Thomas!3
No description provided.