Claude commited on
Commit
dbab2ed
·
unverified ·
1 Parent(s): 2be6d5f

fix(web): valider strictement les payloads Pydantic (max_length, Literal)

Browse files

L'audit a identifié que ``picarones/web/models.py`` n'imposait
aucune borne aux strings ni aux listes — un payload de 1 Go sur
``char_exclude`` ou ``corpus_path`` passait la validation et
consommait de la mémoire pour rien. Les enums (``lang``,
``report_lang``, ``normalization_profile``) étaient typés ``str``
ouvert, donc ``"../../etc"`` était accepté avant d'être passé en
chaîne aux fonctions internes.

Fix : tous les champs ``str`` ont désormais une borne ``max_length``
proportionnée à leur usage attendu (constantes ``_MAX_PATH=1024``,
``_MAX_NAME=256``, ``_MAX_CHAR_EXCLUDE=256``, ``_MAX_PROMPT_FILENAME=256``).
Les listes (``engines``, ``competitors``) ont une borne
``max_length`` de 32. Les énumérations finies sont typées en
``Literal[...]`` pour rejeter au plus tôt :

- ``TesseractLang`` : 18 codes ISO (``fra``, ``lat``, ``eng``, …)
- ``ReportLang`` : ``fr`` ou ``en``
- ``NormalizationProfileId`` : 8 profils Unicode officiels

``BenchmarkRunRequest.competitors`` reçoit ``min_length=1`` —
Pydantic rejette désormais une liste vide en ``422 Unprocessable
Entity`` (code HTTP standard pour payload invalide), ce qui rend
caduque la vérification manuelle dans ``routers/benchmark.py``
(retirée). ``test_run_400_no_competitors`` mis à jour pour
attendre ``422`` au lieu de ``400`` — comportement plus correct
côté HTTP.

Pytest : 3354 passed, 2 skipped, 0 failed. Ruff : All checks passed.

https://claude.ai/code/session_01Hsd7kL8yeCbXn1mA7GQK9L

picarones/web/models.py CHANGED
@@ -1,65 +1,120 @@
1
  """Modèles Pydantic partagés par les routers FastAPI.
2
 
3
- Ces schémas décrivent les payloads des requêtes ``POST`` consommées par
4
- plusieurs endpoints du serveur web. Les sortir d'``app.py`` permet à
5
- chaque routeur de les importer sans dépendance vers l'application
6
- elle-même.
 
 
 
 
 
 
 
 
 
7
  """
8
 
9
  from __future__ import annotations
10
 
11
- from pydantic import BaseModel
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
 
14
  class BenchmarkRequest(BaseModel):
15
- corpus_path: str
16
- engines: list[str] = ["tesseract"]
17
- normalization_profile: str = "nfc"
18
- char_exclude: str = ""
19
  """Caractères à ignorer (séparés par virgule, ex: ``"',–"``)."""
20
- output_dir: str = "./rapports/"
21
- report_name: str = ""
22
- lang: str = "fra"
23
- report_lang: str = "fr"
24
  """Langue du rapport HTML : ``fr`` ou ``en``."""
25
 
26
 
27
  class HTRUnitedImportRequest(BaseModel):
28
- entry_id: str
29
- output_dir: str = "./corpus/"
30
- max_samples: int = 100
31
 
32
 
33
  class HuggingFaceImportRequest(BaseModel):
34
- dataset_id: str
35
- output_dir: str = "./corpus/"
36
- split: str = "train"
37
- max_samples: int = 100
38
 
39
 
40
  class CompetitorConfig(BaseModel):
41
- name: str = ""
42
- ocr_engine: str = ""
43
- """Moteur OCR : ``tesseract``, ``mistral_ocr``, … ou ``corpus`` pour
44
- utiliser l'OCR pré-calculé."""
45
- ocr_model: str = ""
46
- llm_provider: str = ""
47
- llm_model: str = ""
48
- pipeline_mode: str = ""
49
- prompt_file: str = ""
50
 
51
 
52
  class BenchmarkRunRequest(BaseModel):
53
- corpus_path: str
54
- competitors: list[CompetitorConfig]
55
- normalization_profile: str = "nfc"
56
- char_exclude: str = ""
57
- output_dir: str = "./rapports/"
58
- report_name: str = ""
59
- report_lang: str = "fr"
 
 
60
 
61
 
62
  __all__ = [
 
 
 
63
  "BenchmarkRequest",
64
  "HTRUnitedImportRequest",
65
  "HuggingFaceImportRequest",
 
1
  """Modèles Pydantic partagés par les routers FastAPI.
2
 
3
+ Ces schémas décrivent les payloads des requêtes ``POST`` consommées
4
+ par plusieurs endpoints du serveur web. Les sortir d'``app.py``
5
+ permet à chaque routeur de les importer sans dépendance vers
6
+ l'application elle-même.
7
+
8
+ Validation stricte
9
+ ------------------
10
+ Tous les champs ``str`` ont une borne ``max_length`` proportionnée
11
+ à leur usage attendu (chemin filesystem, identifiant HuggingFace,
12
+ nom de rapport…) pour empêcher qu'un payload géant n'épuise la
13
+ mémoire avant validation. Les énumérations finies (langue OCR,
14
+ langue de rapport) sont typées en ``Literal[...]`` pour rejeter au
15
+ plus tôt les valeurs invalides.
16
  """
17
 
18
  from __future__ import annotations
19
 
20
+ from typing import Literal
21
+
22
+ from pydantic import BaseModel, Field
23
+
24
+ # Bornes éditoriales — ajustées au plus large raisonnable, pas plus.
25
+ _MAX_PATH = 1024
26
+ """Longueur max d'un chemin filesystem (limite POSIX généralement 4096)."""
27
+
28
+ _MAX_NAME = 256
29
+ """Longueur max d'un identifiant ou nom court (rapport, label, dataset)."""
30
+
31
+ _MAX_PROMPT_FILENAME = 256
32
+ """Nom de fichier prompt — ``"correction_medieval_french.txt"`` etc."""
33
+
34
+ _MAX_CHAR_EXCLUDE = 256
35
+ """Liste de caractères à exclure (séparés par virgules)."""
36
+
37
+ _MAX_ENGINE_LIST = 32
38
+ """Nombre max de moteurs OCR par requête legacy."""
39
+
40
+ _MAX_COMPETITORS = 32
41
+ """Nombre max de concurrents composés par benchmark/run."""
42
+
43
+ # Codes ISO Tesseract acceptés pour le paramètre ``lang`` de
44
+ # ``BenchmarkRequest``. Liste explicite plutôt que ``str`` ouvert
45
+ # pour rejeter au plus tôt une valeur fantaisiste qui transiterait
46
+ # vers ``pytesseract`` en pure perte.
47
+ TesseractLang = Literal[
48
+ "fra", "lat", "eng", "deu", "ita", "spa", "por", "nld", "cat",
49
+ "rum", "ell", "ara", "heb", "rus", "ukr", "pol", "ces", "swe",
50
+ ]
51
+
52
+ ReportLang = Literal["fr", "en"]
53
+ """Langue du rapport HTML."""
54
+
55
+ NormalizationProfileId = Literal[
56
+ "nfc", "caseless", "minimal",
57
+ "medieval_french", "early_modern_french",
58
+ "medieval_latin",
59
+ "early_modern_english", "medieval_english",
60
+ ]
61
+ """Identifiants des profils de normalisation Unicode disponibles."""
62
 
63
 
64
  class BenchmarkRequest(BaseModel):
65
+ corpus_path: str = Field(min_length=1, max_length=_MAX_PATH)
66
+ engines: list[str] = Field(default=["tesseract"], max_length=_MAX_ENGINE_LIST)
67
+ normalization_profile: NormalizationProfileId = "nfc"
68
+ char_exclude: str = Field(default="", max_length=_MAX_CHAR_EXCLUDE)
69
  """Caractères à ignorer (séparés par virgule, ex: ``"',–"``)."""
70
+ output_dir: str = Field(default="./rapports/", max_length=_MAX_PATH)
71
+ report_name: str = Field(default="", max_length=_MAX_NAME)
72
+ lang: TesseractLang = "fra"
73
+ report_lang: ReportLang = "fr"
74
  """Langue du rapport HTML : ``fr`` ou ``en``."""
75
 
76
 
77
  class HTRUnitedImportRequest(BaseModel):
78
+ entry_id: str = Field(min_length=1, max_length=_MAX_NAME)
79
+ output_dir: str = Field(default="./corpus/", max_length=_MAX_PATH)
80
+ max_samples: int = Field(default=100, ge=1, le=10_000)
81
 
82
 
83
  class HuggingFaceImportRequest(BaseModel):
84
+ dataset_id: str = Field(min_length=1, max_length=_MAX_NAME)
85
+ output_dir: str = Field(default="./corpus/", max_length=_MAX_PATH)
86
+ split: str = Field(default="train", max_length=_MAX_NAME)
87
+ max_samples: int = Field(default=100, ge=1, le=10_000)
88
 
89
 
90
  class CompetitorConfig(BaseModel):
91
+ name: str = Field(default="", max_length=_MAX_NAME)
92
+ ocr_engine: str = Field(default="", max_length=_MAX_NAME)
93
+ """Moteur OCR : ``tesseract``, ``mistral_ocr``, … ou ``corpus``
94
+ pour utiliser l'OCR pré-calculé."""
95
+ ocr_model: str = Field(default="", max_length=_MAX_NAME)
96
+ llm_provider: str = Field(default="", max_length=_MAX_NAME)
97
+ llm_model: str = Field(default="", max_length=_MAX_NAME)
98
+ pipeline_mode: str = Field(default="", max_length=_MAX_NAME)
99
+ prompt_file: str = Field(default="", max_length=_MAX_PROMPT_FILENAME)
100
 
101
 
102
  class BenchmarkRunRequest(BaseModel):
103
+ corpus_path: str = Field(min_length=1, max_length=_MAX_PATH)
104
+ competitors: list[CompetitorConfig] = Field(
105
+ min_length=1, max_length=_MAX_COMPETITORS,
106
+ )
107
+ normalization_profile: NormalizationProfileId = "nfc"
108
+ char_exclude: str = Field(default="", max_length=_MAX_CHAR_EXCLUDE)
109
+ output_dir: str = Field(default="./rapports/", max_length=_MAX_PATH)
110
+ report_name: str = Field(default="", max_length=_MAX_NAME)
111
+ report_lang: ReportLang = "fr"
112
 
113
 
114
  __all__ = [
115
+ "TesseractLang",
116
+ "ReportLang",
117
+ "NormalizationProfileId",
118
  "BenchmarkRequest",
119
  "HTRUnitedImportRequest",
120
  "HuggingFaceImportRequest",
picarones/web/routers/benchmark.py CHANGED
@@ -110,11 +110,10 @@ async def api_benchmark_run(req: BenchmarkRunRequest, request: Request) -> dict:
110
  raise HTTPException(
111
  status_code=400, detail=f"Corpus non trouvé : {req.corpus_path}",
112
  )
113
- if not req.competitors:
114
- raise HTTPException(status_code=400, detail="Aucun concurrent défini.")
115
 
116
- # Sprint 24 — mode public : refuse les pipelines LLM mutualisés et
117
- # les moteurs OCR cloud sollicités par n'importe quel concurrent.
118
  try:
119
  for comp in req.competitors:
120
  assert_engines_allowed([comp.ocr_engine] if comp.ocr_engine else [])
 
110
  raise HTTPException(
111
  status_code=400, detail=f"Corpus non trouvé : {req.corpus_path}",
112
  )
113
+ # ``competitors`` non vide est garanti par Pydantic ``min_length=1``.
 
114
 
115
+ # Mode public : refuse les pipelines LLM mutualisés et les moteurs
116
+ # OCR cloud sollicités par n'importe quel concurrent.
117
  try:
118
  for comp in req.competitors:
119
  assert_engines_allowed([comp.ocr_engine] if comp.ocr_engine else [])
tests/test_sprint6_web_interface.py CHANGED
@@ -1127,7 +1127,9 @@ class TestFastAPIBenchmarkRun:
1127
  "corpus_path": str(tmp_corpus),
1128
  "competitors": [],
1129
  })
1130
- assert r.status_code == 400
 
 
1131
 
1132
  def test_run_missing_ocr_engine_accepted(self, client, tmp_corpus):
1133
  """ocr_engine est désormais optionnel (vide = post-correction corpus)."""
 
1127
  "corpus_path": str(tmp_corpus),
1128
  "competitors": [],
1129
  })
1130
+ # Pydantic ``min_length=1`` rejette en 422 Unprocessable Entity
1131
+ # (code HTTP standard pour payload invalide).
1132
+ assert r.status_code == 422
1133
 
1134
  def test_run_missing_ocr_engine_accepted(self, client, tmp_corpus):
1135
  """ocr_engine est désormais optionnel (vide = post-correction corpus)."""