diff --git a/api/helpers.py b/api/helpers.py index e9af3c47..c53b402b 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -1035,23 +1035,25 @@ def _redact_value(v, *, _enabled: bool | None = None): if isinstance(v, str): return _redact_text(v, _enabled=_enabled) if isinstance(v, dict): - changed = False - out = {} + out = None for key, value in v.items(): redacted = _redact_value(value, _enabled=_enabled) + if redacted is value: + continue + if out is None: + out = dict(v) out[key] = redacted - if redacted is not value: - changed = True - return out if changed else v + return v if out is None else out if isinstance(v, list): - changed = False - out = [] - for item in v: + out = None + for index, item in enumerate(v): redacted = _redact_value(item, _enabled=_enabled) - out.append(redacted) - if redacted is not item: - changed = True - return out if changed else v + if redacted is item: + continue + if out is None: + out = list(v) + out[index] = redacted + return v if out is None else out return v diff --git a/tests/test_redact_value_no_copy_when_clean.py b/tests/test_redact_value_no_copy_when_clean.py index ffb8f875..da3ca30e 100644 --- a/tests/test_redact_value_no_copy_when_clean.py +++ b/tests/test_redact_value_no_copy_when_clean.py @@ -8,12 +8,14 @@ GIL (json/allocations ne le liberent pas), ce qui serialise les onglets. Contrat verifie ici: 1. Quand rien n'est redige, la valeur RENVOYEE EST l'objet d'origine - (identite, pas seulement egalite) -> zero allocation. + (identite, pas seulement egalite) et aucun conteneur proportionnel au + payload n'est alloue en chemin. 2. Quand quelque chose est redige, un NOUVEL objet est renvoye et l'original n'est PAS mute (fail-closed: pas de fuite par aliasing). 3. Le resultat reste egal a celui de l'ancienne implementation. """ import sys +import tracemalloc from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) @@ -55,6 +57,32 @@ def test_clean_payload_is_returned_by_identity(): assert out["meta"] is payload["meta"] +def test_clean_payload_does_not_allocate_eager_container_copies(): + """La voie propre doit rester O(1) en allocation de conteneurs temporaires.""" + payload = { + "dict": {index: index for index in range(20_000)}, + "list": list(range(20_000)), + } + + was_tracing = tracemalloc.is_tracing() + if not was_tracing: + tracemalloc.start() + before, _ = tracemalloc.get_traced_memory() + tracemalloc.reset_peak() + try: + out = _redact_value(payload, _enabled=True) + _, peak = tracemalloc.get_traced_memory() + finally: + if not was_tracing: + tracemalloc.stop() + + assert out is payload + # Une liste temporaire de 20 000 pointeurs depasse a elle seule 160 Ko. + # Le seuil reste volontairement un ordre de grandeur en dessous, sans + # dependre des details exacts de l'allocateur d'une version de CPython. + assert peak - before < 16_384, f"copies temporaires detectees: {peak - before} octets" + + def test_sensitive_payload_is_copied_and_original_untouched(): """Quelque chose est redige -> nouvel objet, original intact.""" inner = {"role": "user", "content": SECRET} @@ -78,6 +106,29 @@ def test_sensitive_payload_is_copied_and_original_untouched(): assert out["meta"] is payload["meta"] +def test_copy_on_first_change_preserves_order_types_and_clean_identity(): + """Une redaction tardive copie seulement son chemin sans reordonner le JSON.""" + before = {"clean": ["avant", 1]} + after = {"clean": ["apres", 2]} + payload = { + "before": before, + "items": [before, {"secret": SECRET}, after], + "after": after, + } + + out = _redact_value(payload, _enabled=True) + + assert type(out) is dict + assert type(out["items"]) is list + assert list(out) == list(payload) + assert out["before"] is before + assert out["after"] is after + assert out["items"][0] is before + assert out["items"][2] is after + assert out["items"][1] is not payload["items"][1] + assert out["items"][1]["secret"] != SECRET + + def test_matches_reference_implementation(): """Le resultat doit rester identique a l'implementation d'origine.""" payload = {