From 51887612094332ec670881b7d6bd68c3cb92ff95 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 10 Sep 2026 21:15:08 +0200 Subject: [PATCH 1/4] fix: add QR token state lock primitive --- pronote_sync/errors.py | 15 +++++ pronote_sync/sources/pronote/auth_state.py | 68 +++++++++++++++++++- tests/unit/test_pronote_auth_state.py | 75 ++++++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) diff --git a/pronote_sync/errors.py b/pronote_sync/errors.py index 0fe1e74..c46fc14 100644 --- a/pronote_sync/errors.py +++ b/pronote_sync/errors.py @@ -38,6 +38,21 @@ class PronoteAuthRotationError(PronoteSyncError): super().__init__(message) +class PronoteAuthStateLockError(PronoteSyncError): + """Erreur levée lorsqu'un autre processus détient l'état d'authentification. + + Cette erreur indique qu'une opération QR code / token concurrente est en + cours. Son message ne contient ni chemin local sensible ni credential. + """ + + def __init__(self, message: str) -> None: + """Initialise l'erreur de contention du verrou d'état. + + :param message: Message actionnable expurgé décrivant la contention. + """ + super().__init__(message) + + class ErrorSeverity(StrEnum): """Niveau de gravité d'une erreur produite par le pipeline.""" diff --git a/pronote_sync/sources/pronote/auth_state.py b/pronote_sync/sources/pronote/auth_state.py index e70d9df..99153d1 100644 --- a/pronote_sync/sources/pronote/auth_state.py +++ b/pronote_sync/sources/pronote/auth_state.py @@ -14,10 +14,13 @@ from __future__ import annotations import json import logging import os +from collections.abc import Generator +from contextlib import contextmanager +from fcntl import LOCK_EX, LOCK_NB, LOCK_UN, flock from pathlib import Path from typing import Any -from pronote_sync.errors import PronoteSyncError +from pronote_sync.errors import PronoteAuthStateLockError, PronoteSyncError from pronote_sync.utils.redaction import redact_exception, redact_secrets logger = logging.getLogger(__name__) @@ -108,6 +111,69 @@ class PronoteAuthState: credentials[key] = value return credentials + @contextmanager + def lock(self) -> Generator[None]: + """Protège une opération d'état par un verrou POSIX non bloquant. + + Le verrou est conservé dans le fichier frère ``.lock`` afin + de survivre à l'écriture atomique du fichier d'état. Le fichier de + verrou reste présent après libération et est créé en ``0600`` pour ne + pas élargir l'accès aux métadonnées de l'état sensible. + + :return: Un gestionnaire de contexte qui tient le verrou exclusif. + :rtype: collections.abc.Generator[None, None, None] + :raises PronoteAuthStateLockError: Si un autre processus détient déjà + le verrou ou si son acquisition échoue. + """ + lock_file = self._state_file.with_name(f"{self._state_file.name}.lock") + descriptor: int | None = None + try: + descriptor = os.open( + str(lock_file), + os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, + 0o600, + ) + os.fchmod(descriptor, 0o600) + except OSError: + logger.error("Impossible d'ouvrir le verrou d'état d'authentification Pronote.") + if descriptor is not None: + os.close(descriptor) + + if descriptor is None: + raise PronoteAuthStateLockError( + "Impossible d'acquérir le verrou d'état d'authentification Pronote." + ) from None + + is_contended = False + lock_acquisition_failed = False + try: + flock(descriptor, LOCK_EX | LOCK_NB) + except BlockingIOError: + is_contended = True + except OSError: + logger.error("Impossible d'acquérir le verrou d'état d'authentification Pronote.") + os.close(descriptor) + lock_acquisition_failed = True + + if lock_acquisition_failed: + raise PronoteAuthStateLockError( + "Impossible d'acquérir le verrou d'état d'authentification Pronote." + ) from None + + if is_contended: + os.close(descriptor) + raise PronoteAuthStateLockError( + "Une autre opération d'authentification Pronote est déjà en cours." + ) + + try: + yield + finally: + try: + flock(descriptor, LOCK_UN) + finally: + os.close(descriptor) + def save(self, credentials: dict[str, str]) -> None: """Sauvegarde les credentials dans le fichier d'état, de manière atomique. diff --git a/tests/unit/test_pronote_auth_state.py b/tests/unit/test_pronote_auth_state.py index fcc1e30..6fa95d3 100644 --- a/tests/unit/test_pronote_auth_state.py +++ b/tests/unit/test_pronote_auth_state.py @@ -17,10 +17,12 @@ from __future__ import annotations import json import logging import os +from fcntl import LOCK_EX, LOCK_NB, LOCK_UN, flock from pathlib import Path import pytest +from pronote_sync.errors import PronoteAuthStateLockError from pronote_sync.sources.pronote.auth_state import PronoteAuthState @@ -206,3 +208,76 @@ def test_no_credentials_in_logs(tmp_path: Path, caplog: pytest.LogCaptureFixture assert "SENTINEL_USER_ZZZ" not in caplog.text assert "SENTINEL_PASSWORD_ZZZ" not in caplog.text assert "SENTINEL_UUID_ZZZ" not in caplog.text + + +def test_lock_rejects_concurrent_access_with_a_redacted_dedicated_error(tmp_path: Path) -> None: + """Vérifie qu'un verrou concurrent échoue immédiatement sans fuite interne. + + :param tmp_path: Fixture pytest pour un répertoire temporaire. + :return: None + """ + state_file = tmp_path / ".pronote_auth_state.json" + state = PronoteAuthState(state_file) + competing_state = PronoteAuthState(state_file) + + with state.lock(): + assert state_file.with_name(f"{state_file.name}.lock").exists() + with pytest.raises(PronoteAuthStateLockError) as exc_info: + with competing_state.lock(): + pass + + assert "BlockingIOError" not in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + + +def test_lock_open_failure_does_not_log_sensitive_lock_path( + tmp_path: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + """Vérifie qu'un échec d'ouverture du verrou ne divulgue pas son chemin. + + :param tmp_path: Fixture pytest pour un répertoire temporaire. + :param caplog: Fixture pytest pour capturer les logs. + :param monkeypatch: Fixture pytest pour remplacer l'ouverture du verrou. + :return: None + """ + sentinel_path = "/SENTINEL_LOCK_PATH_ZZZ/.pronote_auth_state.json.lock" + + def raise_lock_open_error(*args: object, **kwargs: object) -> int: + """Simule un refus d'ouverture portant un chemin sensible.""" + del args, kwargs + raise OSError(13, "Permission denied", sentinel_path) + + monkeypatch.setattr(os, "open", raise_lock_open_error) + + with caplog.at_level(logging.ERROR): + with pytest.raises(PronoteAuthStateLockError) as exc_info: + with PronoteAuthState(tmp_path / ".pronote_auth_state.json").lock(): + pass + + assert "Impossible d'ouvrir le verrou d'état d'authentification Pronote" in caplog.text + assert "SENTINEL_LOCK_PATH_ZZZ" not in caplog.text + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + + +def test_lock_is_released_when_the_protected_operation_raises(tmp_path: Path) -> None: + """Vérifie que le verrou est libéré même si le bloc protégé échoue. + + :param tmp_path: Fixture pytest pour un répertoire temporaire. + :return: None + """ + state_file = tmp_path / ".pronote_auth_state.json" + lock_file = state_file.with_name(f"{state_file.name}.lock") + state = PronoteAuthState(state_file) + + with pytest.raises(RuntimeError, match="échec simulé"): + with state.lock(): + raise RuntimeError("échec simulé") + + descriptor = os.open(lock_file, os.O_RDWR) + try: + flock(descriptor, LOCK_EX | LOCK_NB) + flock(descriptor, LOCK_UN) + finally: + os.close(descriptor) From 22a662ab3982f4c286934297c538a32eb03207b9 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 10 Sep 2026 21:24:11 +0200 Subject: [PATCH 2/4] fix: serialize QR token client operations --- pronote_sync/sources/pronote/client.py | 232 ++++++++++++++----------- tests/unit/test_pronote_client.py | 87 ++++++++++ 2 files changed, 216 insertions(+), 103 deletions(-) diff --git a/pronote_sync/sources/pronote/client.py b/pronote_sync/sources/pronote/client.py index 57051c3..7f5c2ab 100644 --- a/pronote_sync/sources/pronote/client.py +++ b/pronote_sync/sources/pronote/client.py @@ -12,6 +12,8 @@ from __future__ import annotations import json import logging +from collections.abc import Generator +from contextlib import contextmanager from datetime import date from pathlib import Path from typing import Any, Protocol @@ -252,6 +254,26 @@ class PronoteClient: except Exception as exc: logger.debug("Échec de la persistance des credentials : %s", redact_exception(exc)) + @contextmanager + def _qr_token_operation_lock(self) -> Generator[None]: + """Verrouille un cycle d'authentification et de récupération QR/token. + + Le verrou englobe le chargement du token, le login, l'opération de + données et la persistance qui suit. Il est volontairement absent du + mode ``password``, qui ne partage pas de fichier d'état de token. + + :return: Un gestionnaire de contexte protégeant le cycle QR/token. + :rtype: collections.abc.Generator[None, None, None] + :raises PronoteAuthStateLockError: Si l'état QR/token est déjà utilisé + par une autre opération. + """ + if self._settings.auth_mode != "qr_token" or self._auth_state is None: + yield + return + + with self._auth_state.lock(): + yield + def _connect_password(self) -> pronotepy.Client: """Connecte le client ``pronotepy`` en mode ``password``. @@ -426,37 +448,38 @@ class PronoteClient: :return: Liste des messages des professeurs ; vide en cas d'erreur. :rtype: list[Message] """ - try: - client = self._connect() - messages: list[Message] = [] - for discussion in client.discussions(): - for message in discussion.messages: - messages.append( - Message( - id=message.id, - type=MessageType.DISCUSSION, - title=discussion.subject, - content=message.content, - author=message.author or "", - date=message.created, - read=message.seen, + with self._qr_token_operation_lock(): + try: + client = self._connect() + messages: list[Message] = [] + for discussion in client.discussions(): + for message in discussion.messages: + messages.append( + Message( + id=message.id, + type=MessageType.DISCUSSION, + title=discussion.subject, + content=message.content, + author=message.author or "", + date=message.created, + read=message.seen, + ) ) - ) - self._persist_credentials() - return messages - except ( - pronotepy.PronoteAPIError, - ValueError, - requests.RequestException, - ConnectionError, - TimeoutError, - ) as exc: - logger.error( - "Échec de la récupération des messages Pronote : %s", - redact_exception(exc), - ) - self._persist_credentials() - return [] + self._persist_credentials() + return messages + except ( + pronotepy.PronoteAPIError, + ValueError, + requests.RequestException, + ConnectionError, + TimeoutError, + ) as exc: + logger.error( + "Échec de la récupération des messages Pronote : %s", + redact_exception(exc), + ) + self._persist_credentials() + return [] def get_informations(self) -> list[Message]: """Récupère les informations et sondages Pronote. @@ -467,36 +490,37 @@ class PronoteClient: :return: Liste des informations et sondages ; vide en cas d'erreur. :rtype: list[Message] """ - try: - client = self._connect() - messages: list[Message] = [] - for info in client.information_and_surveys(): - messages.append( - Message( - id=info.id, - type=MessageType.SURVEY if info.survey else MessageType.INFORMATION, - title=info.title or "", - content=info.content(), - author=info.author, - date=info.creation_date, - read=info.read, + with self._qr_token_operation_lock(): + try: + client = self._connect() + messages: list[Message] = [] + for info in client.information_and_surveys(): + messages.append( + Message( + id=info.id, + type=MessageType.SURVEY if info.survey else MessageType.INFORMATION, + title=info.title or "", + content=info.content(), + author=info.author, + date=info.creation_date, + read=info.read, + ) ) + self._persist_credentials() + return messages + except ( + pronotepy.PronoteAPIError, + ValueError, + requests.RequestException, + ConnectionError, + TimeoutError, + ) as exc: + logger.error( + "Échec de la récupération des informations Pronote : %s", + redact_exception(exc), ) - self._persist_credentials() - return messages - except ( - pronotepy.PronoteAPIError, - ValueError, - requests.RequestException, - ConnectionError, - TimeoutError, - ) as exc: - logger.error( - "Échec de la récupération des informations Pronote : %s", - redact_exception(exc), - ) - self._persist_credentials() - return [] + self._persist_credentials() + return [] def get_lessons(self, start: date, end: date) -> list[Lesson]: """Récupère les cours via ``pronotepy`` (repli iCal). @@ -523,37 +547,38 @@ class PronoteClient: :raises ConnectionError: Si la connexion réseau échoue. :raises TimeoutError: Si la requête réseau expire. """ - client = self._connect() - lessons: list[Lesson] = [] - for lesson in client.lessons(start, end): - content = lesson.content - raw_uid = lesson.id - if raw_uid: - uid = normalize_pronote_uid(raw_uid) - else: - uid = generate_deterministic_uid( - start=lesson.start, - end=lesson.end, - subject=lesson.subject.name if lesson.subject is not None else "", - teachers=list(lesson.teacher_names or ()), - rooms=list(lesson.classrooms or ()), - group=lesson.group_name, + with self._qr_token_operation_lock(): + client = self._connect() + lessons: list[Lesson] = [] + for lesson in client.lessons(start, end): + content = lesson.content + raw_uid = lesson.id + if raw_uid: + uid = normalize_pronote_uid(raw_uid) + else: + uid = generate_deterministic_uid( + start=lesson.start, + end=lesson.end, + subject=lesson.subject.name if lesson.subject is not None else "", + teachers=list(lesson.teacher_names or ()), + rooms=list(lesson.classrooms or ()), + group=lesson.group_name, + ) + lessons.append( + Lesson( + id=uid, + start=lesson.start, + end=lesson.end, + subject=lesson.subject.name if lesson.subject is not None else "", + teachers=tuple(lesson.teacher_names or ()), + rooms=tuple(lesson.classrooms or ()), + group=lesson.group_name, + status=(LessonStatus.CANCELLED if lesson.canceled else LessonStatus.NORMAL), + content=content.description if content is not None else None, + ) ) - lessons.append( - Lesson( - id=uid, - start=lesson.start, - end=lesson.end, - subject=lesson.subject.name if lesson.subject is not None else "", - teachers=tuple(lesson.teacher_names or ()), - rooms=tuple(lesson.classrooms or ()), - group=lesson.group_name, - status=(LessonStatus.CANCELLED if lesson.canceled else LessonStatus.NORMAL), - content=content.description if content is not None else None, - ) - ) - self._persist_credentials() - return lessons + self._persist_credentials() + return lessons def get_homeworks(self, start: date, end: date) -> list[Homework]: """Récupère les devoirs via ``pronotepy``. @@ -576,19 +601,20 @@ class PronoteClient: :raises ConnectionError: Si la connexion réseau échoue. :raises TimeoutError: Si la requête réseau expire. """ - client = self._connect() - homeworks: list[Homework] = [] - for hw in client.homework(start, end): - homeworks.append( - Homework( - id=hw.id, - subject=hw.subject.name, - teachers=(), - assigned_on=None, - due_on=hw.date, - text=hw.description, - html=hw.description, + with self._qr_token_operation_lock(): + client = self._connect() + homeworks: list[Homework] = [] + for hw in client.homework(start, end): + homeworks.append( + Homework( + id=hw.id, + subject=hw.subject.name, + teachers=(), + assigned_on=None, + due_on=hw.date, + text=hw.description, + html=hw.description, + ) ) - ) - self._persist_credentials() - return homeworks + self._persist_credentials() + return homeworks diff --git a/tests/unit/test_pronote_client.py b/tests/unit/test_pronote_client.py index 838ef2b..74d2701 100644 --- a/tests/unit/test_pronote_client.py +++ b/tests/unit/test_pronote_client.py @@ -9,6 +9,8 @@ from __future__ import annotations import json import logging +from collections.abc import Generator +from contextlib import contextmanager from datetime import date, datetime from pathlib import Path from unittest.mock import MagicMock @@ -1203,6 +1205,91 @@ def test_no_raw_secrets_in_logs( # --- Persistence of credentials after data operations --- +def test_qr_token_lock_covers_login_retrieval_and_credential_persistence( + mocker: pytest_mock.MockerFixture, +) -> None: + """Vérifie que le verrou d'état couvre tout le cycle QR/token des cours. + + :param mocker: Fixture pytest-mock pour le mocking. + :return: None + """ + events: list[str] = [] + credentials = { + "pronote_url": "https://pronote.example.com", + "username": "testuser", + "password": "persisted-token", # pragma: allowlist secret + "uuid": "persisted-uuid", + } + auth_state = mocker.MagicMock(spec=PronoteAuthState) + + @contextmanager + def record_lock() -> Generator[None]: + events.append("lock_acquired") + try: + yield + finally: + events.append("lock_released") + + def load_credentials() -> dict[str, str]: + """Simule le chargement des credentials persistés.""" + events.append("load") + return credentials + + def get_no_lessons(*_: object) -> list[object]: + """Simule une récupération de cours vide.""" + events.append("lessons") + return [] + + def export_credentials() -> dict[str, str]: + """Simule l'export des credentials courantes.""" + events.append("export") + return credentials + + def token_login(**_: object) -> MagicMock: + """Simule le login par token.""" + events.append("token_login") + return mock_client + + auth_state.lock.side_effect = record_lock + auth_state.load.side_effect = load_credentials + auth_state.save.side_effect = lambda _: events.append("save") + + mock_client = _make_lessons_mock_client(mocker) + mock_client.logged_in = True + mock_client.lessons.side_effect = get_no_lessons + mock_client.export_credentials.side_effect = export_credentials + mocker.patch( + "pronotepy.ParentClient.token_login", + side_effect=token_login, + ) + + settings = PronoteSettings( + url="https://pronote.example.com", + username="testuser", + password=SecretStr("testpass"), + ent=None, + account_type="parent", + auth_mode="qr_token", + ) + + lessons = PronoteClient(settings, auth_state=auth_state).get_lessons( + date(2024, 9, 1), date(2024, 9, 30) + ) + + assert lessons == [] + assert events == [ + "lock_acquired", + "load", + "token_login", + "export", + "save", + "lessons", + "export", + "save", + "lock_released", + ] + + def _make_auth_state_mock(mocker: pytest_mock.MockerFixture) -> MagicMock: """Retourne un mock de PronoteAuthState sans credentials persistés. From 8b924b55d1efcafaaf26b35023cfb4e7d16bd89e Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 10 Sep 2026 21:26:43 +0200 Subject: [PATCH 3/4] docs: document QR token state locking --- .secrets.baseline | 4 ++-- GUIDE_DEV_PYTHON.md | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 8364877..e860616 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,7 +140,7 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": true, - "line_number": 5064, + "line_number": 5084, "is_secret": false } ], @@ -177,5 +177,5 @@ } ] }, - "generated_at": "2026-09-08T10:45:46Z" + "generated_at": "2026-09-10T19:26:08Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 4535e81..ad0e78b 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -2259,7 +2259,27 @@ d'informations sont non critiques et peuvent retourner une liste vide avec un wa Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à un jour cible est donc filtré explicitement sur `homework.date == target_date`. -#### 5.1.8 Logique de repli (`sources/pronote/fallback.py`) +#### 5.1.8 Verrou du cycle d'authentification QR/token + +En mode `qr_token`, le token Pronote est un état partagé et rotatif. Afin d'éviter que deux +exécutions ne réutilisent ou n'écrasent cet état simultanément, le client protège chaque cycle +d'authentification et de récupération par un verrou POSIX local non bloquant, situé dans +`.pronote_auth_state.json.lock`, à côté de `.pronote_auth_state.json`. + +Le verrou couvre l'ensemble du cycle QR/token : chargement de l'état, connexion par token ou +enrôlement QR initial, opération de données (agenda, devoirs, messages ou informations), puis +persistance des credentials actualisées. Une tentative concurrente échoue immédiatement avec une +erreur d'état d'authentification expurgée ; elle ne patiente pas et ne relance pas +l'authentification. Le contenu du token, le PIN et les autres credentials ne sont jamais inclus +dans les logs ni dans ce message d'erreur. + +Ce mécanisme est un contrat **local** : il coordonne des processus sur le même hôte Linux et un +filesystem local. Pour des déploiements conteneurisés, les conteneurs qui partagent le même compte +Pronote doivent également partager le fichier d'état et son fichier de verrou. Le verrou ne fournit +aucune exclusion fiable entre plusieurs hôtes ou via NFS ; dans ces cas, l'opérateur doit prévoir +une exclusion externe ou utiliser un token distinct par instance. + +#### 5.1.9 Logique de repli (`sources/pronote/fallback.py`) Le `PronoteFetcher` dépend de `Settings` et d'un protocole de client injecté ; il ne construit pas de singleton et ne contient pas d'identifiants dupliqués. From 4228c1e636797c22afe8c84c76546a8d19607d2d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 10 Sep 2026 23:41:19 +0200 Subject: [PATCH 4/4] fix: ignore QR token lockfile --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index cab0981..51a01e5 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ Thumbs.db .caldav_sync_state.json # État d'authentification pronotepy (QR code / token rotation) .pronote_auth_state.json +.pronote_auth_state.json.lock *.state.json # --- Local scratch / WIP files ---