Files
Antoine Van Elstraete dcf7f69c5a feat: add sanitize_plaintext for XMPP text sanitization (M10-U3)
Add sanitize_plaintext(text: str) -> str to utils/text.py for preparing
XMPP plain-text message bodies from untrusted Pronote/AI content.

- Strips HTML tags via BeautifulSoup (html.parser)
- Strips C0, DEL, and C1 control characters (preserves \t, \n, \r)
- Preserves Unicode including emojis (📌📅📚💬📢)
- Idempotent: f(f(x)) == f(x)
- Addresses SEC-XMPP-06: XMPP injection hardening

37 unit tests covering HTML, entities, control chars, emojis, idempotence.

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-07 21:04:13 +02:00

61 lines
2.4 KiB
Python

"""Utilitaires de normalisation et de traitement du texte.
Ce module centralise les transformations de texte partagées par plusieurs
couches du pipeline ``pronote-sync`` (sources, synchronisation) afin que les
modules de logique de domaine ne dépendent pas d'adaptateurs concrets.
"""
from __future__ import annotations
import re
import unicodedata
from bs4 import BeautifulSoup
__all__ = ["normalize_subject", "sanitize_plaintext"]
def normalize_subject(subject: str) -> str:
"""Normalise une matière pour le matching déterministe.
Applique la normalisation Unicode NFKC, unifie les espaces (y compris
tabulations et espaces insécables), supprime la ponctuation et met la
chaîne en minuscules. Deux représentations visuellement identiques d'une
même matière produisent ainsi la même forme normalisée.
:param subject: La matière brute.
:return: La forme normalisée (NFKC, espaces unifiés, sans ponctuation, minuscule).
:rtype: str
"""
normalized = unicodedata.normalize("NFKC", subject)
normalized = re.sub(r"\s+", " ", normalized).strip()
normalized = re.sub(r"[^\w\s]", "", normalized)
normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized.lower()
# Pattern des caractères de contrôle ASCII non imprimables (à l'exception
# des tabulations ``\\t``, des sauts de ligne ``\\n`` et des retours chariot ``\\r``).
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
def sanitize_plaintext(text: str) -> str:
"""Prépare un texte pour le corps de message XMPP en texte brut.
Supprime les balises HTML (via ``BeautifulSoup`` avec le parseur
``html.parser``) puis les caractères de contrôle ASCII non imprimables,
à l'exception des tabulations (``\\t``), des sauts de ligne (``\\n``) et
des retours chariot (``\\r``). Les caractères Unicode au-delà de ``\\x1f``,
notamment les emojis, sont conservés. La transformation est idempotente :
appliquée deux fois, elle produit le même résultat qu'appliquée une seule
fois. Une chaîne vide donne une chaîne vide.
:param text: Le texte brut ou HTML à assainir.
:return: Le texte assaini, sans balises HTML ni caractères de contrôle.
:rtype: str
"""
# Étape 1 : suppression des balises HTML.
plain = BeautifulSoup(text, "html.parser").get_text()
# Étape 2 : suppression des caractères de contrôle.
return _CONTROL_CHARS_RE.sub("", plain)