Validateur du contrat : juge error.data selon la version de l'optimiseur (protocole 1.3)

tools/etm-powersync-schema-check :
- lit methods[].errors[].x-etm-data-schema : error.data d'une réponse error de ce code est jugé
  contre ce schéma (audité comme les autres) ; exigé à partir de x-etm-data-required-from, la
  version étant celle que l'optimiseur annonce dans SON Handshake ; en deçà, écart écrit en
  AVERTISSEMENT, sans effet sur le code de sortie ; version inconnue = celle du contrat ;
- truncated n'accompagne que 50 problèmes exactement ;
- aucune valeur reçue recopiée dans error.data : écart dès 1.2 (règle de 1.2). Le contrôle ne
  porte que sur les valeurs qu'un chemin conforme ne peut pas contenir — il ne peut pas accuser
  un chemin conforme (domain « battery » / params.battery) ;
- --error (un objet error, avec --request et --handshake) et --responses-only (session de sonde,
  requêtes fautives à dessein) ; RÉSUMÉ compte les avertissements.

Tests : forme exigée en 1.3 et avertie en 1.2 sur de vraies sessions de l'optimiseur de
référence (normal, old-data, echo-value × 1.3, 1.2), forme ligne à ligne, recopie sans faux
positif, contre-épreuve « sans x-etm-data-schema, rien n'est jugé ». Les cinq nouveaux tests
échouent avec le validateur précédent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-23 11:13:57 +02:00
parent 8c7c33be39
commit d961b73ed7
3 changed files with 332 additions and 12 deletions

View File

@ -13,7 +13,9 @@ Trois choses :
« bons » sont valides, chaque mode « mauvais » est rejeté par le schéma OU par une règle « bons » sont valides, chaque mode « mauvais » est rejeté par le schéma OU par une règle
sémantique nommée — et laquelle (`TABLE_MODES`, recopiée au rapport) ; sémantique nommée — et laquelle (`TABLE_MODES`, recopiée au rapport) ;
3. le validateur lui-même ne peut pas se taire : un mot-clé non géré, une règle orpheline, un 3. le validateur lui-même ne peut pas se taire : un mot-clé non géré, une règle orpheline, un
champ ajouté, un mauvais type le font échouer. champ ajouté, un mauvais type le font échouer ;
4. `error.data` du -32602 de GetPlan (protocole 1.3) : forme exigée d'un optimiseur 1.3, avertie
d'un optimiseur 1.2, recopie d'une valeur reçue refusée des deux.
""" """
import copy import copy
@ -59,8 +61,16 @@ TABLE_MODES = {
'unknown-load': ('règle', 'unknown-load'), 'unknown-load': ('règle', 'unknown-load'),
'forbidden-kind': ('règle', 'kind-mismatch'), 'forbidden-kind': ('règle', 'kind-mismatch'),
'missing-load': ('règle', 'incomplete-slot'), 'missing-load': ('règle', 'incomplete-slot'),
# Modes 1.3 : sur un contexte VALIDE et le bon token, ils se comportent comme `normal`.
'old-data': ('—', 'valide'),
'echo-value': ('—', 'valide'),
'major-first': ('—', 'valide'),
} }
# Une valeur que nul chemin conforme ne peut contenir (tiret, deux-points) : si elle apparaît dans
# error.data, c'est une recopie.
MARQUEUR = 'valeur-sonde:7Qz9'
def verifier(*args): def verifier(*args):
p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60) p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60)
@ -117,6 +127,33 @@ def session(mode, dossier):
return capture return capture
def session_contexte_invalide(dossier, mode, annonce, params):
"""Handshake accepté puis UN GetPlan de contexte invalide ; rend le fichier capturé."""
sock_path = os.path.join(dossier, 'opt.sock')
capture = os.path.join(dossier, f'invalide-{mode}-{annonce}.ndjson')
proc = subprocess.Popen([sys.executable, '-I', '-B', REF, '--listen', f'unix://{sock_path}', '--mode', mode,
'--announce', annonce, '--capture', capture],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
try:
assert proc.stdout.readline().startswith('ÉCOUTE'), 'optimiseur de référence muet'
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(sock_path)
tampon = [b'']
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake',
'params': {'protocolVersion': VERSION_MOTEUR,
'engine': 'etm-powersync-energy-plugin-etm/test'}}).encode() + b'\n')
assert lire_ligne(s, tampon)['result']['protocolVersion'] == annonce
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 2, 'method': 'GetPlan', 'params': params}).encode() + b'\n')
reponse = lire_ligne(s, tampon)
assert reponse['error']['code'] == -32602, reponse
s.close()
finally:
proc.terminate()
proc.wait(5)
proc.stdout.close()
return capture
class TestCeQueLeMoteurEmet(unittest.TestCase): class TestCeQueLeMoteurEmet(unittest.TestCase):
def test_les_references_du_serialiseur_sont_conformes(self): def test_les_references_du_serialiseur_sont_conformes(self):
self.assertGreaterEqual(len(GOLDEN), 4, 'fichiers de référence du sérialiseur introuvables') self.assertGreaterEqual(len(GOLDEN), 4, 'fichiers de référence du sérialiseur introuvables')
@ -239,5 +276,135 @@ class TestLeValidateurNeSeTaitPas(unittest.TestCase):
self.assertNotIn('uniqueItems', sortie) self.assertNotIn('uniqueItems', sortie)
class TestDonneesDErreur(unittest.TestCase):
"""error.data du -32602 de GetPlan (protocole 1.3), sur de vraies sessions de l'optimiseur de
référence. La session porte un GetPlan de contexte invalide : le validateur la juge SANS
--semantic (sinon la réponse error est un refus, quel que soit son data)."""
# (mode, version annoncée) → (code de sortie, écarts > 0, avertissements > 0)
ATTENDU = {
('normal', '1.3'): (0, False, False),
('normal', '1.2'): (0, False, False),
('old-data', '1.3'): (1, True, False), # forme d'avant 1.3, annoncée 1.3 : refusée
('old-data', '1.2'): (0, False, True), # la même, annoncée 1.2 : avertie
('echo-value', '1.3'): (1, True, False),
('echo-value', '1.2'): (1, True, True), # forme avertie, RECOPIE refusée (règle de 1.2)
}
def test_forme_exigee_en_1_3_avertie_en_1_2_recopie_refusee(self):
params = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8')))
params['timestamp'] = MARQUEUR # sans décalage, illisible : `format`
obtenu = {}
with tempfile.TemporaryDirectory() as d:
for (mode, annonce) in self.ATTENDU:
capture = session_contexte_invalide(d, mode, annonce, params)
code, r, sortie = verifier('--ndjson', capture, '--responses-only')
self.assertEqual(r['comptes'].get('GetPlan.error'), 1, sortie)
self.assertNotIn('GetPlan.params', r['comptes'])
self.assertEqual(r['comptes'].get('GetPlan.error.data'), 1, sortie)
obtenu[(mode, annonce)] = (code, r['ecarts'] > 0, r['avertissements'] > 0)
print(f' {mode:11s} {annonce} → code {code}, écarts {r["ecarts"]}, avertissements '
f'{r["avertissements"]}', file=sys.stderr)
if mode == 'echo-value':
self.assertIn('recopie la valeur reçue en params.timestamp', sortie)
self.assertNotIn(MARQUEUR, sortie, 'le validateur recopie lui-même la valeur')
if (mode, annonce) == ('old-data', '1.2'):
self.assertIn('AVERTISSEMENT', sortie)
self.assertIn('à aligner avant de s\'annoncer 1.3', sortie)
self.assertEqual(obtenu, self.ATTENDU)
def test_la_forme_vient_du_contrat(self):
# Contre-épreuve : sans x-etm-data-schema, la forme d'avant 1.3 annoncée 1.3 passe — c'est
# bien le contrat qui juge, pas une règle cachée dans l'outil.
with open(SCHEMA, encoding='utf-8') as f:
s = json.load(f)
for e in next(m for m in s['methods'] if m['name'] == 'GetPlan')['errors']:
e.pop('x-etm-data-schema', None)
e.pop('x-etm-data-required-from', None)
with tempfile.TemporaryDirectory() as d:
schema = os.path.join(d, 's.json')
with open(schema, 'w', encoding='utf-8') as f:
json.dump(s, f, ensure_ascii=False)
capture = session_contexte_invalide(d, 'old-data', '1.3', {'timestamp': MARQUEUR})
self.assertEqual(verifier('--ndjson', capture, '--responses-only')[0], 1)
code, r, sortie = verifier('--schema', schema, '--ndjson', capture, '--responses-only')
self.assertEqual(code, 0, sortie)
self.assertNotIn('GetPlan.error.data', r['comptes'])
def ecrire(self, d, nom, contenu):
chemin = os.path.join(d, nom)
with open(chemin, 'w', encoding='utf-8') as f:
json.dump(contenu, f, ensure_ascii=False)
return chemin
def juger(self, d, data, requete=None, version='1.3'):
args = ['--method', 'GetPlan', '--error',
self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide', 'data': data}),
'--handshake', self.ecrire(d, 'h.json', {'protocolVersion': version})]
if requete is not None:
args += ['--request', self.ecrire(d, 'q.json', requete)]
return verifier(*args)
def test_la_forme_ligne_a_ligne(self):
p = {'path': 'params.loads[0].id', 'problem': 'missing'}
cas = [
({'problems': [p]}, 0, None),
({'problems': [{'path': 'params', 'problem': 'type'}]}, 0, None),
({'problems': [{'path': 'params.loads[0].declared.estimatedPowerW.3', 'problem': 'range'}]}, 0, None),
({'problems': [p] * 50, 'truncated': True}, 0, None),
({'problems': []}, 1, '[minItems]'),
({'problems': [p] * 51}, 1, '[maxItems]'),
({'problems': [p] * 50, 'truncated': False}, 1, '[const]'),
({'problems': [p] * 3, 'truncated': True}, 1, '[x-etm-truncated]'),
({'problems': [dict(p, problem='absent')]}, 1, '[enum]'),
({'problems': [dict(p, path='/loads/0/id')]}, 1, '[pattern]'),
({'problems': [dict(p, path='loads[0].id')]}, 1, '[pattern]'),
({'problems': [dict(p, path='params.loads.0.id ')]}, 1, '[pattern]'),
({'problems': [dict(p, value=3)]}, 1, '[additionalProperties]'),
({'problems': [p], 'detail': 'x'}, 1, '[additionalProperties]'),
({'problems': [{'path': 'params.timestamp'}]}, 1, '[required]'),
({'problemes': ['params.timestamp : format']}, 1, '[required]'),
('contexte invalide', 1, '[type]'),
]
with tempfile.TemporaryDirectory() as d:
for data, code_attendu, mot in cas:
code, r, sortie = self.juger(d, data)
self.assertEqual(code, code_attendu, f'{data!r}\n{sortie}')
if mot:
self.assertIn(mot, sortie, data)
# data absent : écart en 1.3
chemin = self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide'})
code, _, sortie = verifier('--method', 'GetPlan', '--error', chemin)
self.assertEqual(code, 1, sortie)
self.assertIn('« data » manquant', sortie)
# Un autre code d'erreur n'a pas de forme normée : rien à juger.
chemin = self.ecrire(d, 'e.json', {'code': -32603, 'message': 'erreur interne', 'data': [1, 'x']})
code, r, sortie = verifier('--method', 'GetPlan', '--error', chemin)
self.assertEqual((code, r['ecarts']), (0, 0), sortie)
def test_la_recopie_ne_peut_pas_accuser_un_chemin_conforme(self):
# Valeurs de la requête qui sont AUSSI des noms de champ (domain « battery », trigger
# « forecast », meter.source « meter ») : un chemin conforme les contient, ce n'est pas une
# recopie. Une valeur à tiret ou à deux-points recopiée, elle, l'est.
requete = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8')))
requete['trigger'] = 'forecast'
requete['loads'][0]['domain'] = 'battery'
data = {'problems': [{'path': 'params.battery.socPercent', 'problem': 'range'},
{'path': 'params.forecast.pv[3].from', 'problem': 'format'},
{'path': 'params.meter.source', 'problem': 'enum'}]}
with tempfile.TemporaryDirectory() as d:
code, r, sortie = self.juger(d, data, requete)
self.assertEqual(code, 0, sortie)
ident = requete['loads'][0]['id']
self.assertRegex(ident, '[^A-Za-z0-9_]')
code, r, sortie = self.juger(d, {'problemes': [f'charge {ident} : priorité hors bornes']}, requete, '1.2')
self.assertEqual(code, 1, sortie)
self.assertIn('recopie la valeur reçue en params.loads[0].id', sortie)
self.assertNotIn(ident, sortie.split('RÉSUMÉ')[0])
# Avant 1.2, la règle n'existait pas : avertie seulement.
code, r, sortie = self.juger(d, {'problemes': [f'charge {ident}']}, requete, '1.1')
self.assertEqual((code, r['ecarts'], r['avertissements'] > 0), (0, 0, True), sortie)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main(verbosity=2) unittest.main(verbosity=2)

View File

@ -60,6 +60,9 @@ Python 3, bibliothèque standard seule, aucun accès réseau. Le contrat :
./tools/etm-powersync-schema-check --method GetPlan --params requete.json ./tools/etm-powersync-schema-check --method GetPlan --params requete.json
./tools/etm-powersync-schema-check --method GetPlan --result plan.json --request requete.json --semantic ./tools/etm-powersync-schema-check --method GetPlan --result plan.json --request requete.json --semantic
./tools/etm-powersync-schema-check --ndjson session.ndjson --semantic ./tools/etm-powersync-schema-check --ndjson session.ndjson --semantic
./tools/etm-powersync-schema-check --ndjson sonde.ndjson --responses-only # requêtes fautives à dessein
./tools/etm-powersync-schema-check --method GetPlan --error erreur.json --request requete.json \
--handshake handshake.json # error.data d'un -32602 (protocole 1.3)
./tools/etm-powersync-schema-check --trace etm-powersync-optimizer-trace-2026-10-17.ndjson --semantic ./tools/etm-powersync-schema-check --trace etm-powersync-optimizer-trace-2026-10-17.ndjson --semantic
./tools/etm-powersync-schema-check --subset # le sous-ensemble de JSON Schema géré ./tools/etm-powersync-schema-check --subset # le sous-ensemble de JSON Schema géré
@ -77,6 +80,10 @@ ETM_POWERSYNC_CODE_LISTS=/tmp/listes.json <build>/tests/auto/protocolschema/etm-
mot-clé est une erreur de schéma (code 2), jamais ignoré. mot-clé est une erreur de schéma (code 2), jamais ignoré.
- **`--semantic`** applique `x-etm-semantic-rules` : ce que le validateur du moteur refuse en plus de - **`--semantic`** applique `x-etm-semantic-rules` : ce que le validateur du moteur refuse en plus de
la forme, dans son ordre, sous le code qu'il publie (`fallbackCode`). la forme, dans son ordre, sous le code qu'il publie (`fallbackCode`).
- **`error.data`** (protocole 1.3) : la forme d'`error.data` d'une erreur, quand le contrat la norme
(`x-etm-data-schema`, `-32602` de `GetPlan`), est exigée d'un optimiseur qui s'annonce
`x-etm-data-required-from` ou plus, et seulement AVERTIE en deçà (ligne `AVERTISSEMENT`, sans effet
sur le code de sortie) ; une valeur reçue recopiée dans `error.data` est un écart dès 1.2.
- **Code de sortie** : 0 conforme ; 1 écart de forme ou refus sémantique ; 2 erreur d'usage ou de - **Code de sortie** : 0 conforme ; 1 écart de forme ou refus sémantique ; 2 erreur d'usage ou de
schéma. schéma.
- Conformité d'un optimiseur de bout en bout, avec le vrai moteur : suite - Conformité d'un optimiseur de bout en bout, avec le vrai moteur : suite

View File

@ -6,7 +6,10 @@
etm-powersync-schema-check [--schema FICHIER] --method M --params FICHIER.json etm-powersync-schema-check [--schema FICHIER] --method M --params FICHIER.json
etm-powersync-schema-check [--schema FICHIER] --method M --result FICHIER.json etm-powersync-schema-check [--schema FICHIER] --method M --result FICHIER.json
[--request PARAMS.json] [--handshake RESULT.json] [--semantic] [--request PARAMS.json] [--handshake RESULT.json] [--semantic]
etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic] [--requests-only] etm-powersync-schema-check [--schema FICHIER] --method M --error FICHIER.json
[--request PARAMS.json] [--handshake RESULT.json]
etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic]
[--requests-only | --responses-only]
etm-powersync-schema-check [--schema FICHIER] --trace TRACE.ndjson [--semantic] etm-powersync-schema-check [--schema FICHIER] --trace TRACE.ndjson [--semantic]
etm-powersync-schema-check --subset etm-powersync-schema-check --subset
@ -28,18 +31,35 @@ Ce que l'outil lit en plus du JSON Schema :
- `x-etm-reason-params` : clés de `reasonParams` admises par `reasonCode` (contrôle de forme) ; - `x-etm-reason-params` : clés de `reasonParams` admises par `reasonCode` (contrôle de forme) ;
- `x-etm-semantic-rules` (avec `--semantic`) : ce que le validateur du moteur refuse EN PLUS de - `x-etm-semantic-rules` (avec `--semantic`) : ce que le validateur du moteur refuse EN PLUS de
la forme, dans le même ordre, sous le même nom. Une règle du schéma sans implantation ici, ou la forme, dans le même ordre, sous le même nom. Une règle du schéma sans implantation ici, ou
l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence. l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence ;
- `methods[].errors[].x-etm-data-schema` (protocole 1.3) : la forme de `error.data` d'une réponse
error de ce code — `InvalidContextData` pour le `-32602` de `GetPlan`. Exigée d'un optimiseur qui
s'annonce `x-etm-data-required-from` ou plus (version lue dans SON `Handshake`) ; plus ancienne,
un écart n'est qu'un AVERTISSEMENT, qui ne change pas le code de sortie. Une version inconnue (pas
de handshake) est jugée comme celle du contrat. En plus de la forme, deux contrôles :
`truncated` n'accompagne que 50 problèmes exactement ; et AUCUNE VALEUR REÇUE n'est recopiée dans
`error.data` (§7 — règle de 1.2, donc un écart dès 1.2, jamais un avertissement). Ce dernier
contrôle porte sur les valeurs textuelles de la requête qu'un chemin conforme ne peut pas
contenir (4 caractères au moins, dont un hors `[A-Za-z0-9_]` : instants, identifiants à tiret,
libellés) : il ne peut pas accuser un chemin conforme. Une recopie de nombre ou de mot nu lui
échappe ; la forme 1.3, fermée, la rend inexprimable.
Modes : Modes :
- `--ndjson` : une session, un message JSON-RPC par ligne, dans les deux sens. Une réponse est - `--ndjson` : une session, un message JSON-RPC par ligne, dans les deux sens. Une réponse est
rapprochée de sa requête par `id` ; un `GetPlan` est jugé avec SA requête et le dernier rapprochée de sa requête par `id` ; un `GetPlan` est jugé avec SA requête et le dernier
`Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes). `Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes) ;
`--responses-only` que ce qu'émet l'optimiseur (réponses) — pour une session de SONDE, dont les
requêtes sont fautives à dessein (un contexte invalide, pour juger le `-32602` qui lui répond).
- `--trace` : traces de décision du moteur (annexe D) — `request` contre les params de - `--trace` : traces de décision du moteur (annexe D) — `request` contre les params de
`GetPlan` ; `response.result` contre son résultat quand la ligne vient d'un optimiseur `GetPlan` ; `response.result` contre son résultat quand la ligne vient d'un optimiseur
(`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur. (`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur. Une
erreur n'y porte que le code d'échec du moteur : rien à juger de son `data`.
- `--error` : l'objet `error` d'une réponse à `--method` ; son `data` est jugé comme en session,
la version tirée de `--handshake` (le `result` du Handshake), la requête de `--request`.
Sortie : une ligne par écart, puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts, Sortie : une ligne par écart, une ligne `AVERTISSEMENT …` par écart toléré (forme normée après la
verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un version de l'optimiseur), puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts,
avertissements, verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un
verdict qui REFUSE (`invalid`, `connection-refused`) ; 2 erreur d'usage ou de schéma. verdict qui REFUSE (`invalid`, `connection-refused`) ; 2 erreur d'usage ou de schéma.
Les verdicts `abstention` et `expired` sont dits, sans échec : ce sont des réponses permises. Les verdicts `abstention` et `expired` sont dits, sans échec : ce sont des réponses permises.
""" """
@ -125,6 +145,10 @@ class Validateur:
racines.append((f'méthode {m.get("name")}, param {p.get("name")}', p.get('schema'))) racines.append((f'méthode {m.get("name")}, param {p.get("name")}', p.get('schema')))
if 'result' in m: if 'result' in m:
racines.append((f'méthode {m.get("name")}, result', m['result'].get('schema'))) racines.append((f'méthode {m.get("name")}, result', m['result'].get('schema')))
for e in m.get('errors', []):
if 'x-etm-data-schema' in e:
racines.append((f'méthode {m.get("name")}, erreur {e.get("code")}, data',
e['x-etm-data-schema']))
for ou, s in racines: for ou, s in racines:
self.auditer_schema(s, ou) self.auditer_schema(s, ou)
@ -284,6 +308,16 @@ class Contrat:
self.kinds = self.v.resoudre('#/components/schemas/ActionKind').get('enum', []) self.kinds = self.v.resoudre('#/components/schemas/ActionKind').get('enum', [])
self.version = str(self.v.resoudre('#/components/schemas/ProtocolVersion').get('const', '')) self.version = str(self.v.resoudre('#/components/schemas/ProtocolVersion').get('const', ''))
self.cles_motif = doc.get('x-etm-reason-params') or {} self.cles_motif = doc.get('x-etm-reason-params') or {}
# (méthode, code) → (schéma de error.data, version de l'optimiseur à partir de laquelle il est exigé)
self.donnees_erreur = {}
for m in doc.get('methods', []):
for e in m.get('errors', []):
if 'x-etm-data-schema' in e:
depuis = version_lue(e.get('x-etm-data-required-from'))
if depuis is None:
raise ErreurSchema(f'{m["name"]}, erreur {e.get("code")} : x-etm-data-required-from '
f'absent ou illisible')
self.donnees_erreur[(m['name'], e.get('code'))] = (e['x-etm-data-schema'], depuis)
def methode(self, nom): def methode(self, nom):
if nom not in self.methodes: if nom not in self.methodes:
@ -330,6 +364,85 @@ class Contrat:
return e return e
# --- error.data (protocole 1.3) ----------------------------------------------------------------------
def version_lue(v):
"""« 1.3 » → (1, 3) ; « 1 » → (1, 0) ; illisible → None."""
if not isinstance(v, str):
return None
morceaux = v.split('.')
try:
return int(morceaux[0]), int(morceaux[1]) if len(morceaux) > 1 else 0
except ValueError:
return None
TEXTE_HORS_CHEMIN = re.compile(r'[^A-Za-z0-9_]')
RECOPIE_DEPUIS = (1, 2) # « jamais la valeur reçue » : règle de 1.2 (§7)
MAX_PROBLEMES = 50
def valeurs_reconnaissables(params, chemin='params'):
"""Les valeurs textuelles de la requête qu'un chemin conforme ne peut pas contenir : (chemin, valeur)."""
if isinstance(params, dict):
for k, v in params.items():
yield from valeurs_reconnaissables(v, f'{chemin}.{k}')
elif isinstance(params, list):
for i, v in enumerate(params):
yield from valeurs_reconnaissables(v, f'{chemin}[{i}]')
elif isinstance(params, str) and len(params) >= 4 and TEXTE_HORS_CHEMIN.search(params):
yield chemin, params
def textes(v):
"""Toutes les chaînes d'un JSON, clés comprises."""
if isinstance(v, str):
yield v
elif isinstance(v, dict):
for k, x in v.items():
yield k
yield from textes(x)
elif isinstance(v, list):
for x in v:
yield from textes(x)
def juger_donnees_erreur(contrat, methode, erreur, requete, version):
"""error.data d'une réponse error : (écarts, avertissements), chacun (chemin, mot-clé, message).
`version` : protocolVersion annoncée par l'optimiseur au handshake (None : inconnue, jugée comme
celle du contrat). La forme n'est exigée qu'à partir de x-etm-data-required-from ; la recopie
d'une valeur reçue est un écart dès 1.2."""
if not isinstance(erreur, dict):
return [], []
forme = contrat.donnees_erreur.get((methode, erreur.get('code')))
if forme is None:
return [], []
schema, depuis = forme
v = version_lue(version) or version_lue(contrat.version)
if 'data' not in erreur:
ecarts_forme = [('/error', 'required', '« data » manquant')]
else:
data = erreur['data']
ecarts_forme = contrat.v.valider(data, schema, '/error/data')
if isinstance(data, dict) and data.get('truncated') is True and isinstance(data.get('problems'), list) \
and len(data['problems']) != MAX_PROBLEMES:
ecarts_forme.append(('/error/data/truncated', 'x-etm-truncated',
f'truncated avec {len(data["problems"])} problème(s) : seulement quand '
f'{MAX_PROBLEMES} sont donnés et d\'autres omis'))
recopies = []
if 'data' in erreur and isinstance(requete, dict):
vus = list(textes(erreur['data']))
for chemin, valeur in valeurs_reconnaissables(requete):
if any(valeur in t for t in vus):
recopies.append(('/error/data', 'x-etm-no-echo',
f'recopie la valeur reçue en {chemin} — jamais la valeur reçue (§7)'))
ecarts, avertissements = [], []
(ecarts if v >= depuis else avertissements).extend(ecarts_forme)
(ecarts if v >= RECOPIE_DEPUIS else avertissements).extend(recopies)
return ecarts, avertissements
# --- Règles sémantiques : le validateur du moteur, transcrit dans son ordre ----------------------- # --- Règles sémantiques : le validateur du moteur, transcrit dans son ordre -----------------------
REGLES_IMPLANTEES = ['abstain', 'malformed-plan', 'empty-plan', 'bad-time', 'slots-unordered', REGLES_IMPLANTEES = ['abstain', 'malformed-plan', 'empty-plan', 'bad-time', 'slots-unordered',
@ -500,6 +613,7 @@ class Bilan:
self.semantique = semantique self.semantique = semantique
self.comptes = {} self.comptes = {}
self.ecarts = [] self.ecarts = []
self.avertissements = []
self.verdicts = {} self.verdicts = {}
self.refus = 0 self.refus = 0
@ -512,6 +626,22 @@ class Bilan:
self.ecarts.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg}) self.ecarts.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
print(f'{ou} {partie} {chemin} : {msg} [{mot}]') print(f'{ou} {partie} {chemin} : {msg} [{mot}]')
def avertir(self, ou, partie, ecarts, pourquoi):
for chemin, mot, msg in ecarts:
self.avertissements.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
print(f'AVERTISSEMENT {ou} {partie} {chemin} : {msg} [{mot}] — {pourquoi}')
def juger_erreur(self, ou, methode, erreur, requete, version):
ecarts, avertissements = juger_donnees_erreur(self.c, methode, erreur, requete, version)
if (methode, erreur.get('code') if isinstance(erreur, dict) else None) not in self.c.donnees_erreur:
return
partie = f'{methode}.error.data'
self.noter(ou, partie, ecarts)
forme = self.c.donnees_erreur[(methode, erreur['code'])]
self.avertir(ou, partie, avertissements,
f'optimiseur en {str(version)[:16]}, forme exigée à partir de '
f'{forme[1][0]}.{forme[1][1]} : à aligner avant de s\'annoncer {forme[1][0]}.{forme[1][1]}')
def verdict(self, ou, code): def verdict(self, ou, code):
if code is None: if code is None:
code = 'valide' code = 'valide'
@ -529,6 +659,7 @@ class Bilan:
def resume(self): def resume(self):
print('RÉSUMÉ ' + json.dumps({'comptes': self.comptes, 'ecarts': len(self.ecarts), print('RÉSUMÉ ' + json.dumps({'comptes': self.comptes, 'ecarts': len(self.ecarts),
'avertissements': len(self.avertissements),
'verdicts': self.verdicts, 'refus': self.refus}, 'verdicts': self.verdicts, 'refus': self.refus},
ensure_ascii=False, sort_keys=True)) ensure_ascii=False, sort_keys=True))
@ -553,10 +684,11 @@ class ErreurLigne:
self.msg = msg self.msg = msg
def session(b, chemin, requetes_seules): def session(b, chemin, requetes_seules, reponses_seules=False):
"""Un message JSON-RPC par ligne ; réponses rapprochées par id.""" """Un message JSON-RPC par ligne ; réponses rapprochées par id."""
attente = {} # id → (méthode, params) attente = {} # id → (méthode, params)
handshake = None # dernier result de Handshake accepté handshake = None # dernier result de Handshake accepté
version = None # protocolVersion de l'optimiseur, lue dans ce result (error.data, 1.3)
for n, m in lignes(chemin): for n, m in lignes(chemin):
ou = f'ligne {n}' ou = f'ligne {n}'
if isinstance(m, ErreurLigne) or not isinstance(m, dict): if isinstance(m, ErreurLigne) or not isinstance(m, dict):
@ -569,7 +701,8 @@ def session(b, chemin, requetes_seules):
if nom not in b.c.methodes: if nom not in b.c.methodes:
b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')]) b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')])
continue continue
b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {}))) if not reponses_seules:
b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {})))
if m.get('id') is not None: if m.get('id') is not None:
attente[m['id']] = (nom, m.get('params')) attente[m['id']] = (nom, m.get('params'))
continue continue
@ -587,9 +720,11 @@ def session(b, chemin, requetes_seules):
b.verdict(ou, code) b.verdict(ou, code)
if code is None: if code is None:
handshake = m['result'] handshake = m['result']
version = handshake.get('protocolVersion')
elif 'error' in m: elif 'error' in m:
b.compter(f'{nom}.error') b.compter(f'{nom}.error')
print(f'{ou} {nom} : réponse error (compte comme un échec de la requête, rpc-error)') print(f'{ou} {nom} : réponse error (compte comme un échec de la requête, rpc-error)')
b.juger_erreur(ou, nom, m['error'], params, version)
if b.semantique: if b.semantique:
b.refus += 1 b.refus += 1
else: else:
@ -623,12 +758,15 @@ def main(argv=None):
p.add_argument('--method', help='méthode du message (--params / --result)') p.add_argument('--method', help='méthode du message (--params / --result)')
p.add_argument('--params', help='fichier JSON : les params d\'une requête') p.add_argument('--params', help='fichier JSON : les params d\'une requête')
p.add_argument('--result', help='fichier JSON : le result d\'une réponse') p.add_argument('--result', help='fichier JSON : le result d\'une réponse')
p.add_argument('--error', help='fichier JSON : l\'objet error d\'une réponse (forme de son data)')
p.add_argument('--request', help='fichier JSON : les params de la requête (règles sémantiques de GetPlan)') p.add_argument('--request', help='fichier JSON : les params de la requête (règles sémantiques de GetPlan)')
p.add_argument('--handshake', help='fichier JSON : le result du Handshake (kinds annoncés)') p.add_argument('--handshake', help='fichier JSON : le result du Handshake (kinds annoncés)')
p.add_argument('--ndjson', help='session JSON-RPC, un message par ligne') p.add_argument('--ndjson', help='session JSON-RPC, un message par ligne')
p.add_argument('--trace', help='traces de décision du moteur (annexe D)') p.add_argument('--trace', help='traces de décision du moteur (annexe D)')
p.add_argument('--semantic', action='store_true', help='applique x-etm-semantic-rules') p.add_argument('--semantic', action='store_true', help='applique x-etm-semantic-rules')
p.add_argument('--requests-only', action='store_true', help='--ndjson : ne juge que les requêtes') p.add_argument('--requests-only', action='store_true', help='--ndjson : ne juge que les requêtes')
p.add_argument('--responses-only', action='store_true',
help='--ndjson : ne juge que les réponses (session de sonde, requêtes fautives à dessein)')
p.add_argument('--subset', action='store_true', help='écrit le sous-ensemble géré et sort') p.add_argument('--subset', action='store_true', help='écrit le sous-ensemble géré et sort')
a = p.parse_args(argv) a = p.parse_args(argv)
if a.subset: if a.subset:
@ -639,10 +777,12 @@ def main(argv=None):
contrat = Contrat(lire_json(a.schema)) contrat = Contrat(lire_json(a.schema))
b = Bilan(contrat, a.semantic) b = Bilan(contrat, a.semantic)
if a.ndjson: if a.ndjson:
session(b, a.ndjson, a.requests_only) if a.requests_only and a.responses_only:
p.error('--requests-only et --responses-only s\'excluent')
session(b, a.ndjson, a.requests_only, a.responses_only)
elif a.trace: elif a.trace:
trace(b, a.trace) trace(b, a.trace)
elif a.method and (a.params or a.result): elif a.method and (a.params or a.result or a.error):
if a.params: if a.params:
b.noter(a.params, f'{a.method}.params', contrat.valider_params(a.method, lire_json(a.params))) b.noter(a.params, f'{a.method}.params', contrat.valider_params(a.method, lire_json(a.params)))
if a.result: if a.result:
@ -656,8 +796,14 @@ def main(argv=None):
b.verdict(a.result, juger_plan(r, req, hs, contrat.kinds)) b.verdict(a.result, juger_plan(r, req, hs, contrat.kinds))
elif a.semantic and a.method == 'Handshake': elif a.semantic and a.method == 'Handshake':
b.verdict(a.result, juger_handshake({'result': r}, contrat.version)) b.verdict(a.result, juger_handshake({'result': r}, contrat.version))
if a.error:
contrat.methode(a.method)
hs = lire_json(a.handshake) if a.handshake else None
b.compter(f'{a.method}.error')
b.juger_erreur(a.error, a.method, lire_json(a.error), lire_json(a.request) if a.request else None,
hs.get('protocolVersion') if isinstance(hs, dict) else None)
else: else:
p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result') p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result / --error')
except ErreurSchema as e: except ErreurSchema as e:
print(f'ERREUR DE SCHÉMA : {e}', file=sys.stderr) print(f'ERREUR DE SCHÉMA : {e}', file=sys.stderr)
return 2 return 2