File size: 8,118 Bytes
a2bea75
 
6b429be
a2bea75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de9192c
a2bea75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
"""Sprint A14-S1 β€” A.I.0 P0 : validation des chemins utilisateur.

Tests sur ``picarones.interfaces.web.security.validated_path``,
``validated_prompt_filename`` et ``safe_report_name`` : les helpers
introduits pour bloquer les chemins arbitraires reΓ§us des endpoints
benchmark/run et benchmark/start.

Avant le sprint S1 du rewrite ciblΓ©, l'API web acceptait :

- n'importe quel ``corpus_path`` validΓ© uniquement par ``Path.exists()`` ;
- n'importe quel ``output_dir`` créé par ``Path(req.output_dir).mkdir()`` ;
- n'importe quel ``report_name`` concatΓ©nΓ© directement (escape via ``../``) ;
- n'importe quel ``prompt_file`` absolu (vecteur d'exfiltration via LLM).

Les tests ci-dessous font office de filet de sΓ©curitΓ©.  Toute Γ©volution
ultΓ©rieure de la couche security.py qui ferait rΓ©gresser ces invariants
est bloquΓ©e par cette suite.
"""

from __future__ import annotations

import tempfile
from pathlib import Path

import pytest

from picarones.interfaces.web.security import (
    PathValidationError,
    safe_report_name,
    validated_path,
    validated_prompt_filename,
)


# ──────────────────────────────────────────────────────────────────────
# validated_path
# ──────────────────────────────────────────────────────────────────────


class TestValidatedPath:
    def test_accepts_path_within_allowed_root(self, tmp_path: Path) -> None:
        sub = tmp_path / "corpus_a"
        sub.mkdir()
        result = validated_path(str(sub), allowed_roots=[tmp_path], must_be_dir=True)
        assert result == sub.resolve()

    def test_rejects_path_outside_allowed_roots(self, tmp_path: Path) -> None:
        # /etc/passwd existe sur tout Linux et est clairement hors workspace.
        with pytest.raises(PathValidationError, match="hors zone autorisΓ©e"):
            validated_path("/etc/passwd", allowed_roots=[tmp_path])

    def test_rejects_traversal_via_dot_dot(self, tmp_path: Path) -> None:
        sub = tmp_path / "inside"
        sub.mkdir()
        # tmp_path/inside/../../../etc β†’ rΓ©solu = /etc β†’ hors zone
        evasion = str(sub / ".." / ".." / ".." / "etc")
        with pytest.raises(PathValidationError, match="hors zone autorisΓ©e"):
            validated_path(evasion, allowed_roots=[tmp_path])

    def test_rejects_empty_path(self, tmp_path: Path) -> None:
        with pytest.raises(PathValidationError, match="vide"):
            validated_path("", allowed_roots=[tmp_path])

    def test_rejects_null_byte(self, tmp_path: Path) -> None:
        with pytest.raises(PathValidationError, match="octet nul"):
            validated_path("foo\x00bar", allowed_roots=[tmp_path])

    def test_rejects_when_no_allowed_roots(self, tmp_path: Path) -> None:
        with pytest.raises(PathValidationError, match="Aucune racine autorisΓ©e"):
            validated_path(str(tmp_path), allowed_roots=[])

    def test_must_exist_raises_on_missing(self, tmp_path: Path) -> None:
        missing = tmp_path / "does_not_exist"
        with pytest.raises(PathValidationError, match="inexistant"):
            validated_path(str(missing), allowed_roots=[tmp_path], must_exist=True)

    def test_must_be_dir_raises_on_file(self, tmp_path: Path) -> None:
        f = tmp_path / "a_file.txt"
        f.write_text("hello")
        with pytest.raises(PathValidationError, match="n'est pas un rΓ©pertoire"):
            validated_path(str(f), allowed_roots=[tmp_path], must_be_dir=True)

    def test_resolves_symlinks(self, tmp_path: Path) -> None:
        # Si on crΓ©e un symlink dans tmp_path qui pointe vers /tmp/ailleurs,
        # ``resolve()`` doit suivre le symlink.  Si la cible est hors zone,
        # on rejette.
        outside = Path(tempfile.mkdtemp(prefix="picarones_outside_"))
        try:
            link = tmp_path / "tricky_link"
            link.symlink_to(outside)
            with pytest.raises(PathValidationError, match="hors zone autorisΓ©e"):
                validated_path(str(link), allowed_roots=[tmp_path])
        finally:
            # cleanup
            outside.rmdir()


# ──────────────────────────────────────────────────────────────────────
# safe_report_name
# ──────────────────────────────────────────────────────────────────────


class TestSafeReportName:
    def test_accepts_simple_name(self) -> None:
        assert safe_report_name("rapport_2026") == "rapport_2026"

    def test_strips_path_separators(self) -> None:
        # Les sΓ©parateurs sont supprimΓ©s silencieusement.
        # ``../etc/passwd`` β†’ ``..etcpasswd``, et ``..`` initial est strippΓ© β†’
        # ``etcpasswd`` (caractères neutres, pas de chemin).
        result = safe_report_name("../etc/passwd")
        assert "/" not in result
        assert "\\" not in result

    def test_rejects_empty(self) -> None:
        with pytest.raises(PathValidationError, match="vide"):
            safe_report_name("")

    def test_rejects_null_byte(self) -> None:
        with pytest.raises(PathValidationError, match="octet nul"):
            safe_report_name("rapport\x00.html")

    def test_rejects_pure_separators(self) -> None:
        with pytest.raises(PathValidationError, match="invalide"):
            safe_report_name("///")

    def test_rejects_dot_only(self) -> None:
        with pytest.raises(PathValidationError):
            safe_report_name(".")

    def test_truncates_to_max_length(self) -> None:
        long_name = "a" * 500
        assert len(safe_report_name(long_name, max_length=128)) == 128


# ──────────────────────────────────────────────────────────────────────
# validated_prompt_filename
# ──────────────────────────────────────────────────────────────────────


class TestValidatedPromptFilename:
    def test_accepts_builtin_name(self) -> None:
        assert (
            validated_prompt_filename("correction_medieval_french.txt")
            == "correction_medieval_french.txt"
        )

    def test_rejects_absolute_path(self) -> None:
        with pytest.raises(PathValidationError, match="sΓ©parateur de chemin"):
            validated_prompt_filename("/etc/passwd")

    def test_rejects_relative_traversal(self) -> None:
        with pytest.raises(PathValidationError):
            validated_prompt_filename("../prompts/secret.txt")

    def test_rejects_dot_dot_inline(self) -> None:
        with pytest.raises(PathValidationError, match="suspect"):
            validated_prompt_filename("foo..bar.txt")

    def test_rejects_windows_separator(self) -> None:
        with pytest.raises(PathValidationError, match="sΓ©parateur de chemin"):
            validated_prompt_filename(r"C:\Users\victim\file.txt")

    def test_rejects_dot_prefix(self) -> None:
        with pytest.raises(PathValidationError, match="suspect"):
            validated_prompt_filename(".env")

    def test_rejects_null_byte(self) -> None:
        with pytest.raises(PathValidationError, match="octet nul"):
            validated_prompt_filename("file\x00.txt")

    def test_rejects_control_characters(self) -> None:
        with pytest.raises(PathValidationError, match="caractère de contrôle"):
            validated_prompt_filename("file\x01.txt")

    def test_rejects_empty(self) -> None:
        with pytest.raises(PathValidationError, match="vide"):
            validated_prompt_filename("")