voight-kampff-forensics 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voight_kampff/__init__.py +15 -0
- voight_kampff/__main__.py +7 -0
- voight_kampff/_orchestration/__init__.py +5 -0
- voight_kampff/_orchestration/manifestes.py +123 -0
- voight_kampff/_orchestration/travail.py +448 -0
- voight_kampff/_protocole.py +78 -0
- voight_kampff/_travail/__init__.py +6 -0
- voight_kampff/_travail/__main__.py +7 -0
- voight_kampff/_travail/contexte.py +179 -0
- voight_kampff/_travail/fichier.py +64 -0
- voight_kampff/_travail/garde.py +28 -0
- voight_kampff/_travail/principal.py +218 -0
- voight_kampff/cli.py +48 -0
- voight_kampff/modules/__init__.py +31 -0
- voight_kampff/py.typed +0 -0
- voight_kampff/rapport/__init__.py +1 -0
- voight_kampff/rapport/modele.py +958 -0
- voight_kampff/schemas/__init__.py +60 -0
- voight_kampff/schemas/manifeste.schema.json +63 -0
- voight_kampff/schemas/messages.schema.json +85 -0
- voight_kampff/schemas/rapport.schema.json +371 -0
- voight_kampff_forensics-0.1.0.dev0.dist-info/METADATA +41 -0
- voight_kampff_forensics-0.1.0.dev0.dist-info/RECORD +26 -0
- voight_kampff_forensics-0.1.0.dev0.dist-info/WHEEL +4 -0
- voight_kampff_forensics-0.1.0.dev0.dist-info/entry_points.txt +4 -0
- voight_kampff_forensics-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Contexte remis à chaque module : lecture du fichier et réception des résultats (ADR-0061).
|
|
2
|
+
|
|
3
|
+
Les résultats d'un module restent dans son contexte jusqu'à ce qu'il rende la main ; s'il lève
|
|
4
|
+
une exception, ils sont écartés (ADR-0060). Le contexte construit les objets du modèle, qui
|
|
5
|
+
vérifient leurs invariants, et numérote les observations ``<module>/<n>``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
9
|
+
|
|
10
|
+
from voight_kampff._protocole import TAILLE_MAX_MESSAGE, encoder
|
|
11
|
+
from voight_kampff._travail import garde
|
|
12
|
+
from voight_kampff._travail.fichier import Fichier
|
|
13
|
+
from voight_kampff.rapport.modele import (
|
|
14
|
+
Categorie,
|
|
15
|
+
CategorieIdentifiante,
|
|
16
|
+
Constat,
|
|
17
|
+
Direction,
|
|
18
|
+
Fondement,
|
|
19
|
+
Graduation,
|
|
20
|
+
Localisation,
|
|
21
|
+
NonMenee,
|
|
22
|
+
Observation,
|
|
23
|
+
Phrase,
|
|
24
|
+
RaisonNonMenee,
|
|
25
|
+
ReferenceRegle,
|
|
26
|
+
Scalaire,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = ["RAISONS_D_UN_MODULE", "Contexte", "LimiteDepassee"]
|
|
30
|
+
|
|
31
|
+
RAISONS_D_UN_MODULE = frozenset(
|
|
32
|
+
{
|
|
33
|
+
RaisonNonMenee.FORMAT_PARTIEL,
|
|
34
|
+
RaisonNonMenee.FICHIER_TRONQUE,
|
|
35
|
+
RaisonNonMenee.REFERENCE_MANQUANTE,
|
|
36
|
+
RaisonNonMenee.RESEAU_DESACTIVE,
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
"""Raisons qu'un module peut donner lui-même ; les autres sont produites par le moteur."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LimiteDepassee(Exception):
|
|
43
|
+
"""Résultats d'un module au-delà des tailles permises par le protocole."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Contexte:
|
|
47
|
+
"""Ce qu'un module voit du fichier et de l'analyse en cours."""
|
|
48
|
+
|
|
49
|
+
__slots__ = (
|
|
50
|
+
"_budget",
|
|
51
|
+
"_depasse",
|
|
52
|
+
"_directions",
|
|
53
|
+
"_fichier",
|
|
54
|
+
"_lignes",
|
|
55
|
+
"_numero",
|
|
56
|
+
"_observations",
|
|
57
|
+
"_taille_lignes",
|
|
58
|
+
"format_reel",
|
|
59
|
+
"module",
|
|
60
|
+
"taille",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
module: str,
|
|
66
|
+
directions: Collection[Direction],
|
|
67
|
+
fichier: Fichier,
|
|
68
|
+
format_reel: str,
|
|
69
|
+
taille: int,
|
|
70
|
+
budget: int,
|
|
71
|
+
) -> None:
|
|
72
|
+
garde.exiger()
|
|
73
|
+
self.module = module
|
|
74
|
+
self.format_reel = format_reel
|
|
75
|
+
"""Format identifié par le contenu (T13)."""
|
|
76
|
+
self.taille = taille
|
|
77
|
+
"""Taille du fichier en octets."""
|
|
78
|
+
self._directions = frozenset(directions)
|
|
79
|
+
self._fichier = fichier
|
|
80
|
+
self._budget = budget
|
|
81
|
+
self._lignes: list[bytes] = []
|
|
82
|
+
self._taille_lignes = 0
|
|
83
|
+
self._numero = 0
|
|
84
|
+
self._observations: set[str] = set()
|
|
85
|
+
self._depasse = False
|
|
86
|
+
|
|
87
|
+
# Lecture
|
|
88
|
+
|
|
89
|
+
def lire(self, position: int, taille: int) -> bytes:
|
|
90
|
+
"""Au plus ``taille`` octets du fichier à partir de ``position`` ; moins en fin."""
|
|
91
|
+
return self._fichier.lire(position, taille)
|
|
92
|
+
|
|
93
|
+
# Résultats
|
|
94
|
+
|
|
95
|
+
def observer(
|
|
96
|
+
self,
|
|
97
|
+
nom: str,
|
|
98
|
+
valeur: Scalaire | Sequence[Scalaire],
|
|
99
|
+
*,
|
|
100
|
+
unite: str | None = None,
|
|
101
|
+
localisation: Localisation | None = None,
|
|
102
|
+
identifiante: CategorieIdentifiante | None = None,
|
|
103
|
+
) -> str:
|
|
104
|
+
"""Enregistre une observation et renvoie son identifiant, à citer dans un constat."""
|
|
105
|
+
identifiant = f"{self.module}/{self._numero + 1}"
|
|
106
|
+
observation = Observation(
|
|
107
|
+
identifiant,
|
|
108
|
+
self.module,
|
|
109
|
+
nom,
|
|
110
|
+
valeur if isinstance(valeur, str | int | float | bool) else tuple(valeur),
|
|
111
|
+
unite=unite,
|
|
112
|
+
localisation=localisation,
|
|
113
|
+
identifiante=identifiante,
|
|
114
|
+
)
|
|
115
|
+
self._ajouter({"type": "observation", "observation": observation.vers_dict()})
|
|
116
|
+
self._numero += 1
|
|
117
|
+
self._observations.add(identifiant)
|
|
118
|
+
return identifiant
|
|
119
|
+
|
|
120
|
+
def constater(
|
|
121
|
+
self,
|
|
122
|
+
phrase: Phrase,
|
|
123
|
+
donnees: Sequence[str],
|
|
124
|
+
regle: ReferenceRegle,
|
|
125
|
+
*,
|
|
126
|
+
fondement: Fondement,
|
|
127
|
+
categorie: Categorie,
|
|
128
|
+
direction: Direction,
|
|
129
|
+
graduation: Graduation | None = None,
|
|
130
|
+
localisation: Localisation | None = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Enregistre un constat ; ses données sont des observations de ce module."""
|
|
133
|
+
if direction not in self._directions:
|
|
134
|
+
raise ValueError("constat : direction non permise par la fiche technique")
|
|
135
|
+
if not set(donnees) <= self._observations:
|
|
136
|
+
raise ValueError("constat.donnees : observation inconnue de ce module")
|
|
137
|
+
constat = Constat(
|
|
138
|
+
phrase,
|
|
139
|
+
tuple(donnees),
|
|
140
|
+
regle,
|
|
141
|
+
self.module,
|
|
142
|
+
fondement,
|
|
143
|
+
categorie,
|
|
144
|
+
direction,
|
|
145
|
+
graduation=graduation,
|
|
146
|
+
localisation=localisation,
|
|
147
|
+
)
|
|
148
|
+
self._ajouter({"type": "constat", "constat": constat.vers_dict()})
|
|
149
|
+
|
|
150
|
+
def non_mener(
|
|
151
|
+
self, raison: RaisonNonMenee, *, localisation: Localisation | None = None
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Signale une partie de l'analyse que le module n'a pas pu mener."""
|
|
154
|
+
if raison not in RAISONS_D_UN_MODULE:
|
|
155
|
+
raise ValueError("non_menee.raison : réservée au moteur")
|
|
156
|
+
non_menee = NonMenee(raison, self.module, localisation)
|
|
157
|
+
self._ajouter({"type": "non_menee", "non_menee": non_menee.vers_dict()})
|
|
158
|
+
|
|
159
|
+
# Pour le moteur
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def depasse(self) -> bool:
|
|
163
|
+
"""Vrai si le module a dépassé une limite, même s'il a intercepté l'exception."""
|
|
164
|
+
return self._depasse
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def lignes(self) -> tuple[bytes, ...]:
|
|
168
|
+
"""Messages encodés, dans l'ordre où le module les a produits."""
|
|
169
|
+
return tuple(self._lignes)
|
|
170
|
+
|
|
171
|
+
def _ajouter(self, message: Mapping[str, object]) -> None:
|
|
172
|
+
if self._depasse:
|
|
173
|
+
raise LimiteDepassee
|
|
174
|
+
ligne = encoder(message)
|
|
175
|
+
if len(ligne) > TAILLE_MAX_MESSAGE or self._taille_lignes + len(ligne) > self._budget:
|
|
176
|
+
self._depasse = True
|
|
177
|
+
raise LimiteDepassee
|
|
178
|
+
self._lignes.append(ligne)
|
|
179
|
+
self._taille_lignes += len(ligne)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Accès du processus de travail au fichier analysé, par le descripteur hérité (ADR-0036)."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from voight_kampff._protocole import TAILLE_MAX_LECTURE
|
|
8
|
+
|
|
9
|
+
__all__ = ["Fichier", "ouvrir_herite"]
|
|
10
|
+
|
|
11
|
+
_BLOC = 1 << 20
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ouvrir_herite(valeur: int) -> int:
|
|
15
|
+
"""Descripteur du fichier transmis par le processus principal.
|
|
16
|
+
|
|
17
|
+
Sous Windows, le processus principal transmet un handle, converti ici en descripteur.
|
|
18
|
+
"""
|
|
19
|
+
if sys.platform == "win32": # pragma: no cover - exécuté par la CI Windows
|
|
20
|
+
import msvcrt
|
|
21
|
+
|
|
22
|
+
return msvcrt.open_osfhandle(valeur, os.O_RDONLY | os.O_BINARY)
|
|
23
|
+
return valeur
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Fichier:
|
|
27
|
+
"""Lectures à position explicite : le processus de travail ne dépend d'aucune position."""
|
|
28
|
+
|
|
29
|
+
__slots__ = ("_descripteur",)
|
|
30
|
+
|
|
31
|
+
def __init__(self, descripteur: int) -> None:
|
|
32
|
+
self._descripteur = descripteur
|
|
33
|
+
|
|
34
|
+
def lire(self, position: int, taille: int) -> bytes:
|
|
35
|
+
"""Au plus ``taille`` octets à partir de ``position`` ; moins en fin de fichier."""
|
|
36
|
+
if position < 0 or not 0 <= taille <= TAILLE_MAX_LECTURE:
|
|
37
|
+
raise ValueError("lecture : position ou taille hors bornes")
|
|
38
|
+
morceaux: list[bytes] = []
|
|
39
|
+
reste = taille
|
|
40
|
+
while reste:
|
|
41
|
+
morceau = self._lire_a(position, reste)
|
|
42
|
+
if not morceau:
|
|
43
|
+
break
|
|
44
|
+
morceaux.append(morceau)
|
|
45
|
+
position += len(morceau)
|
|
46
|
+
reste -= len(morceau)
|
|
47
|
+
return b"".join(morceaux)
|
|
48
|
+
|
|
49
|
+
def _lire_a(self, position: int, taille: int) -> bytes:
|
|
50
|
+
if sys.platform == "win32": # pragma: no cover - exécuté par la CI Windows
|
|
51
|
+
# Pas de pread sous Windows. Le processus principal ne lit plus le descripteur
|
|
52
|
+
# pendant l'analyse : déplacer la position commune ne gêne personne.
|
|
53
|
+
os.lseek(self._descripteur, position, os.SEEK_SET)
|
|
54
|
+
return os.read(self._descripteur, taille)
|
|
55
|
+
return os.pread(self._descripteur, taille, position)
|
|
56
|
+
|
|
57
|
+
def empreinte(self) -> tuple[str, int]:
|
|
58
|
+
"""SHA-256 du fichier entier, en hexadécimal minuscule, et sa taille (ES-08, ADR-0040)."""
|
|
59
|
+
condensat = hashlib.sha256()
|
|
60
|
+
position = 0
|
|
61
|
+
while morceau := self._lire_a(position, _BLOC):
|
|
62
|
+
condensat.update(morceau)
|
|
63
|
+
position += len(morceau)
|
|
64
|
+
return condensat.hexdigest(), position
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Garde des analyseurs : ils refusent de s'exécuter hors d'un processus de travail (ES-17).
|
|
2
|
+
|
|
3
|
+
Le processus de travail se déclare verrouillé après avoir appliqué ses restrictions et avant
|
|
4
|
+
d'envoyer l'état de l'isolation (ADR-0036). Un programme qui importerait un module d'analyse
|
|
5
|
+
pour l'appeler dans son propre processus est arrêté par :func:`exiger`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__ = ["HorsProcessusDeTravail", "exiger", "verrouiller"]
|
|
9
|
+
|
|
10
|
+
_verrouille = False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HorsProcessusDeTravail(RuntimeError):
|
|
14
|
+
"""Analyse demandée hors d'un processus de travail verrouillé."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def verrouiller() -> None:
|
|
18
|
+
"""Déclare le processus courant processus de travail verrouillé ; sans retour arrière."""
|
|
19
|
+
global _verrouille # état du processus entier, posé une seule fois
|
|
20
|
+
_verrouille = True
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def exiger() -> None:
|
|
24
|
+
"""Lève :class:`HorsProcessusDeTravail` hors d'un processus de travail verrouillé."""
|
|
25
|
+
if not _verrouille:
|
|
26
|
+
raise HorsProcessusDeTravail(
|
|
27
|
+
"les analyseurs ne s'exécutent que dans un processus de travail (ES-17)"
|
|
28
|
+
)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Déroulé du processus de travail (ADR-0036) et acheminement vers les modules (ADR-0042).
|
|
2
|
+
|
|
3
|
+
1. Importer les modules livrés, tant que le système de fichiers est accessible.
|
|
4
|
+
2. Appliquer les restrictions (T14), se déclarer verrouillé, envoyer l'état de l'isolation :
|
|
5
|
+
aucun octet du fichier n'a encore été lu.
|
|
6
|
+
3. Lire la configuration : modules actifs, dans l'ordre d'exécution.
|
|
7
|
+
4. Recalculer l'empreinte, identifier le format (T13), exécuter chaque module actif, envoyer
|
|
8
|
+
ses résultats puis son message « module » (ADR-0062), et enfin les versions et la fin.
|
|
9
|
+
|
|
10
|
+
Une exception dans un module ne touche que ce module (NF-03, ADR-0060) ; toute autre erreur
|
|
11
|
+
arrête le processus, et le processus principal en fait une analyse non menée.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import importlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import BinaryIO
|
|
22
|
+
|
|
23
|
+
from voight_kampff import modules
|
|
24
|
+
from voight_kampff._protocole import (
|
|
25
|
+
TAILLE_MAX_CONFIGURATION,
|
|
26
|
+
TAILLE_MAX_SORTIE,
|
|
27
|
+
encoder,
|
|
28
|
+
)
|
|
29
|
+
from voight_kampff._travail import garde
|
|
30
|
+
from voight_kampff._travail.contexte import Contexte, LimiteDepassee
|
|
31
|
+
from voight_kampff._travail.fichier import Fichier, ouvrir_herite
|
|
32
|
+
from voight_kampff.rapport.modele import (
|
|
33
|
+
VERSION_SCHEMA,
|
|
34
|
+
Direction,
|
|
35
|
+
EtatIsolation,
|
|
36
|
+
ManqueIsolation,
|
|
37
|
+
NiveauIsolation,
|
|
38
|
+
NonMenee,
|
|
39
|
+
RaisonNonMenee,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"Analyseur",
|
|
44
|
+
"ModuleConfigure",
|
|
45
|
+
"canal_du_protocole",
|
|
46
|
+
"importer",
|
|
47
|
+
"lire_configuration",
|
|
48
|
+
"principal",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
type Analyseur = Callable[[Contexte], None]
|
|
52
|
+
|
|
53
|
+
_RESERVE = 64 << 10
|
|
54
|
+
"""Part de la sortie gardée pour les messages du moteur après le dernier module."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class ModuleConfigure:
|
|
59
|
+
"""Module actif, tel que le processus principal l'a lu dans son manifeste validé."""
|
|
60
|
+
|
|
61
|
+
identifiant: str
|
|
62
|
+
formats: frozenset[str]
|
|
63
|
+
directions: frozenset[Direction]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def canal_du_protocole() -> BinaryIO:
|
|
67
|
+
"""Réserve la sortie standard au protocole et renvoie le flux qui y écrit.
|
|
68
|
+
|
|
69
|
+
Un module qui afficherait du texte écrirait ensuite sur la sortie d'erreur, que le processus
|
|
70
|
+
principal ne lit pas (ES-15) : il ne peut pas glisser une ligne dans le protocole.
|
|
71
|
+
"""
|
|
72
|
+
canal = os.fdopen(os.dup(sys.stdout.fileno()), "wb")
|
|
73
|
+
os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
|
|
74
|
+
sys.stdout = sys.stderr
|
|
75
|
+
return canal
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def importer() -> tuple[dict[str, Analyseur], frozenset[str]]:
|
|
79
|
+
"""Importe les modules livrés ; renvoie leurs analyseurs et ceux qui n'ont pas pu l'être.
|
|
80
|
+
|
|
81
|
+
Appelé avant les restrictions : après, le code ne peut plus être lu sur le disque.
|
|
82
|
+
"""
|
|
83
|
+
analyseurs: dict[str, Analyseur] = {}
|
|
84
|
+
echecs: set[str] = set()
|
|
85
|
+
for dossier in modules.dossiers():
|
|
86
|
+
try:
|
|
87
|
+
code = importlib.import_module(f"{modules.__name__}.{dossier.name}")
|
|
88
|
+
analyseur = code.analyser
|
|
89
|
+
if not callable(analyseur):
|
|
90
|
+
raise TypeError("analyser n'est pas appelable")
|
|
91
|
+
except Exception:
|
|
92
|
+
# Le module en échec sera non mené ; les autres continuent (NF-03).
|
|
93
|
+
echecs.add(dossier.name)
|
|
94
|
+
else:
|
|
95
|
+
analyseurs[dossier.name] = analyseur
|
|
96
|
+
return analyseurs, frozenset(echecs)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def lire_configuration(entree: BinaryIO) -> tuple[ModuleConfigure, ...]:
|
|
100
|
+
"""Lit la configuration envoyée par le processus principal (ADR-0065).
|
|
101
|
+
|
|
102
|
+
Elle vient d'un processus de confiance : une configuration mal formée est un défaut de
|
|
103
|
+
l'outil, qui arrête le processus de travail.
|
|
104
|
+
"""
|
|
105
|
+
ligne = entree.readline(TAILLE_MAX_CONFIGURATION + 1)
|
|
106
|
+
if not ligne.endswith(b"\n") or len(ligne) > TAILLE_MAX_CONFIGURATION:
|
|
107
|
+
raise ValueError("configuration : absente ou trop longue")
|
|
108
|
+
document = json.loads(ligne)
|
|
109
|
+
if document["version_schema"] != VERSION_SCHEMA:
|
|
110
|
+
raise ValueError("configuration : version de schéma différente")
|
|
111
|
+
return tuple(
|
|
112
|
+
ModuleConfigure(
|
|
113
|
+
identifiant=str(module["identifiant"]),
|
|
114
|
+
formats=frozenset(str(f) for f in module["formats"]),
|
|
115
|
+
directions=frozenset(Direction(d) for d in module["directions"]),
|
|
116
|
+
)
|
|
117
|
+
for module in document["modules"]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _identifier(_fichier: Fichier) -> str | None:
|
|
122
|
+
"""Format réel du fichier ; aucun avant T13, donc aucun module n'est applicable."""
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class _Canal:
|
|
127
|
+
"""Sortie du protocole : une ligne par message, comptée contre la taille maximale."""
|
|
128
|
+
|
|
129
|
+
__slots__ = ("_sortie", "envoye")
|
|
130
|
+
|
|
131
|
+
def __init__(self, sortie: BinaryIO) -> None:
|
|
132
|
+
self._sortie = sortie
|
|
133
|
+
self.envoye = 0
|
|
134
|
+
|
|
135
|
+
def envoyer(self, lignes: Sequence[bytes]) -> None:
|
|
136
|
+
for ligne in lignes:
|
|
137
|
+
self._sortie.write(ligne)
|
|
138
|
+
self.envoye += len(ligne)
|
|
139
|
+
self._sortie.flush()
|
|
140
|
+
|
|
141
|
+
def message(self, message: Mapping[str, object]) -> None:
|
|
142
|
+
self.envoyer((encoder(message),))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _executer(
|
|
146
|
+
module: ModuleConfigure,
|
|
147
|
+
analyseur: Analyseur | None,
|
|
148
|
+
fichier: Fichier,
|
|
149
|
+
format_reel: str,
|
|
150
|
+
taille: int,
|
|
151
|
+
canal: _Canal,
|
|
152
|
+
) -> None:
|
|
153
|
+
raison: RaisonNonMenee | None = RaisonNonMenee.ERREUR_MODULE
|
|
154
|
+
contexte: Contexte | None = None
|
|
155
|
+
if analyseur is not None:
|
|
156
|
+
budget = TAILLE_MAX_SORTIE - canal.envoye - _RESERVE
|
|
157
|
+
contexte = Contexte(
|
|
158
|
+
module.identifiant, module.directions, fichier, format_reel, taille, budget
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
analyseur(contexte)
|
|
162
|
+
except LimiteDepassee:
|
|
163
|
+
raison = RaisonNonMenee.LIMITE_DEPASSEE
|
|
164
|
+
except Exception: # NF-03 : seul ce module est touché
|
|
165
|
+
raison = RaisonNonMenee.ERREUR_MODULE
|
|
166
|
+
else:
|
|
167
|
+
raison = RaisonNonMenee.LIMITE_DEPASSEE if contexte.depasse else None
|
|
168
|
+
if raison is None and contexte is not None:
|
|
169
|
+
canal.envoyer(contexte.lignes)
|
|
170
|
+
else:
|
|
171
|
+
# Résultats écartés (ADR-0060) : rien de ce que le module a produit n'est envoyé.
|
|
172
|
+
non_menee = NonMenee(raison or RaisonNonMenee.ERREUR_MODULE, module.identifiant)
|
|
173
|
+
canal.message({"type": "non_menee", "non_menee": non_menee.vers_dict()})
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def principal(
|
|
177
|
+
argv: Sequence[str],
|
|
178
|
+
entree: BinaryIO,
|
|
179
|
+
sortie: BinaryIO,
|
|
180
|
+
*,
|
|
181
|
+
analyseurs: Mapping[str, Analyseur] | None = None,
|
|
182
|
+
identifier: Callable[[Fichier], str | None] = _identifier,
|
|
183
|
+
) -> int:
|
|
184
|
+
"""Analyse le fichier transmis par le processus principal ; renvoie le code de sortie.
|
|
185
|
+
|
|
186
|
+
``analyseurs`` et ``identifier`` remplacent, pour les tests, les modules livrés et
|
|
187
|
+
l'identification du format.
|
|
188
|
+
"""
|
|
189
|
+
arguments = argparse.ArgumentParser(prog="voight_kampff._travail")
|
|
190
|
+
arguments.add_argument("--descripteur", type=int, required=True)
|
|
191
|
+
descripteur = arguments.parse_args(argv).descripteur
|
|
192
|
+
|
|
193
|
+
echecs: frozenset[str] = frozenset()
|
|
194
|
+
if analyseurs is None:
|
|
195
|
+
analyseurs, echecs = importer()
|
|
196
|
+
|
|
197
|
+
# Restrictions du système : T14. Jusque-là, aucune n'est appliquée et le rapport le dit.
|
|
198
|
+
isolation = EtatIsolation(NiveauIsolation.REDUITE, tuple(ManqueIsolation))
|
|
199
|
+
garde.verrouiller()
|
|
200
|
+
canal = _Canal(sortie)
|
|
201
|
+
canal.message(
|
|
202
|
+
{"type": "isolation", "version_schema": VERSION_SCHEMA, "isolation": isolation.vers_dict()}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
configures = lire_configuration(entree)
|
|
206
|
+
fichier = Fichier(ouvrir_herite(descripteur))
|
|
207
|
+
empreinte, taille = fichier.empreinte()
|
|
208
|
+
format_reel = identifier(fichier)
|
|
209
|
+
for module in configures:
|
|
210
|
+
applicable = format_reel is not None and format_reel in module.formats
|
|
211
|
+
if format_reel is not None and applicable:
|
|
212
|
+
analyseur = None if module.identifiant in echecs else analyseurs.get(module.identifiant)
|
|
213
|
+
_executer(module, analyseur, fichier, format_reel, taille, canal)
|
|
214
|
+
canal.message({"type": "module", "module": module.identifiant, "applicable": applicable})
|
|
215
|
+
# Versions des bibliothèques qui lisent le fichier : avec le premier module qui en emploie.
|
|
216
|
+
canal.message({"type": "versions", "dependances": []})
|
|
217
|
+
canal.message({"type": "fin", "empreinte": empreinte})
|
|
218
|
+
return 0
|
voight_kampff/cli.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Ligne de commande, installée sous les noms ``voight-kampff`` et ``vk`` (ADR-0045)."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
from voight_kampff import __version__
|
|
7
|
+
from voight_kampff._orchestration import manifestes
|
|
8
|
+
|
|
9
|
+
_DESCRIPTION = (
|
|
10
|
+
"Analyse la provenance et l'intégrité d'un fichier : preuves et indices de "
|
|
11
|
+
"génération ou de retouche, sans verdict certain."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def construire_analyseur() -> argparse.ArgumentParser:
|
|
16
|
+
"""Construit l'analyseur d'arguments de la ligne de commande."""
|
|
17
|
+
analyseur = argparse.ArgumentParser(prog="voight-kampff", description=_DESCRIPTION)
|
|
18
|
+
analyseur.add_argument(
|
|
19
|
+
"--version",
|
|
20
|
+
action="version",
|
|
21
|
+
version=f"%(prog)s {__version__}",
|
|
22
|
+
)
|
|
23
|
+
analyseur.add_argument(
|
|
24
|
+
"--modules",
|
|
25
|
+
action="store_true",
|
|
26
|
+
help="liste les modules d'analyse livrés, avec leur version",
|
|
27
|
+
)
|
|
28
|
+
return analyseur
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _lister_modules() -> None:
|
|
32
|
+
livres = manifestes.decouvrir()
|
|
33
|
+
if not livres:
|
|
34
|
+
print("Aucun module d'analyse livré dans cette version.")
|
|
35
|
+
for manifeste in livres:
|
|
36
|
+
print(f"{manifeste.identifiant} {manifeste.version}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
40
|
+
"""Point d'entrée ; renvoie le code de sortie du processus."""
|
|
41
|
+
analyseur = construire_analyseur()
|
|
42
|
+
arguments = analyseur.parse_args(argv)
|
|
43
|
+
if arguments.modules:
|
|
44
|
+
_lister_modules()
|
|
45
|
+
return 0
|
|
46
|
+
# Aucune commande d'analyse avant T13 : sans option, l'aide est affichée.
|
|
47
|
+
analyseur.print_help()
|
|
48
|
+
return 0
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Modules d'analyse : une technique par sous-paquet, avec son manifeste (ADR-0042).
|
|
2
|
+
|
|
3
|
+
Un module est un dossier ``modules/<identifiant>/`` qui contient ``manifeste.toml`` et un
|
|
4
|
+
``__init__.py`` exposant ``analyser(contexte)`` (ADR-0061). Le processus principal lit les
|
|
5
|
+
manifestes sans importer ce code ; seul le processus de travail l'importe.
|
|
6
|
+
|
|
7
|
+
La découverte se limite à ce dossier : aucune extension tierce n'est chargée (ADR-0042). Aucun
|
|
8
|
+
module n'est livré avant T13.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from importlib.resources import files
|
|
12
|
+
from importlib.resources.abc import Traversable
|
|
13
|
+
|
|
14
|
+
__all__ = ["MANIFESTE", "dossiers"]
|
|
15
|
+
|
|
16
|
+
MANIFESTE = "manifeste.toml"
|
|
17
|
+
"""Nom du manifeste dans le dossier de chaque module."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def dossiers(racine: Traversable | None = None) -> tuple[Traversable, ...]:
|
|
21
|
+
"""Dossiers des modules, triés par nom : ceux qui contiennent un manifeste.
|
|
22
|
+
|
|
23
|
+
``racine`` vaut par défaut le dossier de ce paquet ; les tests en donnent un autre.
|
|
24
|
+
"""
|
|
25
|
+
racine = files(__name__) if racine is None else racine
|
|
26
|
+
trouves = (
|
|
27
|
+
dossier
|
|
28
|
+
for dossier in racine.iterdir()
|
|
29
|
+
if dossier.is_dir() and (dossier / MANIFESTE).is_file()
|
|
30
|
+
)
|
|
31
|
+
return tuple(sorted(trouves, key=lambda dossier: dossier.name))
|
voight_kampff/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Rapport d'analyse : modèle de données (T11), exports HTML et JSON (T16)."""
|