Claude commited on
Commit
4f9f5f6
·
unverified ·
1 Parent(s): 3a4fc3a

feat(app): Sprint A14-S20 — CorpusService (import ZIP sandboxé + détection patterns image/GT)

Browse files

Deuxième service applicatif du rewrite, après ``BenchmarkService``
(S17) et ``WorkspaceManager`` (S19) : prend en entrée un blob ZIP
uploadé (web ou CLI) et produit un ``CorpusSpec`` immédiatement
consommable par le ``BenchmarkService``.

Détection des patterns
----------------------
Conventions de nommage alignées sur l'historique (Sprint 32) :

::

mon_doc.png → image source
mon_doc.gt.txt → RAW_TEXT GT
mon_doc.gt.alto.xml → ALTO_XML GT
mon_doc.gt.page.xml → PAGE_XML GT
mon_doc.gt.entities.json → ENTITIES GT
mon_doc.gt.reading_order.json → READING_ORDER GT

Toutes les GT partageant le même stem que l'image sont rattachées
au même ``DocumentRef``. Une image sans GT est incluse (warning,
``n_images_without_gt``) ; une GT orpheline n'est pas rattachée
(warning, ``n_gt_without_image``).

Sécurité (chacun testé)
-----------------------
- **Path traversal** (``../etc/passwd``) → ``CorpusImportError``.
- **Chemin absolu** (Unix ``/etc/passwd`` ou Windows ``C:/evil``)
→ erreur.
- **Symlink** dans le ZIP (mode UNIX ``S_IFLNK`` détecté via
``external_attr``) → erreur.
- **Octet nul** dans un nom d'entrée → erreur.
- **Garde-fou final** : chaque chemin résolu doit rester sous
``extract_dir`` (défense en profondeur post-extraction).
- **Plafond taille blob** (``max_zip_size_bytes``, défaut 100 Mo).
- **Plafond nb entrées** (``max_entry_count``, défaut 5000) — anti
zip bomb par nombre.
- **Plafond taille décompressée** (``max_uncompressed_bytes``,
défaut 500 Mo) — anti zip bomb par expansion.
- **Archive corrompue** (``BadZipFile``) → erreur typée.

Filtrage silencieux des artefacts OS
------------------------------------
Détectés et sautés sans warning (bruit standard d'un ZIP produit
par un poste de travail patrimonial) :

- ``__MACOSX/`` et ``__MACOSX/._*``.
- ``._*`` (Apple resource forks).
- ``.DS_Store``.
- ``Thumbs.db`` (case-insensitive).

Ces sauts sont comptés dans ``n_skipped_noise`` pour que
l'utilisateur puisse vérifier le triage.

Anti-sur-ingénierie
-------------------
- Pas d'OCR à l'import (le service organise, ne lit pas).
- Pas de validation de schéma ALTO/PAGE à l'import (lourde —
reste à la demande des projecteurs/loaders).
- Pas de quotas par utilisateur ni rate-limiting (responsabilité
du caller web/CLI).
- Pas d'autodétection magique de format image (extension only) —
Pillow protégera plus tard côté web.

Sandbox
-------
Toute extraction se fait dans un sous-dossier ``corpus_<safe_name>``
du ``WorkspaceManager`` injecté. Plusieurs imports peuvent
coexister dans un même workspace sans collision (test inclus).
``corpus_name`` est sanitizé via ``safe_report_name``.

Tests
-----
32 tests dans ``tests/security/test_sprint_a14_s20_corpus_service.py`` :

- Import basique (image + GT → 1 doc, extraction sandboxée,
sanitization corpus_name).
- Détection paramétrée des 5 niveaux GT.
- GT multi-niveaux pour le même stem.
- Pairing : image sans GT, GT orpheline, doublon d'image (premier
gardé), hiérarchie ``volA/folio_001`` préservée dans le doc_id.
- Filtrage OS : __MACOSX, ._*, .DS_Store, Thumbs.db (case-insensitive).
- Sécurité : 8 cas (traversal, absolu Unix/Windows, ZIP corrompu,
taille trop grande, trop d'entrées, décompression trop grande,
symlink).
- Cas limites : ZIP vide, extension inconnue sautée, caractères
invalides dans doc_id remplacés, metadata pass-through, imports
multiples sans collision.
- Smoke : un corpus importé est immédiatement consumable par le
BenchmarkService (vérification end-to-end de l'API publique).

439 tests sprint_a14 passent (407 S1-S19 + 32 S20).

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

picarones/app/services/__init__.py CHANGED
@@ -30,6 +30,11 @@ from picarones.app.services.benchmark_service import (
30
  GroundTruthFactory,
31
  PipelineInputsFactory,
32
  )
 
 
 
 
 
33
  from picarones.app.services.path_security import (
34
  PathValidationError,
35
  WorkspaceManager,
@@ -41,6 +46,9 @@ from picarones.app.services.path_security import (
41
  __all__ = [
42
  "BenchmarkService",
43
  "ContextFactory",
 
 
 
44
  "GroundTruthFactory",
45
  "PathValidationError",
46
  "PipelineInputsFactory",
 
30
  GroundTruthFactory,
31
  PipelineInputsFactory,
32
  )
33
+ from picarones.app.services.corpus_service import (
34
+ CorpusImportError,
35
+ CorpusImportReport,
36
+ CorpusService,
37
+ )
38
  from picarones.app.services.path_security import (
39
  PathValidationError,
40
  WorkspaceManager,
 
46
  __all__ = [
47
  "BenchmarkService",
48
  "ContextFactory",
49
+ "CorpusImportError",
50
+ "CorpusImportReport",
51
+ "CorpusService",
52
  "GroundTruthFactory",
53
  "PathValidationError",
54
  "PipelineInputsFactory",
picarones/app/services/corpus_service.py ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``CorpusService`` — upload ZIP sandboxé + détection des paires image/GT.
2
+
3
+ Sprint A14-S20 du rewrite ciblé.
4
+
5
+ Le service applicatif qui prend en entrée un blob ZIP (uploadé par
6
+ le web ou la CLI) et produit un ``CorpusSpec`` immédiatement
7
+ consommable par le ``BenchmarkService`` (S17), avec :
8
+
9
+ - **Extraction sandboxée** dans un sous-dossier d'un
10
+ ``WorkspaceManager`` (S19) — refus du path traversal, des symlinks,
11
+ et des zip bombs.
12
+ - **Détection des paires** image / GT par convention de nommage,
13
+ alignée sur l'historique (Sprint 32) :
14
+
15
+ ::
16
+
17
+ mon_doc.png
18
+ mon_doc.gt.txt
19
+ mon_doc.gt.alto.xml
20
+ mon_doc.gt.page.xml
21
+ mon_doc.gt.entities.json
22
+ mon_doc.gt.reading_order.json
23
+
24
+ Toutes les GT partageant le **même stem** que l'image sont rattachées
25
+ au même ``DocumentRef``.
26
+
27
+ - **Filtrage silencieux** des artefacts macOS / Windows (``__MACOSX/``,
28
+ ``._*``, ``.DS_Store``, ``Thumbs.db``) — bruit standard d'un ZIP
29
+ produit par un poste de travail patrimonial.
30
+
31
+ - **Rapport** ``CorpusImportReport`` qui agrège warnings (image
32
+ sans GT, GT orpheline) et compte les entrées sautées — l'utilisateur
33
+ doit pouvoir vérifier visuellement que son corpus a été interprété
34
+ correctement.
35
+
36
+ Anti-sur-ingénierie
37
+ -------------------
38
+ - Pas d'OCR à l'import. Le service ne lit pas les contenus, il
39
+ organise.
40
+ - Pas de validation de schéma ALTO/PAGE à l'import (c'est lourd).
41
+ Les fichiers sont juste catalogués ; la validation se fait à la
42
+ demande par les projecteurs/loaders.
43
+ - Pas de quotas par utilisateur ou rate-limiting (responsabilité
44
+ du caller web/CLI ; les paramètres ``max_*`` du constructeur sont
45
+ des plafonds défensifs absolus).
46
+ - Pas d'autodétection de format image (PNG vs JPEG vs TIFF) — on
47
+ reconnaît par extension. Si un attaquant met un EXE en ``.png``,
48
+ Pillow protégera plus tard (S21+ pour la web).
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import io
54
+ import logging
55
+ import re
56
+ import zipfile
57
+ from dataclasses import dataclass, field
58
+ from pathlib import Path
59
+
60
+ from picarones.app.services.path_security import (
61
+ WorkspaceManager,
62
+ safe_report_name,
63
+ )
64
+ from picarones.domain.artifacts import ArtifactType
65
+ from picarones.domain.corpus import CorpusSpec
66
+ from picarones.domain.documents import DocumentRef, GroundTruthRef
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+
71
+ class CorpusImportError(Exception):
72
+ """Levée quand l'import ZIP échoue de manière irrécupérable.
73
+
74
+ Cas typiques :
75
+ - Archive corrompue / non-ZIP.
76
+ - Path traversal détecté.
77
+ - Symlink détecté.
78
+ - Plafond de taille / nombre d'entrées dépassé (zip bomb).
79
+ """
80
+
81
+
82
+ # ──────────────────────────────────────────────────────────────────────
83
+ # Conventions de nommage GT (alignées sur picarones/core/corpus.py
84
+ # Sprint 32, mais exprimées en ``ArtifactType`` pour le rewrite).
85
+ # ──────────────────────────────────────────────────────────────────────
86
+
87
+ #: Suffixes de GT reconnus, dans l'ordre du plus spécifique au moins
88
+ #: spécifique (``.gt.alto.xml`` doit être testé AVANT ``.gt.txt`` qui
89
+ #: est une sous-chaîne moins discriminante).
90
+ _GT_SUFFIX_TO_TYPE: tuple[tuple[str, ArtifactType], ...] = (
91
+ (".gt.alto.xml", ArtifactType.ALTO_XML),
92
+ (".gt.page.xml", ArtifactType.PAGE_XML),
93
+ (".gt.entities.json", ArtifactType.ENTITIES),
94
+ (".gt.reading_order.json", ArtifactType.READING_ORDER),
95
+ (".gt.txt", ArtifactType.RAW_TEXT),
96
+ )
97
+
98
+ #: Extensions image reconnues (case-insensitive). L'absence de ``.gt.``
99
+ #: dans le chemin est requise pour distinguer ``foo.png`` (image) d'un
100
+ #: éventuel ``foo.gt.alto.xml`` (qui ne match pas ces extensions, mais
101
+ #: par défense).
102
+ _IMAGE_EXTENSIONS: frozenset[str] = frozenset({
103
+ ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp", ".bmp",
104
+ })
105
+
106
+ #: Patterns à ignorer silencieusement (artefacts OS).
107
+ _OS_NOISE_PATTERNS: tuple[re.Pattern[str], ...] = (
108
+ re.compile(r"(^|/)__MACOSX(/|$)"),
109
+ re.compile(r"(^|/)\._[^/]*$"),
110
+ re.compile(r"(^|/)\.DS_Store$"),
111
+ re.compile(r"(^|/)Thumbs\.db$", re.IGNORECASE),
112
+ )
113
+
114
+
115
+ # ──────────────────────────────────────────────────────────────────────
116
+ # Rapport d'import
117
+ # ──────────────────────────────────────────────────────────────────────
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class CorpusImportReport:
122
+ """Résultat lisible humainement d'un ``import_zip``.
123
+
124
+ Attributs
125
+ ---------
126
+ spec:
127
+ Le ``CorpusSpec`` construit, prêt à être passé au
128
+ ``BenchmarkService``.
129
+ extracted_dir:
130
+ Chemin filesystem absolu du sous-dossier où le ZIP a été
131
+ extrait. Vit sous le ``WorkspaceManager.root``.
132
+ n_documents:
133
+ Nombre de documents avec au moins une image (= longueur de
134
+ ``spec.documents``).
135
+ n_images_without_gt:
136
+ Nombre d'images trouvées sans GT. Ces documents sont quand
137
+ même inclus dans le corpus (l'utilisateur peut juste vouloir
138
+ OCRiser, pas évaluer).
139
+ n_gt_without_image:
140
+ Nombre de GT orphelines (stem qui n'a pas d'image
141
+ correspondante). Loggées en warning et non rattachées —
142
+ ne participent pas au corpus.
143
+ n_skipped_noise:
144
+ Nombre d'entrées sautées silencieusement (artefacts OS).
145
+ warnings:
146
+ Messages humainement lisibles à présenter au caller (web
147
+ affiche dans une bannière, CLI affiche en stderr).
148
+ skipped_paths:
149
+ Liste des chemins (relatifs au root du ZIP) qui ont été
150
+ sautés ou non rattachés — utile au debug d'un import qui
151
+ a perdu des fichiers.
152
+ """
153
+
154
+ spec: CorpusSpec
155
+ extracted_dir: Path
156
+ n_documents: int
157
+ n_images_without_gt: int
158
+ n_gt_without_image: int
159
+ n_skipped_noise: int
160
+ warnings: tuple[str, ...] = field(default_factory=tuple)
161
+ skipped_paths: tuple[str, ...] = field(default_factory=tuple)
162
+
163
+
164
+ # ──────────────────────────────────────────────────────────────────────
165
+ # Service
166
+ # ──────────────────────────────────────────────────────────────────────
167
+
168
+
169
+ class CorpusService:
170
+ """Service d'import et d'analyse de structure d'un corpus.
171
+
172
+ Parameters
173
+ ----------
174
+ workspace:
175
+ ``WorkspaceManager`` dans lequel extraire le ZIP. Le service
176
+ crée un sous-dossier par import — plusieurs imports peuvent
177
+ coexister dans un même workspace.
178
+ max_zip_size_bytes:
179
+ Plafond sur la **taille du blob ZIP** lui-même (avant
180
+ extraction). Défaut 100 Mo. Le caller (web layer) doit
181
+ idéalement vérifier ça aussi en amont via
182
+ ``Content-Length``.
183
+ max_entry_count:
184
+ Plafond sur le nombre d'entrées dans le ZIP (anti-bombe par
185
+ nombre). Défaut 5000.
186
+ max_uncompressed_bytes:
187
+ Plafond sur la taille totale **décompressée** (anti-bombe
188
+ par expansion). Défaut 500 Mo.
189
+ """
190
+
191
+ def __init__(
192
+ self,
193
+ workspace: WorkspaceManager,
194
+ *,
195
+ max_zip_size_bytes: int = 100 * 1024 * 1024,
196
+ max_entry_count: int = 5000,
197
+ max_uncompressed_bytes: int = 500 * 1024 * 1024,
198
+ ) -> None:
199
+ self._workspace = workspace
200
+ self._max_zip_size = max_zip_size_bytes
201
+ self._max_entries = max_entry_count
202
+ self._max_uncompressed = max_uncompressed_bytes
203
+
204
+ # ──────────────────────────────────────────────────────────────────
205
+ # API publique
206
+ # ──────────────────────────────────────────────────────────────────
207
+
208
+ def import_zip(
209
+ self,
210
+ zip_bytes: bytes,
211
+ *,
212
+ corpus_name: str,
213
+ metadata: dict[str, str] | None = None,
214
+ ) -> CorpusImportReport:
215
+ """Extrait un ZIP et construit le ``CorpusSpec`` correspondant.
216
+
217
+ Étapes :
218
+
219
+ 1. Validation des plafonds (taille blob, nb entrées,
220
+ taille décompressée prévisible si dispo).
221
+ 2. Validation de chaque entrée (refus traversal, symlinks).
222
+ 3. Extraction sécurisée dans un sous-dossier dédié.
223
+ 4. Catalogage : détection images + GT + appariement par stem.
224
+ 5. Construction du ``CorpusSpec``.
225
+
226
+ Le ``corpus_name`` est nettoyé via :func:`safe_report_name`
227
+ (le caller peut passer un nom utilisateur sans pré-validation).
228
+ """
229
+ if len(zip_bytes) > self._max_zip_size:
230
+ raise CorpusImportError(
231
+ f"ZIP trop volumineux : {len(zip_bytes)} octets > "
232
+ f"plafond {self._max_zip_size}.",
233
+ )
234
+
235
+ safe_name = safe_report_name(corpus_name, max_length=64)
236
+ # Sous-dossier d'extraction unique pour cet import — permet
237
+ # plusieurs imports sans collision.
238
+ extract_dir = self._workspace.subpath(f"corpus_{safe_name}")
239
+ extract_dir.mkdir(parents=True, exist_ok=True)
240
+
241
+ try:
242
+ zf = zipfile.ZipFile(io.BytesIO(zip_bytes))
243
+ except zipfile.BadZipFile as exc:
244
+ raise CorpusImportError(f"Archive ZIP invalide : {exc}") from exc
245
+
246
+ with zf:
247
+ self._validate_archive(zf)
248
+ extracted_files, n_noise = self._extract_safely(zf, extract_dir)
249
+
250
+ spec, warnings, n_orphan_gt, n_no_gt, skipped_paths = (
251
+ self._build_corpus_spec(
252
+ extracted_files=extracted_files,
253
+ corpus_name=safe_name,
254
+ extract_dir=extract_dir,
255
+ metadata=metadata or {},
256
+ )
257
+ )
258
+
259
+ return CorpusImportReport(
260
+ spec=spec,
261
+ extracted_dir=extract_dir,
262
+ n_documents=len(spec.documents),
263
+ n_images_without_gt=n_no_gt,
264
+ n_gt_without_image=n_orphan_gt,
265
+ n_skipped_noise=n_noise,
266
+ warnings=tuple(warnings),
267
+ skipped_paths=tuple(skipped_paths),
268
+ )
269
+
270
+ # ──────────────────────────────────────────────────────────────────
271
+ # Étape 1 : validation globale de l'archive
272
+ # ──────────────────────────────────────────────────────────────────
273
+
274
+ def _validate_archive(self, zf: zipfile.ZipFile) -> None:
275
+ """Vérifie les plafonds globaux (entrées, taille décompressée)."""
276
+ infos = zf.infolist()
277
+ if len(infos) > self._max_entries:
278
+ raise CorpusImportError(
279
+ f"ZIP contient trop d'entrées : {len(infos)} > "
280
+ f"plafond {self._max_entries} (zip bomb suspectée).",
281
+ )
282
+ total_uncompressed = sum(info.file_size for info in infos)
283
+ if total_uncompressed > self._max_uncompressed:
284
+ raise CorpusImportError(
285
+ f"ZIP décompressé trop volumineux : {total_uncompressed} "
286
+ f"octets > plafond {self._max_uncompressed} (zip bomb "
287
+ "suspectée).",
288
+ )
289
+
290
+ # ──────────────────────────────────────────────────────────────────
291
+ # Étape 2 + 3 : extraction sécurisée
292
+ # ──────────────────────────────────────────────────────────────────
293
+
294
+ def _extract_safely(
295
+ self,
296
+ zf: zipfile.ZipFile,
297
+ extract_dir: Path,
298
+ ) -> tuple[list[tuple[str, Path]], int]:
299
+ """Extrait chaque fichier en validant son chemin cible.
300
+
301
+ Returns
302
+ -------
303
+ tuple[list[tuple[str, Path]], int]
304
+ ``(extracted_files, n_skipped_noise)`` — liste des paires
305
+ ``(relative_in_zip, absolute_on_disk)`` des fichiers
306
+ réellement extraits, et compte des entrées sautées car
307
+ artefact OS.
308
+ """
309
+ out: list[tuple[str, Path]] = []
310
+ n_noise = 0
311
+ for info in zf.infolist():
312
+ arc_name = info.filename
313
+ # Saut des répertoires nus.
314
+ if arc_name.endswith("/"):
315
+ continue
316
+ # Saut des artefacts OS (silencieux par design).
317
+ if _is_os_noise(arc_name):
318
+ n_noise += 1
319
+ continue
320
+ # Refus des chemins absolus, traversals, octets nuls.
321
+ self._reject_unsafe_arcname(arc_name)
322
+ # Refus des symlinks (mode UNIX bit S_IFLNK = 0xA000).
323
+ unix_mode = (info.external_attr >> 16) & 0xF000
324
+ if unix_mode == 0xA000:
325
+ raise CorpusImportError(
326
+ f"Symlink dans le ZIP refusé : {arc_name!r}.",
327
+ )
328
+
329
+ target = (extract_dir / arc_name).resolve()
330
+ # Garde-fou final : le path résolu doit rester sous extract_dir.
331
+ try:
332
+ target.relative_to(extract_dir.resolve())
333
+ except ValueError as exc:
334
+ raise CorpusImportError(
335
+ f"Entrée ZIP {arc_name!r} sort du dossier "
336
+ f"d'extraction après résolution.",
337
+ ) from exc
338
+
339
+ target.parent.mkdir(parents=True, exist_ok=True)
340
+ with zf.open(info) as src, target.open("wb") as dst:
341
+ while True:
342
+ chunk = src.read(64 * 1024)
343
+ if not chunk:
344
+ break
345
+ dst.write(chunk)
346
+ out.append((arc_name, target))
347
+ return out, n_noise
348
+
349
+ @staticmethod
350
+ def _reject_unsafe_arcname(arc_name: str) -> None:
351
+ if not arc_name:
352
+ raise CorpusImportError("Entrée ZIP au nom vide.")
353
+ if "\x00" in arc_name:
354
+ raise CorpusImportError(
355
+ f"Entrée ZIP avec octet nul dans le nom : {arc_name!r}.",
356
+ )
357
+ # Refus chemin absolu (Unix ``/`` ou Windows ``C:\``).
358
+ if arc_name.startswith("/") or arc_name.startswith("\\"):
359
+ raise CorpusImportError(
360
+ f"Chemin absolu interdit dans le ZIP : {arc_name!r}.",
361
+ )
362
+ if len(arc_name) >= 3 and arc_name[1] == ":" and arc_name[2] in ("/", "\\"):
363
+ raise CorpusImportError(
364
+ f"Chemin absolu Windows interdit dans le ZIP : "
365
+ f"{arc_name!r}.",
366
+ )
367
+ # Refus des traversals (``..`` comme composant).
368
+ parts = arc_name.replace("\\", "/").split("/")
369
+ if any(p == ".." for p in parts):
370
+ raise CorpusImportError(
371
+ f"Traversal détecté dans le ZIP : {arc_name!r}.",
372
+ )
373
+
374
+ # ──────────────────────────────────────────────────────────────────
375
+ # Étape 4 + 5 : catalogage et construction de la spec
376
+ # ──────────────────────────────────────────────────────────────────
377
+
378
+ def _build_corpus_spec(
379
+ self,
380
+ *,
381
+ extracted_files: list[tuple[str, Path]],
382
+ corpus_name: str,
383
+ extract_dir: Path,
384
+ metadata: dict[str, str],
385
+ ) -> tuple[CorpusSpec, list[str], int, int, list[str]]:
386
+ """Catalogue images et GT puis construit le ``CorpusSpec``.
387
+
388
+ Returns
389
+ -------
390
+ tuple[CorpusSpec, warnings, n_orphan_gt, n_no_gt, skipped_paths]
391
+ """
392
+ images_by_stem: dict[str, Path] = {}
393
+ gts_by_stem: dict[str, dict[ArtifactType, Path]] = {}
394
+ skipped_paths: list[str] = []
395
+ warnings_list: list[str] = []
396
+
397
+ for arc_name, abs_path in extracted_files:
398
+ # Conserver l'arc_name comme « chemin source » pour le doc
399
+ # id (relatif, lisible). L'image_uri / gt.uri sera l'absolu.
400
+ kind = _classify(arc_name)
401
+ if kind is None:
402
+ skipped_paths.append(arc_name)
403
+ continue
404
+ if isinstance(kind, ArtifactType):
405
+ # GT
406
+ stem = _strip_gt_suffix(arc_name, kind)
407
+ if stem is None:
408
+ skipped_paths.append(arc_name)
409
+ continue
410
+ gts_by_stem.setdefault(stem, {})[kind] = abs_path
411
+ else:
412
+ # Image
413
+ stem = _strip_image_extension(arc_name)
414
+ if stem in images_by_stem:
415
+ warnings_list.append(
416
+ f"Plusieurs images partagent le stem "
417
+ f"{stem!r} — première gardée, "
418
+ f"{arc_name!r} ignorée.",
419
+ )
420
+ skipped_paths.append(arc_name)
421
+ continue
422
+ images_by_stem[stem] = abs_path
423
+
424
+ # Appariement.
425
+ documents: list[DocumentRef] = []
426
+ n_no_gt = 0
427
+ for stem in sorted(images_by_stem):
428
+ image_path = images_by_stem[stem]
429
+ gts = gts_by_stem.pop(stem, {})
430
+ if not gts:
431
+ n_no_gt += 1
432
+ warnings_list.append(
433
+ f"Image {stem!r} sans GT — incluse mais non "
434
+ "évaluable.",
435
+ )
436
+ ground_truths = tuple(
437
+ GroundTruthRef(type=art_type, uri=str(path))
438
+ for art_type, path in sorted(
439
+ gts.items(), key=lambda kv: kv[0].value,
440
+ )
441
+ )
442
+ doc_id = _doc_id_from_stem(stem)
443
+ documents.append(
444
+ DocumentRef(
445
+ id=doc_id,
446
+ image_uri=str(image_path),
447
+ ground_truths=ground_truths,
448
+ ),
449
+ )
450
+
451
+ # GT orphelines (stems sans image correspondante).
452
+ n_orphan_gt = 0
453
+ for stem, gts in gts_by_stem.items():
454
+ for art_type in gts:
455
+ n_orphan_gt += 1
456
+ warnings_list.append(
457
+ f"GT orpheline (pas d'image pour stem "
458
+ f"{stem!r}) : niveau {art_type.value!r}.",
459
+ )
460
+
461
+ spec = CorpusSpec(
462
+ name=corpus_name,
463
+ documents=tuple(documents),
464
+ metadata=metadata,
465
+ )
466
+ return spec, warnings_list, n_orphan_gt, n_no_gt, skipped_paths
467
+
468
+
469
+ # ──────────────────────────────────────────────────────────────────────
470
+ # Helpers de classification
471
+ # ──────────────────────────────────────────────────────────────────────
472
+
473
+
474
+ def _is_os_noise(arc_name: str) -> bool:
475
+ return any(p.search(arc_name) for p in _OS_NOISE_PATTERNS)
476
+
477
+
478
+ def _classify(arc_name: str) -> ArtifactType | str | None:
479
+ """Classifie une entrée en ``ArtifactType`` (GT) ou ``"image"``.
480
+
481
+ Returns
482
+ -------
483
+ ArtifactType si GT reconnue, "image" si image reconnue,
484
+ None si non classifiable.
485
+ """
486
+ lower = arc_name.lower()
487
+ for suffix, art_type in _GT_SUFFIX_TO_TYPE:
488
+ if lower.endswith(suffix):
489
+ return art_type
490
+ # On distingue les images : extension reconnue ET pas de ``.gt.``.
491
+ # (``foo.gt.png`` est conceptuellement pas une convention valide,
492
+ # mais on défend.)
493
+ if ".gt." in lower:
494
+ return None
495
+ for ext in _IMAGE_EXTENSIONS:
496
+ if lower.endswith(ext):
497
+ return "image"
498
+ return None
499
+
500
+
501
+ def _strip_gt_suffix(arc_name: str, art_type: ArtifactType) -> str | None:
502
+ """Retire le suffixe GT et retourne le stem. ``None`` si non match."""
503
+ lower = arc_name.lower()
504
+ for suffix, t in _GT_SUFFIX_TO_TYPE:
505
+ if t is art_type and lower.endswith(suffix):
506
+ return arc_name[: len(arc_name) - len(suffix)]
507
+ return None
508
+
509
+
510
+ def _strip_image_extension(arc_name: str) -> str:
511
+ """Retire l'extension image (case-insensitive)."""
512
+ lower = arc_name.lower()
513
+ for ext in _IMAGE_EXTENSIONS:
514
+ if lower.endswith(ext):
515
+ return arc_name[: len(arc_name) - len(ext)]
516
+ return arc_name
517
+
518
+
519
+ _DOC_ID_INVALID_RE = re.compile(r"[^A-Za-z0-9_.\-/]")
520
+
521
+
522
+ def _doc_id_from_stem(stem: str) -> str:
523
+ """Convertit un stem (chemin relatif) en ``DocumentRef.id`` valide.
524
+
525
+ Le validateur de ``DocumentRef`` exige
526
+ ``[A-Za-z0-9_.\\-/]+`` — on remplace tout caractère hors de cet
527
+ alphabet par ``_`` (typique : espaces, accents, parenthèses dans
528
+ des noms BnF).
529
+ """
530
+ cleaned = _DOC_ID_INVALID_RE.sub("_", stem)
531
+ if not cleaned:
532
+ return "doc"
533
+ return cleaned
534
+
535
+
536
+ __all__ = [
537
+ "CorpusImportError",
538
+ "CorpusImportReport",
539
+ "CorpusService",
540
+ ]
tests/security/test_sprint_a14_s20_corpus_service.py ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S20 — ``CorpusService`` (import ZIP sandboxé +
2
+ détection des paires image/GT).
3
+
4
+ Couverture :
5
+
6
+ - Import basique : 1 image + 1 GT → 1 doc.
7
+ - Détection de tous les niveaux GT (alto, page, entities,
8
+ reading_order, txt).
9
+ - GT multi-niveaux pour le même stem → un seul doc avec plusieurs
10
+ GroundTruthRef.
11
+ - Image sans GT → doc inclus + warning, ``n_images_without_gt`` > 0.
12
+ - GT orpheline (sans image) → warning + non rattachée,
13
+ ``n_gt_without_image`` > 0.
14
+ - Filtrage silencieux des artefacts macOS (``__MACOSX/``, ``._*``,
15
+ ``.DS_Store``, ``Thumbs.db``).
16
+
17
+ Sécurité :
18
+
19
+ - Path traversal (``../etc/passwd``) → ``CorpusImportError``.
20
+ - Chemin absolu Unix (``/etc/passwd``) → ``CorpusImportError``.
21
+ - Chemin absolu Windows (``C:\\evil``) → ``CorpusImportError``.
22
+ - Octet nul dans le nom → ``CorpusImportError``.
23
+ - Symlink dans l'archive → ``CorpusImportError``.
24
+ - ZIP plus volumineux que ``max_zip_size_bytes`` → erreur.
25
+ - Trop d'entrées (zip bomb par nombre) → erreur.
26
+ - Décompression trop volumineuse (zip bomb par expansion) → erreur.
27
+ - Archive corrompue / non-ZIP → erreur.
28
+
29
+ Cas limites :
30
+
31
+ - ZIP vide → corpus vide, pas d'erreur.
32
+ - corpus_name avec caractères spéciaux → sanitizé via
33
+ ``safe_report_name``.
34
+ - ZIP avec hiérarchie (``volA/folio.png``) → doc_id préserve la
35
+ hiérarchie.
36
+ - Doublon d'image (même stem, deux extensions) → premier gardé +
37
+ warning.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import io
43
+ import zipfile
44
+ from pathlib import Path
45
+
46
+ import pytest
47
+
48
+ from picarones.app.services import (
49
+ CorpusImportError,
50
+ CorpusImportReport,
51
+ CorpusService,
52
+ WorkspaceManager,
53
+ )
54
+ from picarones.domain.artifacts import ArtifactType
55
+
56
+
57
+ # ──────────────────────────────────────────────────────────────────
58
+ # Fixtures
59
+ # ──────────────────────────────────────────────────────────────────
60
+
61
+
62
+ @pytest.fixture
63
+ def workspace(tmp_path: Path) -> WorkspaceManager:
64
+ return WorkspaceManager(tmp_path)
65
+
66
+
67
+ @pytest.fixture
68
+ def service(workspace: WorkspaceManager) -> CorpusService:
69
+ return CorpusService(workspace)
70
+
71
+
72
+ def _make_zip(entries: dict[str, bytes]) -> bytes:
73
+ """Produit un ZIP en mémoire à partir d'un dict ``{arcname: bytes}``."""
74
+ buf = io.BytesIO()
75
+ with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
76
+ for name, data in entries.items():
77
+ zf.writestr(name, data)
78
+ return buf.getvalue()
79
+
80
+
81
+ def _png_bytes() -> bytes:
82
+ """Minimal valid PNG header (signature + IHDR), suffisant pour les
83
+ tests qui ne valident pas l'image."""
84
+ return (
85
+ b"\x89PNG\r\n\x1a\n"
86
+ b"\x00\x00\x00\rIHDR"
87
+ b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00"
88
+ b"\x1f\x15\xc4\x89"
89
+ )
90
+
91
+
92
+ # ──────────────────────────────────────────────────────────────────
93
+ # Import basique + détection GT
94
+ # ──────────────────────────────────────────────────────────────────
95
+
96
+
97
+ class TestBasicImport:
98
+ def test_image_plus_text_gt_creates_one_doc(
99
+ self, service: CorpusService,
100
+ ) -> None:
101
+ zip_bytes = _make_zip({
102
+ "doc01.png": _png_bytes(),
103
+ "doc01.gt.txt": "Hello world".encode("utf-8"),
104
+ })
105
+ report = service.import_zip(zip_bytes, corpus_name="test_corpus")
106
+ assert isinstance(report, CorpusImportReport)
107
+ assert report.n_documents == 1
108
+ doc = report.spec.documents[0]
109
+ assert doc.id == "doc01"
110
+ assert doc.image_uri is not None
111
+ assert Path(doc.image_uri).name == "doc01.png"
112
+ assert len(doc.ground_truths) == 1
113
+ gt = doc.ground_truths[0]
114
+ assert gt.type == ArtifactType.RAW_TEXT
115
+ assert Path(gt.uri).name == "doc01.gt.txt"
116
+
117
+ def test_extracted_dir_lives_inside_workspace(
118
+ self,
119
+ service: CorpusService,
120
+ workspace: WorkspaceManager,
121
+ ) -> None:
122
+ zip_bytes = _make_zip({"doc.png": _png_bytes()})
123
+ report = service.import_zip(zip_bytes, corpus_name="x")
124
+ # Garantie sandbox : le dir extrait est sous le workspace root.
125
+ report.extracted_dir.relative_to(workspace.root)
126
+
127
+ def test_corpus_name_is_sanitized(
128
+ self, service: CorpusService,
129
+ ) -> None:
130
+ zip_bytes = _make_zip({"doc.png": _png_bytes()})
131
+ report = service.import_zip(
132
+ zip_bytes,
133
+ corpus_name="my/corpus/with/slashes",
134
+ )
135
+ # Les / sont retirés par safe_report_name.
136
+ assert "/" not in report.spec.name
137
+ assert report.spec.name == "mycorpuswithslashes"
138
+
139
+
140
+ class TestGTLevelDetection:
141
+ @pytest.mark.parametrize(
142
+ "suffix,expected_type",
143
+ [
144
+ (".gt.alto.xml", ArtifactType.ALTO_XML),
145
+ (".gt.page.xml", ArtifactType.PAGE_XML),
146
+ (".gt.entities.json", ArtifactType.ENTITIES),
147
+ (".gt.reading_order.json", ArtifactType.READING_ORDER),
148
+ (".gt.txt", ArtifactType.RAW_TEXT),
149
+ ],
150
+ )
151
+ def test_each_gt_suffix_is_recognized(
152
+ self,
153
+ service: CorpusService,
154
+ suffix: str,
155
+ expected_type: ArtifactType,
156
+ ) -> None:
157
+ zip_bytes = _make_zip({
158
+ "doc.png": _png_bytes(),
159
+ f"doc{suffix}": b"<gt></gt>",
160
+ })
161
+ report = service.import_zip(zip_bytes, corpus_name="x")
162
+ assert report.n_documents == 1
163
+ doc = report.spec.documents[0]
164
+ assert len(doc.ground_truths) == 1
165
+ assert doc.ground_truths[0].type == expected_type
166
+
167
+ def test_multi_level_gt_for_same_stem(
168
+ self, service: CorpusService,
169
+ ) -> None:
170
+ zip_bytes = _make_zip({
171
+ "doc.png": _png_bytes(),
172
+ "doc.gt.txt": b"text",
173
+ "doc.gt.alto.xml": b"<alto></alto>",
174
+ "doc.gt.entities.json": b"[]",
175
+ })
176
+ report = service.import_zip(zip_bytes, corpus_name="x")
177
+ assert report.n_documents == 1
178
+ doc = report.spec.documents[0]
179
+ types = {gt.type for gt in doc.ground_truths}
180
+ assert types == {
181
+ ArtifactType.RAW_TEXT,
182
+ ArtifactType.ALTO_XML,
183
+ ArtifactType.ENTITIES,
184
+ }
185
+
186
+ def test_case_insensitive_extension_for_image(
187
+ self, service: CorpusService,
188
+ ) -> None:
189
+ zip_bytes = _make_zip({
190
+ "doc.PNG": _png_bytes(),
191
+ "doc.gt.txt": b"x",
192
+ })
193
+ report = service.import_zip(zip_bytes, corpus_name="x")
194
+ assert report.n_documents == 1
195
+
196
+
197
+ class TestPairing:
198
+ def test_image_without_gt_is_included_with_warning(
199
+ self, service: CorpusService,
200
+ ) -> None:
201
+ zip_bytes = _make_zip({"only_image.png": _png_bytes()})
202
+ report = service.import_zip(zip_bytes, corpus_name="x")
203
+ assert report.n_documents == 1
204
+ assert report.n_images_without_gt == 1
205
+ assert any("sans GT" in w for w in report.warnings)
206
+
207
+ def test_gt_without_image_is_orphan(
208
+ self, service: CorpusService,
209
+ ) -> None:
210
+ zip_bytes = _make_zip({"orphan.gt.txt": b"text"})
211
+ report = service.import_zip(zip_bytes, corpus_name="x")
212
+ assert report.n_documents == 0
213
+ assert report.n_gt_without_image == 1
214
+ assert any("orpheline" in w for w in report.warnings)
215
+
216
+ def test_duplicate_image_stem_keeps_first(
217
+ self, service: CorpusService,
218
+ ) -> None:
219
+ zip_bytes = _make_zip({
220
+ "doc.png": _png_bytes(),
221
+ "doc.jpg": b"jpeg-bytes",
222
+ "doc.gt.txt": b"text",
223
+ })
224
+ report = service.import_zip(zip_bytes, corpus_name="x")
225
+ assert report.n_documents == 1
226
+ # Une des deux est sautée (warning).
227
+ assert any("partagent le stem" in w for w in report.warnings)
228
+
229
+ def test_hierarchical_paths_preserved_in_doc_id(
230
+ self, service: CorpusService,
231
+ ) -> None:
232
+ zip_bytes = _make_zip({
233
+ "volA/folio_001.png": _png_bytes(),
234
+ "volA/folio_001.gt.txt": b"x",
235
+ "volB/folio_002.png": _png_bytes(),
236
+ "volB/folio_002.gt.txt": b"y",
237
+ })
238
+ report = service.import_zip(zip_bytes, corpus_name="x")
239
+ assert report.n_documents == 2
240
+ doc_ids = sorted(d.id for d in report.spec.documents)
241
+ assert doc_ids == ["volA/folio_001", "volB/folio_002"]
242
+
243
+
244
+ # ──────────────────────────────────────────────────────────────────
245
+ # Filtrage silencieux des artefacts OS
246
+ # ──────────────────────────────────────────────────────────────────
247
+
248
+
249
+ class TestOSNoiseFiltering:
250
+ def test_macosx_dir_is_skipped(self, service: CorpusService) -> None:
251
+ zip_bytes = _make_zip({
252
+ "doc.png": _png_bytes(),
253
+ "doc.gt.txt": b"x",
254
+ "__MACOSX/doc.png": b"macos-meta",
255
+ "__MACOSX/._doc.png": b"macos-meta-fork",
256
+ })
257
+ report = service.import_zip(zip_bytes, corpus_name="x")
258
+ assert report.n_documents == 1
259
+ assert report.n_skipped_noise >= 1
260
+
261
+ def test_dotunderscore_files_skipped(
262
+ self, service: CorpusService,
263
+ ) -> None:
264
+ zip_bytes = _make_zip({
265
+ "doc.png": _png_bytes(),
266
+ "._doc.png": b"resource-fork",
267
+ })
268
+ report = service.import_zip(zip_bytes, corpus_name="x")
269
+ assert report.n_documents == 1
270
+
271
+ def test_dsstore_skipped(self, service: CorpusService) -> None:
272
+ zip_bytes = _make_zip({
273
+ "doc.png": _png_bytes(),
274
+ ".DS_Store": b"finder-metadata",
275
+ })
276
+ report = service.import_zip(zip_bytes, corpus_name="x")
277
+ assert report.n_documents == 1
278
+ assert report.n_skipped_noise >= 1
279
+
280
+ def test_thumbsdb_skipped_case_insensitive(
281
+ self, service: CorpusService,
282
+ ) -> None:
283
+ zip_bytes = _make_zip({
284
+ "doc.png": _png_bytes(),
285
+ "Thumbs.db": b"win-thumbs",
286
+ "subdir/THUMBS.DB": b"more",
287
+ })
288
+ report = service.import_zip(zip_bytes, corpus_name="x")
289
+ assert report.n_documents == 1
290
+ assert report.n_skipped_noise >= 2
291
+
292
+
293
+ # ──────────────────────────────────────────────────────────────────
294
+ # Sécurité — refus brutal
295
+ # ──────────────────────────────────────────────────────────────────
296
+
297
+
298
+ class TestSecurityRejections:
299
+ def test_traversal_in_arcname_is_rejected(
300
+ self, service: CorpusService,
301
+ ) -> None:
302
+ zip_bytes = _make_zip({"../escape.txt": b"evil"})
303
+ with pytest.raises(CorpusImportError, match="Traversal"):
304
+ service.import_zip(zip_bytes, corpus_name="x")
305
+
306
+ def test_absolute_unix_path_is_rejected(
307
+ self, service: CorpusService,
308
+ ) -> None:
309
+ zip_bytes = _make_zip({"/etc/passwd": b"root:x:0:0::/root:/bin/bash"})
310
+ with pytest.raises(CorpusImportError, match="absolu"):
311
+ service.import_zip(zip_bytes, corpus_name="x")
312
+
313
+ def test_absolute_windows_path_is_rejected(
314
+ self, service: CorpusService,
315
+ ) -> None:
316
+ zip_bytes = _make_zip({"C:/evil.txt": b"evil"})
317
+ with pytest.raises(CorpusImportError, match="absolu"):
318
+ service.import_zip(zip_bytes, corpus_name="x")
319
+
320
+ def test_corrupt_zip_raises(self, service: CorpusService) -> None:
321
+ with pytest.raises(CorpusImportError, match="invalide"):
322
+ service.import_zip(b"not a zip", corpus_name="x")
323
+
324
+ def test_zip_too_large_raises(
325
+ self, workspace: WorkspaceManager,
326
+ ) -> None:
327
+ small_service = CorpusService(workspace, max_zip_size_bytes=10)
328
+ zip_bytes = _make_zip({"doc.png": _png_bytes()})
329
+ assert len(zip_bytes) > 10
330
+ with pytest.raises(CorpusImportError, match="trop volumineux"):
331
+ small_service.import_zip(zip_bytes, corpus_name="x")
332
+
333
+ def test_too_many_entries_raises(
334
+ self, workspace: WorkspaceManager,
335
+ ) -> None:
336
+ cap_service = CorpusService(workspace, max_entry_count=3)
337
+ zip_bytes = _make_zip({f"f{i}.png": _png_bytes() for i in range(5)})
338
+ with pytest.raises(CorpusImportError, match="trop d'entrées"):
339
+ cap_service.import_zip(zip_bytes, corpus_name="x")
340
+
341
+ def test_uncompressed_too_large_raises(
342
+ self, workspace: WorkspaceManager,
343
+ ) -> None:
344
+ # 3 fichiers de 100 octets, plafond à 200 → refus.
345
+ cap_service = CorpusService(
346
+ workspace, max_uncompressed_bytes=200,
347
+ )
348
+ zip_bytes = _make_zip({
349
+ f"f{i}.png": b"x" * 100 for i in range(3)
350
+ })
351
+ with pytest.raises(CorpusImportError, match="décompressé trop volumineux"):
352
+ cap_service.import_zip(zip_bytes, corpus_name="x")
353
+
354
+ def test_symlink_entry_rejected(
355
+ self, service: CorpusService, tmp_path: Path,
356
+ ) -> None:
357
+ # Construire manuellement un ZIP avec une entrée flaggée
358
+ # symlink (mode UNIX 0xA000).
359
+ buf = io.BytesIO()
360
+ with zipfile.ZipFile(buf, mode="w") as zf:
361
+ info = zipfile.ZipInfo("evil_link")
362
+ info.external_attr = 0xA000 << 16 # S_IFLNK
363
+ zf.writestr(info, "/etc/passwd")
364
+ with pytest.raises(CorpusImportError, match="Symlink"):
365
+ service.import_zip(buf.getvalue(), corpus_name="x")
366
+
367
+
368
+ # ──────────────────────────────────────────────────────────────────
369
+ # Cas limites
370
+ # ──────────────────────────────────────────────────────────────────
371
+
372
+
373
+ class TestEdgeCases:
374
+ def test_empty_zip_yields_empty_corpus(
375
+ self, service: CorpusService,
376
+ ) -> None:
377
+ zip_bytes = _make_zip({})
378
+ report = service.import_zip(zip_bytes, corpus_name="x")
379
+ assert report.n_documents == 0
380
+ assert report.n_images_without_gt == 0
381
+ assert report.n_gt_without_image == 0
382
+
383
+ def test_unrecognized_extension_is_skipped(
384
+ self, service: CorpusService,
385
+ ) -> None:
386
+ zip_bytes = _make_zip({
387
+ "doc.png": _png_bytes(),
388
+ "doc.gt.txt": b"x",
389
+ "readme.md": b"# readme",
390
+ })
391
+ report = service.import_zip(zip_bytes, corpus_name="x")
392
+ assert report.n_documents == 1
393
+ # readme.md sauté car pas image, pas GT reconnue.
394
+ assert "readme.md" in report.skipped_paths
395
+
396
+ def test_invalid_chars_in_doc_id_are_replaced(
397
+ self, service: CorpusService,
398
+ ) -> None:
399
+ # Espaces, parenthèses, accents → remplacés par _.
400
+ zip_bytes = _make_zip({
401
+ "doc avec espaces (BnF).png": _png_bytes(),
402
+ "doc avec espaces (BnF).gt.txt": b"x",
403
+ })
404
+ report = service.import_zip(zip_bytes, corpus_name="x")
405
+ assert report.n_documents == 1
406
+ doc = report.spec.documents[0]
407
+ # Le doc_id ne contient plus d'espaces ni de parenthèses.
408
+ assert " " not in doc.id
409
+ assert "(" not in doc.id
410
+ assert ")" not in doc.id
411
+
412
+ def test_metadata_passes_through(
413
+ self, service: CorpusService,
414
+ ) -> None:
415
+ zip_bytes = _make_zip({"doc.png": _png_bytes()})
416
+ report = service.import_zip(
417
+ zip_bytes,
418
+ corpus_name="x",
419
+ metadata={"language": "fr", "period": "early_modern"},
420
+ )
421
+ assert report.spec.metadata == {
422
+ "language": "fr",
423
+ "period": "early_modern",
424
+ }
425
+
426
+ def test_multiple_imports_dont_collide(
427
+ self, service: CorpusService,
428
+ ) -> None:
429
+ """Deux imports avec corpus_name distincts coexistent."""
430
+ zb = _make_zip({"doc.png": _png_bytes()})
431
+ r1 = service.import_zip(zb, corpus_name="alpha")
432
+ r2 = service.import_zip(zb, corpus_name="beta")
433
+ assert r1.extracted_dir != r2.extracted_dir
434
+ assert r1.extracted_dir.exists()
435
+ assert r2.extracted_dir.exists()
436
+
437
+
438
+ # ──────────────────────────────────────────────────────────────────
439
+ # Smoke test : import bout-en-bout puis BenchmarkService consume
440
+ # ──────────────────────────────────────────────────────────────────
441
+
442
+
443
+ class TestSmokeIntegration:
444
+ def test_imported_corpus_is_consumable_by_benchmark_service(
445
+ self, service: CorpusService,
446
+ ) -> None:
447
+ """L'import produit un CorpusSpec immédiatement utilisable
448
+ — vérifie l'API en bout-en-bout sans lancer un vrai bench."""
449
+ zip_bytes = _make_zip({
450
+ "doc01.png": _png_bytes(),
451
+ "doc01.gt.txt": "première page".encode("utf-8"),
452
+ "doc02.png": _png_bytes(),
453
+ "doc02.gt.txt": "deuxième page".encode("utf-8"),
454
+ "doc02.gt.alto.xml": b"<alto/>",
455
+ })
456
+ report = service.import_zip(
457
+ zip_bytes,
458
+ corpus_name="bnf_test",
459
+ metadata={"language": "fr"},
460
+ )
461
+ assert report.n_documents == 2
462
+ # Un doc avec 1 GT (text), un avec 2 GT (text + alto).
463
+ gts_by_doc = {d.id: d.available_gt_types for d in report.spec.documents}
464
+ assert ArtifactType.RAW_TEXT in gts_by_doc["doc01"]
465
+ assert set(gts_by_doc["doc02"]) == {
466
+ ArtifactType.RAW_TEXT, ArtifactType.ALTO_XML,
467
+ }