feat(email): finaliser le pivot EML forensique

This commit is contained in:
grayTerminal-sh 2026-07-28 09:33:31 +02:00
parent d00d149bef
commit db9be54a1a
20 changed files with 781 additions and 249 deletions

View file

@ -180,6 +180,37 @@ Le socle actuel comprend notamment :
- glisser-déposer des extractions texte depuis l'arborescence vers le graphe,
avec confirmation explicite, création d'entité ou rattachement à une entité
existante, sans déplacement du fichier produit ;
- pivot forensique EML : contrôle SHA-256 avant analyse, lecture des en-têtes,
extraction MIME récursive, inventaire des pièces jointes et analyse locale
facultative par PDF, OCR et ExifTool ;
- propositions bancaires issues du texte ou de l'OCR, avec validation IBAN/BIC
avant toute conservation ;
- observations EML persistantes dans la fiche de preuve, sans création
automatique d'un nœud ;
- promotion facultative et explicite d'une observation vers une entité du
graphe, puis retrait réversible conservant l'observation.
- propriété persistante V13 des rattachements preuve-entité : un retrait EML
ne supprime que la source de l'observation concernée.
### Pivot EML
L'action « Analyser l'e-mail » vérifie d'abord que l'empreinte SHA-256 de la
preuve correspond à celle enregistrée. Le pipeline lit ensuite les en-têtes,
parcourt la structure MIME et présente les pièces jointes, textes PDF ou OCR,
métadonnées ExifTool et propositions bancaires disponibles.
Une proposition cochée avec « Conserver dans la fiche » devient une
observation persistante liée à la preuve. Cette confirmation normale ne crée
ni entité, ni nœud, ni rattachement `preuve_entites`. « Promouvoir en entité »
est une décision séparée, facultative et désactivée par défaut. Une promotion
peut ensuite être retirée du graphe sans supprimer l'observation ; une entité
encore utilisée par une autre observation, une autre preuve ou une relation
est conservée.
Les outils documentaires sont optionnels : leur absence produit un résultat
partiel sans empêcher la lecture des en-têtes ni l'extraction MIME. Les
métadonnées sensibles, notamment GPS, sont signalées et ne sont jamais
promues automatiquement.
Les outils actuellement présents dans le catalogue initial sont :
@ -285,7 +316,7 @@ Les paquets AUR ne devront jamais devenir une dépendance obligatoire du futur p
Depuis la racine du dépôt :
```bash
make
make -j8
```
Le binaire produit est :
@ -315,15 +346,15 @@ Le projet est compilé en C17 avec les avertissements traités comme des erreurs
Lancer tous les tests :
```bash
make test
make -j8 test
```
Vérifications recommandées avant chaque commit :
```bash
make clean
make
make test
make -j8
make -j8 test
git diff --check
```

View file

@ -212,3 +212,29 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_evidence_entity_observations_semantic
ON evidence_entity_observations(
evidence_id,entity_type,value_normalized,role,source_header,
occurrence,provenance_kind,COALESCE(extraction_id,''));
CREATE TABLE IF NOT EXISTS preuve_entite_sources
(
id TEXT PRIMARY KEY,
preuve_id TEXT NOT NULL,
entite_id TEXT NOT NULL,
source_kind TEXT NOT NULL CHECK (
source_kind IN ('manual', 'legacy_manual', 'eml_observation')
),
source_uuid TEXT,
created_at TEXT NOT NULL CHECK (length(created_at) = 20),
FOREIGN KEY (preuve_id, entite_id)
REFERENCES preuve_entites(preuve_id, entite_id) ON DELETE CASCADE,
CHECK (
(source_kind = 'eml_observation' AND source_uuid IS NOT NULL) OR
(source_kind <> 'eml_observation' AND source_uuid IS NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_preuve_entite_sources_unique
ON preuve_entite_sources(
preuve_id, entite_id, source_kind, COALESCE(source_uuid, '')
);
CREATE INDEX IF NOT EXISTS idx_preuve_entite_sources_entity
ON preuve_entite_sources(entite_id);
CREATE INDEX IF NOT EXISTS idx_preuve_entite_sources_source
ON preuve_entite_sources(source_kind, source_uuid);

54
database/schema_v13.sql Normal file
View file

@ -0,0 +1,54 @@
/******************************************************************************
* Schéma SQLite V13 propriété des associations preuve-entité.
******************************************************************************/
CREATE TABLE preuve_entite_sources
(
id TEXT PRIMARY KEY,
preuve_id TEXT NOT NULL,
entite_id TEXT NOT NULL,
source_kind TEXT NOT NULL CHECK (
source_kind IN ('manual', 'legacy_manual', 'eml_observation')
),
source_uuid TEXT,
created_at TEXT NOT NULL CHECK (length(created_at) = 20),
FOREIGN KEY (preuve_id, entite_id)
REFERENCES preuve_entites(preuve_id, entite_id) ON DELETE CASCADE,
CHECK (
(source_kind = 'eml_observation' AND source_uuid IS NOT NULL) OR
(source_kind <> 'eml_observation' AND source_uuid IS NULL)
)
);
CREATE UNIQUE INDEX idx_preuve_entite_sources_unique
ON preuve_entite_sources(
preuve_id, entite_id, source_kind, COALESCE(source_uuid, '')
);
CREATE INDEX idx_preuve_entite_sources_entity
ON preuve_entite_sources(entite_id);
CREATE INDEX idx_preuve_entite_sources_source
ON preuve_entite_sources(source_kind, source_uuid);
/* Les rattachements antérieurs sont conservés de façon prudente. */
INSERT INTO preuve_entite_sources(
id, preuve_id, entite_id, source_kind, source_uuid, created_at
)
SELECT
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
substr(lower(hex(randomblob(2))),2) || '-a' ||
substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))),
preuve_id, entite_id, 'legacy_manual', NULL,
strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
FROM preuve_entites;
/* Une promotion V12 identifiable reçoit aussi sa justification précise. */
INSERT OR IGNORE INTO preuve_entite_sources(
id, preuve_id, entite_id, source_kind, source_uuid, created_at
)
SELECT
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
substr(lower(hex(randomblob(2))),2) || '-a' ||
substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))),
evidence_id, entity_id, 'eml_observation', id,
COALESCE(promoted_at, integrated_at)
FROM evidence_entity_observations
WHERE entity_id IS NOT NULL;

View file

@ -1,8 +1,8 @@
# Architecture
> **Version :** 3.0
> **Dernière mise à jour :** 2026-07-24
> **Schéma SQLite courant :** V10
> **Version :** 3.1
> **Dernière mise à jour :** 2026-07-28
> **Schéma SQLite courant :** V13
> **Statut :** architecture courante
---
@ -301,7 +301,7 @@ validation du chemin
création de l'arborescence
initialisation transactionnelle de SQLite V10
initialisation transactionnelle de SQLite V13
création de l'identité de l'enquête
@ -492,43 +492,168 @@ sont proches visuellement.
## 13. Pivot EML
Statut :
### 13.1 Parcours
```text
PARTIEL
preuve EML
↓ contrôle d'intégrité SHA-256
tâche asynchrone
analyse des en-têtes
extraction MIME récursive vers un staging
outils documentaires optionnels
propositions temporaires
↓ confirmation explicite
observations persistantes dans la fiche
↓ promotion facultative
entités canoniques du graphe
```
La branche contient des briques d'analyse EML, d'extraction MIME, d'IBAN, OCR,
ExifTool, vocabulaire contrôlé, propositions bancaires et pipeline de tâche.
La preuve originale n'est jamais modifiée. Le staging et ses pièces jointes
extraites sont supprimés après rejet, annulation, erreur ou intégration.
L'absence d'ExifTool, Tesseract ou Poppler conserve un résultat partiel :
l'analyse des en-têtes et MIME reste utilisable.
Le flux cible est :
### 13.2 Responsabilités
```text
EML original
en-têtes et MIME
pièces jointes dérivées
empreintes et métadonnées
OCR et indicateurs
valeurs brutes + normalisées + dérivées
révision humaine
intégration transactionnelle
```
- `EmlAnalyzer` lit les en-têtes, conserve leurs occurrences et qualifie les
adresses, domaines et IP.
- `EmlMimeExtractor` parcourt les parties imbriquées, y compris
`message/rfc822`, décode Base64, quoted-printable, RFC 2047 et RFC 2231,
assainit les noms et applique les limites de profondeur, nombre et taille.
- `EmlPipelineTask` orchestre en arrière-plan l'analyse, le staging, les outils
documentaires et les propositions bancaires.
- `DocumentToolRunner` lance les programmes avec `GSubprocess`, sans shell,
draine simultanément `stdout` et `stderr`, borne les sorties et propage
l'annulation.
- les modules ExifTool, OCR et PDF structurent les résultats sans modifier la
source. Le PDF privilégie le texte natif puis utilise l'OCR page par page.
- `BankProposal` conserve les valeurs bancaires détectées, leur normalisation,
leur validation et une éventuelle correction OCR distincte.
- `EmlAnalysisDialog` présente les résultats et collecte séparément la
conservation et la promotion.
- `EmlIntegration`, les DAO et `EvidenceObservation` assurent l'écriture
transactionnelle, la déduplication, la promotion et le retrait.
- `Application`, `MainWindow` et `Workspace` raccordent la tâche au contexte
GTK principal, rafraîchissent le graphe et rechargent la fiche depuis SQLite.
Les valeurs brutes restent immuables.
### 13.3 Proposition, observation et entité
Une IP de relais SMTP ne doit pas être présentée comme l'adresse personnelle
d'un suspect.
Une **proposition** est un résultat temporaire. Elle peut être rejetée,
invalidée, corrigée, conservée ou accompagnée d'une demande de promotion.
Une donnée bancaire observée ne prouve pas l'identité de l'auteur d'une fraude.
Une **observation** est une information confirmée liée à une preuve. La table
`evidence_entity_observations` conserve son UUID, son type, ses valeurs brute,
normalisée et corrigée éventuelle, son rôle, l'en-tête et son occurrence, sa
provenance, son statut, ses dates et une association facultative à une entité.
Une observation peut donc exister durablement sans nœud de graphe.
Le ticket Forgejo #107 reste la référence fonctionnelle du chantier.
Une **entité** est un objet canonique réutilisable de `entites`. Elle n'est
créée ou réutilisée que si « Promouvoir en entité » est explicitement coché.
`preuve_entites` fournit alors le rattachement nécessaire à la projection du
graphe. Une même entité peut servir plusieurs rôles, observations, preuves ou
relations.
### 13.4 MIME et outils documentaires
L'extracteur accepte un EML de 50 Mio au maximum. Il limite une partie décodée
à 8 Mio, le total décodé à 32 Mio, le message à 128 parties, la profondeur à
12 niveaux et un nom produit à 240 octets. Les chemins MIME sont conservés,
les fichiers inline et `Content-ID` sont inventoriés, les traversées de chemin
sont neutralisées et les écritures passent par un temporaire renommé après
succès. Cette prise en charge volontairement bornée ne prétend pas couvrir
l'intégralité des RFC MIME.
Une analyse documentaire accepte au maximum 50 Mio, 8 Mio de `stdout`,
256 Kio de `stderr`, 100 pages PDF et 128 documents par pipeline. Un PDF
chiffré n'est pas contourné. `pdfinfo` inspecte le document, `pdftotext`
fournit en priorité le texte natif et `pdftoppm` rend les pages nécessitant un
OCR. L'ordre des pages, les résultats partiels et la méthode utilisée sont
conservés ; les images temporaires sont nettoyées.
Tesseract reçoit `fra+eng` dans le pipeline. Son texte brut n'est pas corrigé
silencieusement : une correction OCR proposée reste distincte. Les arguments,
la version et l'état de l'exécution documentent la provenance.
ExifTool est appelé en sortie JSON avec les groupes de tags. Les champs connus
sont normalisés et les tags inconnus sont conservés avec leur groupe, leur nom
et leur valeur brute. La version de l'outil est attachée à l'exécution. Les
coordonnées GPS sont marquées sensibles et ne créent jamais automatiquement
une entité.
Les propositions bancaires peuvent contenir IBAN brut et normalisé,
validation MOD-97, BIC, banque, titulaire déclaré, adresse, éléments de RIB et
correction OCR distincte. Une donnée invalide n'est pas intégrable. Un
titulaire déclaré dans un document n'établit ni identité certaine ni
attribution pénale : la donnée reste une proposition puis une observation
tant que l'enquêteur ne choisit pas de la promouvoir.
### 13.5 Migrations V11, V12 et V13
V11 crée le premier modèle `evidence_entity_observations`, où chaque
observation est obligatoirement liée à une entité.
V12 donne un UUID propre à l'observation, rend `entity_id` nullable, ajoute les
valeurs corrigées, l'extraction, les avertissements, les dates d'observation,
d'intégration et de promotion ainsi que `promotion_kind`. Les lignes V11 sont
reprises avec `promotion_kind = 'legacy'`. L'index sémantique assure la
déduplication, y compris lorsque `extraction_id` est nul.
V13 ajoute `preuve_entite_sources`. Chaque rattachement matérialisé possède
une justification `manual`, `legacy_manual` ou `eml_observation`. La migration
protège les lignes historiques par `legacy_manual` et reprend les promotions
V12 identifiables.
### 13.6 Promotion et retrait
La conservation seule écrit l'observation et l'affiche dans la fiche, sans
créer `entites` ni `preuve_entites`. La promotion explicite crée ou réutilise
une entité, l'associe à l'observation et ajoute le rattachement nécessaire.
L'opération est transactionnelle et idempotente.
« Retirer du graphe » efface uniquement la source `eml_observation` portant
l'UUID de l'observation et conserve
l'observation. Le nœud n'est supprimé que si les références connues
(observations, preuves, relations, tags, recherches, chronologie, hypothèses,
OSINT, comptes sociaux et rôles de personne) sont absentes.
### 13.7 Qualification et provenance
Les rôles couvrent `From`, `Sender`, `Reply-To`, `Return-Path`, `To`, `Cc`,
`Bcc`, les relais `Received` et le domaine de `Message-ID`. Une même adresse
peut conserver plusieurs rôles sans multiplier l'entité canonique.
`192.0.2.10` et `198.51.100.20` sont des IP, jamais des domaines ;
`MIME-Version: 1.0` ne produit pas de domaine intégrable.
### 13.8 Asynchronisme et sécurité
`BackgroundTask`, `TaskManager` et `GCancellable` portent l'état, la
progression, l'annulation et la remise du résultat au contexte principal.
Le worker ne manipule aucun widget. Avant de présenter le résultat,
`Application` vérifie que la session attendue est toujours active ; un
changement d'enquête rend le résultat caduc et déclenche le nettoyage.
Tous les arguments externes sont séparés, sans shell. `stdout` et `stderr`
sont drainés en parallèle pour éviter un interblocage. Les processus sont
forcés à terminer lors d'une annulation, puis les répertoires temporaires sont
supprimés.
### 13.9 Limites connues
- la promotion est disponible dans le dialogue d'analyse, pas directement
depuis la fiche ;
- la fiche affiche la valeur canonique et les codes persistés, mais pas encore
la valeur brute distincte ni l'UUID de l'entité associée ;
- le runner borne les sorties et gère l'annulation, mais ne possède pas de
délai maximal autonome ;
- les pièces jointes analysées dans le staging ne sont pas persistées comme
dérivés confirmés par ce parcours ;
- les rattachements antérieurs à V13 restent volontairement protégés par
`legacy_manual`, faute de provenance historique plus précise ;
- la couverture visuelle du dialogue et de la fiche reste manuelle.
---

View file

@ -1,7 +1,7 @@
# Conventions de développement
> **Version :** 2.0
> **Dernière mise à jour :** 2026-07-24
> **Version :** 2.1
> **Dernière mise à jour :** 2026-07-28
> **Projet :** Labfy Investigation
---
@ -265,7 +265,7 @@ présentation graphique.
La version courante du schéma est :
```text
V10
V13
```
Les scripts versionnés sont conservés dans :
@ -273,7 +273,7 @@ Les scripts versionnés sont conservés dans :
```text
database/schema_v1.sql
...
database/schema_v10.sql
database/schema_v12.sql
```
Le complément idempotent du schéma courant est :
@ -614,3 +614,35 @@ HISTORIQUE
La priorité reste la qualité, la traçabilité et la compréhension durable du
projet.
---
## 13. Propositions, observations et entités
Ces objets ne sont pas interchangeables :
- une proposition est temporaire et ne constitue pas un fait persistant ;
- une observation confirmée décrit ce qui a été relevé dans une preuve ;
- une entité est un objet canonique du graphe, créé ou réutilisé uniquement
après une promotion explicite.
Une conservation normale ne crée jamais automatiquement d'entité, de nœud ou
de rattachement `preuve_entites`. La promotion est facultative et désactivée
par défaut.
Une observation conserve obligatoirement une provenance et un rôle
compréhensible dans son contexte. Les codes issus d'un vocabulaire contrôlé
sont utilisés à la place de chaînes libres. Valeur brute, valeur normalisée et
correction utilisateur restent dans des champs distincts ; aucune correction
ne réécrit silencieusement la valeur observée.
Le retrait d'une promotion conserve l'observation et retire exclusivement la
source `eml_observation` identifiée par son UUID. Une entité partagée avec
d'autres observations, preuves ou relations ne doit jamais être supprimée
aveuglément. Le service doit également préserver tout rattachement indépendant
de la promotion. Les sources `manual` et `legacy_manual` persistent
indépendamment et protègent le rattachement matérialisé.
Ces règles s'ajoutent aux interdictions générales : aucune modification d'une
preuve originale, aucun shell dynamique et aucun accès SQLite direct depuis
un widget GTK.

View file

@ -3,6 +3,9 @@
Ce fichier recense les outils utilisés par LabFy Investigation et leur
installation. Il sera complété à chaque intégration dun nouvel outil.
La migration SQLite V13 et la gestion des propriétaires de
`preuve_entites` n'ajoutent aucune dépendance système.
## Principe dinstallation
Les dépendances système sont installées avec le gestionnaire de paquets de la
@ -27,8 +30,9 @@ export PATH="$HOME/.local/share/labfy-osint/bin:$PATH"
| Fonction | Exécutable | Arch Linux (pacman) | Ubuntu/Debian (apt) |
| --- | --- | --- | --- |
| OCR | `tesseract` | `sudo pacman -S tesseract tesseract-data-fra` | `sudo apt install tesseract-ocr tesseract-ocr-fra` |
| OCR français et anglais | `tesseract` | `sudo pacman -S tesseract tesseract-data-fra tesseract-data-eng` | `sudo apt install tesseract-ocr tesseract-ocr-fra tesseract-ocr-eng` |
| Métadonnées | `exiftool` | `sudo pacman -S perl-image-exiftool` | `sudo apt install libimage-exiftool-perl` |
| Inspection, texte et rendu PDF | `pdfinfo`, `pdftotext`, `pdftoppm` | `sudo pacman -S poppler` | `sudo apt install poppler-utils` |
| Recherche DNS | `dig` | `sudo pacman -S bind` | `sudo apt install dnsutils` |
| Résolution réseau | `host` | `sudo pacman -S bind` | `sudo apt install bind9-host` |
| WHOIS | `whois` | `sudo pacman -S whois` | `sudo apt install whois` |
@ -40,7 +44,7 @@ export PATH="$HOME/.local/share/labfy-osint/bin:$PATH"
Après installation, vérifier les outils avec :
```sh
command -v tesseract exiftool dig host whois curl openssl qpdf john pdf2john
command -v tesseract exiftool pdfinfo pdftotext pdftoppm dig host whois curl openssl qpdf john pdf2john
```
## Outils OSINT Python
@ -83,8 +87,9 @@ vérifiée avec `command -v`, puis avec son option de version (`--version` ou
## Notes
- Ne pas installer ces outils dans le dépôt Git.
- ExifTool, Tesseract et Poppler sont optionnels pour le pivot EML. Leur
absence laisse disponibles l'analyse des en-têtes et l'extraction MIME.
- Ne jamais stocker de jetons, mots de passe ou données privées dans ce
fichier.
- Les versions peuvent varier selon la distribution ; noter ici toute
commande particulière nécessaire à Ubuntu des forces de lordre.

View file

@ -1,7 +1,7 @@
# Guide de développement
> **Version :** 2.0
> **Dernière mise à jour :** 2026-07-24
> **Version :** 2.1
> **Dernière mise à jour :** 2026-07-28
> **Projet :** Labfy Investigation
---
@ -86,6 +86,22 @@ système de build ne les impose pas.
Les outils OSINT, OCR, métadonnées ou PDF sont documentés séparément et restent
optionnels sauf décision explicite.
### 3.1 Dépendances documentaires optionnelles
Le pivot EML appelle directement, lorsqu'ils sont installés :
- `exiftool` pour les métadonnées JSON ;
- `tesseract` pour l'OCR, avec la demande `fra+eng` dans le pipeline ;
- `pdfinfo`, `pdftotext` et `pdftoppm` fournis par Poppler pour l'inspection,
le texte natif et le rendu des pages PDF.
Sous Debian ou Ubuntu, les paquets usuels déjà identifiés par le projet sont
`libimage-exiftool-perl`, `tesseract-ocr`, `tesseract-ocr-fra`,
`tesseract-ocr-eng` et `poppler-utils`. Ils ne sont pas nécessaires à la
compilation ni au démarrage. Un exécutable absent produit un état
« indisponible » ou un résultat partiel ; les en-têtes et MIME restent
consultables. L'application ne les installe jamais automatiquement.
---
## 4. Récupération du dépôt
@ -195,6 +211,42 @@ make tests/test_eml_pipeline_task
./tests/test_eml_pipeline_task
```
Tests ciblés du pivot EML :
```sh
make -j8 \
tests/test_eml_analyzer \
tests/test_eml_mime_extractor \
tests/test_eml_pipeline_task \
tests/test_document_tool_runner \
tests/test_exiftool_analysis \
tests/test_ocr_analysis \
tests/test_pdf_analysis \
tests/test_bank_proposal \
tests/test_eml_integration \
tests/test_evidence_entity_dao \
tests/test_database
./tests/test_eml_analyzer
./tests/test_eml_mime_extractor
./tests/test_eml_pipeline_task
./tests/test_document_tool_runner
./tests/test_exiftool_analysis
./tests/test_ocr_analysis
./tests/test_pdf_analysis
./tests/test_bank_proposal
./tests/test_eml_integration
./tests/test_evidence_entity_dao
./tests/test_database
```
La fixture manuelle est
`tests/fixtures/eml/manual_smoke_test.eml`. Elle est exclusivement
synthétique. Pour la validation GTK, créer une enquête neuve dans un
répertoire temporaire choisi pour le test, importer cette fixture, puis suivre
`docs/testing/EML_PIVOT_MANUAL_TEST.md`. Ne jamais réutiliser une base ou une
preuve réelle.
Le nom exact d'une cible doit être vérifié dans le Makefile.
### 6.3 Validation avant intégration
@ -345,7 +397,7 @@ Lire :
docs/database/DATABASE_ARCHITECTURE.md
docs/database/SCHEMA_AUDIT_CURRENT.md
database/schema_current.sql
database/schema_v10.sql
database/schema_v12.sql
src/database/database.c
src/database/schema.c
tests/test_database.c
@ -353,21 +405,24 @@ tests/test_database.c
### 9.2 Nouvelle version de schéma
Pour créer V11, par exemple :
Pour créer une nouvelle version après V13, par exemple V14 :
1. ajouter `database/schema_v11.sql` ;
2. déclarer et implémenter `schema_install_v11()` ;
3. ajouter `database_migrate_v10_to_v11()` ;
1. ajouter `database/schema_v13.sql` ;
2. déclarer et implémenter `schema_install_v13()` ;
3. ajouter `database_migrate_v12_to_v13()` ;
4. raccorder la migration dans la boucle vers la version courante ;
5. mettre à jour les constantes de version ;
6. installer V11 lors de la création d'une base neuve ;
6. installer V13 lors de la création d'une base neuve ;
7. adapter `schema_current.sql` si nécessaire ;
8. ajouter une fixture V10 vers V11 ;
8. ajouter une fixture V13 vers V14 ;
La fixture V12 vers V13 vérifie le backfill `legacy_manual`. Les tests EML
couvrent aussi le retrait isolé d'une source face à un rattachement manuel.
9. tester une base neuve ;
10. tester le rollback ;
11. vérifier l'intégrité et les clés étrangères ;
12. mettre à jour l'audit courant ;
13. créer l'audit versionné de V11.
13. créer l'audit versionné de V13.
Ne pas réécrire une ancienne migration publiée pour changer sa signification.

View file

@ -1,8 +1,8 @@
# Roadmap
> **Dernière mise à jour :** 2026-07-24
> **Dernière mise à jour :** 2026-07-28
> **État du projet :** développement actif
> **Schéma SQLite courant :** V10
> **Schéma SQLite courant :** V13
> **Usage opérationnel :** non prêt pour la production
---
@ -116,7 +116,7 @@ La branche `main` contient notamment :
- création, validation et ouverture d'enquêtes ;
- session d'enquête remplaçable proprement ;
- infrastructure SQLite et migrations jusqu'à V10 ;
- infrastructure SQLite et migrations jusqu'à V13 ;
- couche Database, DAO et services métier ;
- import de preuves avec copie contrôlée et SHA-256 ;
- vérification d'intégrité et reclassement des preuves ;
@ -135,7 +135,8 @@ La branche `main` contient notamment :
- types canoniques de relations ;
- vocabulaire contrôlé ;
- table V10 `bank_account_entities` ;
- premiers composants du pipeline EML.
- pivot EML raccordé à GTK, observations persistantes indépendantes des
entités, promotion facultative et retrait réversible.
Cette liste est une synthèse et non un contrat de stabilité.
@ -145,15 +146,13 @@ Cette liste est une synthèse et non un contrat de stabilité.
### Ticket #107 — Pivot e-mail forensique
Statut :
Statut technique :
```text
PARTIEL
IMPLÉMENTÉ — VALIDATION DOCUMENTAIRE ET CORRECTION BLOQUANTE RESTANTES
```
Les briques préparatoires existent, mais le flux complet reste à terminer.
Objectif principal :
Le parcours livré est :
```text
preuve EML
@ -171,30 +170,29 @@ OCR et détection d'indicateurs
normalisation sans perte de la valeur brute
interface de révision
confirmation explicite
intégration transactionnelle
rafraîchissement des vues et du graphe
↓ conservation explicite
observation persistante dans la fiche
↓ promotion facultative
entité créée ou réutilisée dans le graphe
```
Priorités immédiates :
Les tests automatiques EML, MIME, outils documentaires, PDF, OCR, ExifTool,
banque, intégration et migration sont présents. Le parcours GTK dispose d'une
fixture et d'un guide manuel ; la couverture visuelle demeure manuelle.
1. terminer l'extraction MIME robuste ;
2. conserver les pièces jointes comme fichiers dérivés ;
3. réunir EML, OCR, IBAN et ExifTool dans un pipeline unique ;
4. terminer l'interface de révision ;
5. persister uniquement les propositions confirmées ;
6. réutiliser ou créer les entités correspondantes ;
7. créer les relations canoniques avec leur provenance ;
8. garantir un rollback complet en cas d'échec ;
9. rafraîchir la barre latérale et le graphe ;
10. compléter les tests de migration et d'intégration ;
11. mettre à jour la documentation avec l'état réellement livré.
V13 distingue désormais chaque propriétaire du rattachement
`preuve_entites`. Un retrait EML enlève sa seule source
`eml_observation`; les sources manuelles, historiques et celles des autres
observations restent protégées.
Le ticket reste ouvert tant que son flux complet et ses critères d'acceptation
ne sont pas validés.
Améliorations suivantes :
1. proposer la promotion directement depuis la fiche ;
2. afficher les libellés contrôlés, la valeur brute distincte et l'UUID de
l'entité associée ;
3. persister explicitement les dérivés confirmés du staging ;
4. ajouter un délai maximal autonome aux outils documentaires ;
5. automatiser davantage le parcours GTK.
---

View file

@ -1,66 +1,61 @@
# Test manuel du parcours EML
Ce test emploie uniquement la fixture synthétique
`tests/fixtures/eml/manual_smoke_test.eml`. Il ne nécessite aucune donnée
réelle.
Ce parcours complète les tests automatiques. Il utilise exclusivement la
fixture synthétique `tests/fixtures/eml/manual_smoke_test.eml`, dans une
enquête synthétique neuve. Toute donnée ou base réelle est interdite.
## Parcours synthétique
## Préparation et tests automatiques
1. Compiler avec `make -j8`.
2. Créer une enquête synthétique neuve dans un répertoire de test.
3. Importer `tests/fixtures/eml/manual_smoke_test.eml` comme preuve.
4. Sélectionner cette preuve dans la barre latérale.
5. Vérifier que laction compacte « Analyser le-mail » devient active.
6. Lancer laction et observer « Analyse complète de le-mail » dans le
panneau dactivité.
7. Pendant un premier essai, annuler la tâche depuis ce panneau et vérifier
quaucun dialogue de succès ni aucune nouvelle entité napparaît.
8. Relancer lanalyse et vérifier dans le dialogue les en-têtes, les deux
pièces jointes, les textes, les métadonnées et les avertissements relatifs
aux outils optionnels éventuellement absents.
9. Vérifier que lIBAN commençant par `FR00` est indiqué comme donnée de
démonstration invalide et nest pas proposé à lintégration.
10. Vérifier que les valeurs sont sélectionnables et copiables.
11. Fermer avec « Rejeter et fermer » et confirmer labsence de nouvelle
entité, relation ou rattachement.
12. Relancer et vérifier que chaque proposition affiche deux actions
distinctes : « Conserver dans la fiche » et « Promouvoir en entité ».
La promotion doit être décochée et désactivée tant que la conservation
nest pas cochée.
13. Vérifier que chaque proposition affiche son rôle et son
origine (`From`, `To`, `Message-ID` ou `Received #n`). Vérifier
explicitement que `192.0.2.10`, `198.51.100.20` sont des IP et que
`1.0` nest jamais proposé comme domaine.
14. Cocher uniquement « Conserver dans la fiche » pour quelques adresses,
domaines et relais synthétiques, puis
choisir « Intégrer les éléments sélectionnés ».
15. Vérifier que le bilan annonce zéro promotion, quaucun nœud ni lien
`preuve_entites` nest créé et que la
fiche de la preuve reste sélectionnée. La section « Entités observées
dans cette preuve » doit afficher valeur canonique, type, rôle,
en-tête/occurrence, provenance et « graphe : non ajoutée ».
16. Fermer puis rouvrir lenquête synthétique et vérifier que les
observations, rôles et origines sont toujours présents sans nœud.
17. Relancer lanalyse, cocher la conservation et « Promouvoir en entité »
pour une seule observation, confirmer et vérifier quun seul nœud
apparaît et que la fiche indique la promotion.
18. Répéter la promotion et vérifier quaucune observation ni entité nest
dupliquée.
19. Dans « Observations extraites », utiliser « Retirer du graphe » sur
lobservation promue et confirmer. Vérifier que lobservation, son rôle
et sa provenance restent affichés avec « Graphe : Non ajoutée ».
20. Vérifier que le nœud disparaît lorsquil na aucune autre référence.
21. Promouvoir deux observations de rôles différents vers la même valeur
canonique, puis nen retirer quune. Vérifier que lentité partagée et
lautre observation restent présentes.
22. Fermer puis rouvrir lenquête synthétique et vérifier que tous les états
de promotion et de retrait persistent.
23. Recalculer lintégrité de la preuve et vérifier que son SHA-256 est
inchangé.
```sh
make -j8
make -j8 test
```
Noter séparément le comportement lorsque ExifTool, Tesseract ou les outils
Poppler ne sont pas installés : les en-têtes et lextraction MIME doivent
rester consultables.
Un échec de compilation ou de test est bloquant. Créer ensuite une enquête
neuve dans un répertoire temporaire dédié, importer la fixture et vérifier son
intégrité depuis la fiche. L'empreinte doit correspondre à celle enregistrée.
Ce parcours doit rester exclusivement synthétique : aucune enquête, preuve,
base SQLite, pièce jointe ou donnée réelle ne doit être utilisée.
## Parcours manuel
| Étape | Action | Résultat attendu | Échec bloquant |
|---|---|---|---|
| 1 | Sélectionner la fixture importée. | « Analyser l'e-mail » est disponible. | Action absente ou active sur un fichier non EML. |
| 2 | Lancer l'analyse puis l'annuler depuis le panneau d'activité. | État annulé, aucun dialogue d'intégration, aucune observation, entité ou relation créée. | Écriture persistante après annulation. |
| 3 | Relancer l'analyse. | Le dialogue affiche en-têtes, deux pièces jointes, textes, métadonnées et avertissements disponibles. | Plantage ou modification de la preuve. |
| 4 | Contrôler `From`, `Sender`, `Reply-To`, `Return-Path`, `To`, `Cc`, `Bcc`, `Message-ID` et chaque `Received`. | Chaque proposition d'en-tête indique son rôle et son origine avec occurrence. | Rôle ou origine perdus. |
| 5 | Contrôler les indicateurs. | `192.0.2.10` et `198.51.100.20` sont des IP ; `1.0` n'est jamais un domaine ; `Message-ID` n'est pas une adresse e-mail. | Mauvaise qualification. |
| 6 | Contrôler les propositions bancaires. | L'IBAN synthétique commençant par `FR00` est invalide et non intégrable ; aucune attribution pénale n'est déduite du titulaire déclaré. | Promotion automatique ou donnée invalide intégrable. |
| 7 | Fermer avec « Rejeter et fermer ». | Aucun objet persistant nouveau. | Observation, entité ou lien créé. |
| 8 | Relancer, cocher seulement « Conserver dans la fiche » sur quelques propositions. | « Promouvoir en entité » reste décoché par défaut et n'est activable que pour une proposition conservée. | Promotion implicite. |
| 9 | Intégrer la sélection. | Le bilan annonce zéro promotion ; la fiche reste sélectionnée et affiche toutes les observations choisies, leur valeur canonique, type, rôle, source/occurrence, provenance, validation et « Graphe : Non ajoutée ». Aucune ligne `entites` ou `preuve_entites` n'est créée. | Nœud ou rattachement créé par la conservation. |
| 10 | Changer de preuve puis revenir. | Toutes les observations réapparaissent immédiatement. | Observation perdue au rafraîchissement. |
| 11 | Fermer puis rouvrir l'enquête synthétique. | Observations, rôles, origines et état non promu persistent. | Perte de données. |
| 12 | Relancer et promouvoir explicitement une seule observation conservée. | Une seule entité est créée ou réutilisée, un seul nœud apparaît et la fiche indique la promotion. | Plusieurs nœuds ou promotion non demandée. |
| 13 | Répéter la même intégration. | Aucune observation ni entité en double. | Doublon. |
| 14 | Dans « Observations extraites », choisir « Retirer du graphe » et confirmer. | L'observation reste présente avec rôle et provenance ; elle repasse à « Graphe : Non ajoutée ». | Observation supprimée. |
| 15 | Vérifier le graphe. | Le nœud disparaît seulement s'il n'a aucune autre référence. | Suppression d'une entité encore utilisée. |
| 16 | Promouvoir deux rôles vers la même valeur canonique, puis en retirer un. | L'entité et l'autre observation promue restent présentes. | Entité partagée supprimée. |
| 17 | Relier une entité promue à une relation, puis retirer la promotion. | La relation et l'entité restent présentes. | Relation ou entité utilisée supprimée. |
| 18 | Réouvrir l'enquête. | États de conservation, promotion et retrait persistants. | État uniquement visuel. |
| 19 | Créer manuellement le même rattachement preuve-entité qu'une promotion, puis retirer cette promotion. | L'observation est détachée, mais le rattachement manuel, le nœud et l'entité restent présents. | Le rattachement manuel ou l'entité disparaît. |
| 19 | Recalculer l'intégrité. | Le SHA-256 final de la fixture est inchangé. | Empreinte modifiée. |
## Dépendances optionnelles
Répéter l'analyse avec ExifTool, Tesseract ou les outils Poppler absents, selon
les possibilités du poste. L'outil concerné doit être signalé indisponible ou
le résultat partiel ; les en-têtes et l'extraction MIME restent consultables.
Un plantage ou l'impossibilité d'accéder aux résultats EML de base est
bloquant.
## Limites connues
- La promotion est proposée dans le dialogue d'analyse, pas depuis la fiche.
- La fiche affiche encore les codes persistés et ne montre pas séparément la
valeur brute différente ni l'UUID de l'entité.
- Les sorties des outils sont bornées et annulables, mais sans délai maximal
autonome.
- Les fichiers du staging sont nettoyés ; le parcours ne conserve pas encore
les pièces jointes comme dérivés confirmés.
- Tester le retrait avec un rattachement manuel préexistant : V13 conserve sa
source `manual` indépendamment de la source `eml_observation`.

View file

@ -151,6 +151,16 @@ gboolean evidence_entity_dao_unlink(
GError **error
);
gboolean evidence_entity_dao_add_source(
EvidenceEntityDao *dao, const char *evidence_identifier,
const char *entity_identifier, const char *source_kind,
const char *source_uuid, const char *created_at, GError **error);
gboolean evidence_entity_dao_remove_source(
EvidenceEntityDao *dao, const char *evidence_identifier,
const char *entity_identifier, const char *source_kind,
const char *source_uuid, gboolean *out_link_removed, GError **error);
/**
* @brief Vérifie si une association existe.
*

View file

@ -93,6 +93,7 @@ bool schema_install_v9(Database *database);
bool schema_install_v10(Database *database);
bool schema_install_v11(Database *database);
bool schema_install_v12(Database *database);
bool schema_install_v13(Database *database);
/**
* @brief Garantit la présence des extensions du schéma courant V2.

View file

@ -4069,14 +4069,8 @@ static void application_on_rib_ocr_confirmed(const char *iban,
}
link_dao = evidence_entity_dao_new(database, &error);
if (link_dao == NULL) goto failure;
{
gboolean exists = FALSE;
if (!evidence_entity_dao_exists(link_dao, context->evidence_identifier,
entity_identifier, &exists, &error) ||
(!exists && !evidence_entity_dao_link(link_dao,
context->evidence_identifier, entity_identifier, &error)))
goto failure;
}
if (!evidence_entity_dao_link(link_dao, context->evidence_identifier,
entity_identifier, &error)) goto failure;
if (!database_transaction_commit(database)) goto failure;
active = FALSE; project = investigation_session_get_project(application->session);
g_free(application->pending_entity_selection_identifier);
@ -6416,11 +6410,8 @@ static void application_on_person_evidences_selected(GPtrArray *selected,
for (guint index = 0; index < selected->len; index++)
{
const char *identifier = g_ptr_array_index(selected, index);
gboolean exists = FALSE;
if (!evidence_entity_dao_exists(dao, identifier,
context->entity_identifier, &exists, &error) ||
(!exists && !evidence_entity_dao_link(dao, identifier,
context->entity_identifier, &error))) goto failure;
if (!evidence_entity_dao_link(dao, identifier,
context->entity_identifier, &error)) goto failure;
}
if (!database_transaction_commit(database)) goto failure;
active = FALSE;

View file

@ -164,7 +164,6 @@ gboolean eml_integration_apply(Database *database, const char *evidence_identifi
const char *identifier = existing != NULL
? entity_record_get_identifier(existing) : NULL;
char *new_identifier = NULL; EntityRecord *new_record = NULL;
gboolean linked = FALSE;
if (existing == NULL)
{
new_identifier = g_uuid_string_random(); identifier = new_identifier;
@ -178,10 +177,9 @@ gboolean eml_integration_apply(Database *database, const char *evidence_identifi
g_ptr_array_add(entities, new_record); new_record = NULL; created++;
}
else reused++;
if (!evidence_entity_dao_exists(link_dao, evidence_identifier,
identifier, &linked, error) ||
(!linked && !evidence_entity_dao_link(link_dao,
evidence_identifier, identifier, error)))
if (!evidence_entity_dao_add_source(link_dao, evidence_identifier,
identifier, "eml_observation", observation_identifier,
timestamp, error))
{ g_free(observation_identifier); g_free(new_identifier); goto cleanup; }
if (!evidence_entity_dao_promote_observation(link_dao,
observation_identifier, identifier, timestamp,
@ -225,15 +223,11 @@ gboolean eml_integration_remove_promotion(Database *database,
static const char *detach_sql =
"UPDATE evidence_entity_observations SET entity_id=NULL,"
"promoted_at=NULL,promotion_kind=NULL WHERE id=? AND entity_id=?;";
static const char *same_evidence_sql =
"SELECT COUNT(*) FROM evidence_entity_observations "
"WHERE evidence_id=?1 AND entity_id=?2;";
static const char *unlink_sql =
"DELETE FROM preuve_entites WHERE preuve_id=?1 AND entite_id=?2;";
static const char *dependency_sql =
"SELECT "
"(SELECT COUNT(*) FROM evidence_entity_observations WHERE entity_id=?1)+"
"(SELECT COUNT(*) FROM preuve_entites WHERE entite_id=?1)+"
"(SELECT COUNT(*) FROM preuve_entite_sources WHERE entite_id=?1)+"
"(SELECT COUNT(*) FROM relations WHERE entite_source_id=?1 OR entite_cible_id=?1)+"
"(SELECT COUNT(*) FROM tag_entites WHERE entite_id=?1)+"
"(SELECT COUNT(*) FROM recherche_entites WHERE entite_id=?1)+"
@ -244,6 +238,7 @@ gboolean eml_integration_remove_promotion(Database *database,
"(SELECT COUNT(*) FROM person_roles WHERE entity_id=?1);";
static const char *delete_sql = "DELETE FROM entites WHERE id=?;";
DatabaseStatement *statement = NULL;
EvidenceEntityDao *link_dao = NULL;
char *evidence_identifier = NULL, *entity_identifier = NULL;
gint64 count = 0; gboolean active = FALSE, success = FALSE;
g_return_val_if_fail(error == NULL || *error == NULL, FALSE);
@ -253,6 +248,8 @@ gboolean eml_integration_remove_promotion(Database *database,
return FALSE;
if (!database_transaction_begin(database)) return FALSE;
active = TRUE;
link_dao = evidence_entity_dao_new(database, error);
if (link_dao == NULL) goto cleanup;
statement = database_statement_prepare(database, read_sql);
if (statement == NULL ||
!database_statement_bind_text(statement, 1, observation_identifier) ||
@ -275,24 +272,9 @@ gboolean eml_integration_remove_promotion(Database *database,
database_statement_step(statement) != DATABASE_STATEMENT_STEP_DONE)
goto cleanup;
database_statement_finalize(statement); statement = NULL;
statement = database_statement_prepare(database, same_evidence_sql);
if (statement == NULL ||
!database_statement_bind_text(statement, 1, evidence_identifier) ||
!database_statement_bind_text(statement, 2, entity_identifier) ||
database_statement_step(statement) != DATABASE_STATEMENT_STEP_ROW ||
!database_statement_column_int64(statement, 0, &count))
goto cleanup;
database_statement_finalize(statement); statement = NULL;
if (count == 0)
{
statement = database_statement_prepare(database, unlink_sql);
if (statement == NULL ||
!database_statement_bind_text(statement, 1, evidence_identifier) ||
!database_statement_bind_text(statement, 2, entity_identifier) ||
database_statement_step(statement) != DATABASE_STATEMENT_STEP_DONE)
goto cleanup;
database_statement_finalize(statement); statement = NULL;
}
if (!evidence_entity_dao_remove_source(link_dao, evidence_identifier,
entity_identifier, "eml_observation", observation_identifier,
NULL, error)) goto cleanup;
if (!eml_integration_read_count(database, dependency_sql,
entity_identifier, &count)) goto cleanup;
if (count == 0)
@ -311,6 +293,7 @@ gboolean eml_integration_remove_promotion(Database *database,
cleanup:
database_statement_finalize(statement);
if (!success && active) database_transaction_rollback(database);
evidence_entity_dao_free(link_dao);
g_free(evidence_identifier); g_free(entity_identifier);
return success;
}

View file

@ -237,7 +237,6 @@ static gboolean extraction_drop_service_attach_in_transaction(
{
EvidenceEntityDao *link_dao = NULL;
char *evidence_identifier = NULL;
gboolean exists = FALSE;
gboolean success = FALSE;
g_return_val_if_fail(error == NULL || *error == NULL, FALSE);
@ -254,10 +253,8 @@ static gboolean extraction_drop_service_attach_in_transaction(
if (evidence_identifier == NULL) goto cleanup;
link_dao = evidence_entity_dao_new(database, error);
if (link_dao == NULL ||
!evidence_entity_dao_exists(link_dao, evidence_identifier,
entity_identifier, &exists, error) ||
(!exists && !evidence_entity_dao_link(link_dao, evidence_identifier,
entity_identifier, error))) goto cleanup;
!evidence_entity_dao_link(link_dao, evidence_identifier,
entity_identifier, error)) goto cleanup;
success = TRUE;
cleanup:
evidence_entity_dao_free(link_dao);

View file

@ -52,7 +52,7 @@ static const char *const evidence_entity_dao_association_exists_sql =
* @brief Requête créant une association.
*/
static const char *const evidence_entity_dao_link_sql =
"INSERT INTO preuve_entites"
"INSERT OR IGNORE INTO preuve_entites"
"("
" preuve_id,"
" entite_id"
@ -71,6 +71,36 @@ static const char *const evidence_entity_dao_unlink_sql =
"WHERE preuve_id = ? "
"AND entite_id = ?;";
static void evidence_entity_dao_set_database_error(
EvidenceEntityDao *evidence_entity_dao, GError **error,
EvidenceEntityDaoError error_code, const char *context);
static gboolean evidence_entity_dao_execute_source_statement(
EvidenceEntityDao *dao, const char *sql, const char *evidence_identifier,
const char *entity_identifier, const char *source_kind,
const char *source_uuid, const char *created_at, GError **error)
{
DatabaseStatement *statement = database_statement_prepare(dao->database, sql);
gboolean success = statement != NULL &&
database_statement_bind_text(statement, 1, evidence_identifier) &&
database_statement_bind_text(statement, 2, entity_identifier) &&
database_statement_bind_text(statement, 3, source_kind);
if (success && source_uuid != NULL)
success = database_statement_bind_text(statement, 4, source_uuid);
if (success && created_at != NULL)
success = database_statement_bind_text(statement, 5, created_at);
if (success)
success = database_statement_step(statement) ==
DATABASE_STATEMENT_STEP_DONE;
if (!success)
evidence_entity_dao_set_database_error(dao, error,
statement == NULL ? EVIDENCE_ENTITY_DAO_ERROR_PREPARE :
EVIDENCE_ENTITY_DAO_ERROR_EXECUTE,
"Impossible de modifier la provenance de l'association");
database_statement_finalize(statement);
return success;
}
/**
* @brief Requête listant les entités liées à une preuve.
*/
@ -801,8 +831,9 @@ gboolean evidence_entity_dao_link(
gboolean entity_exists =
FALSE;
gboolean association_exists =
FALSE;
GDateTime *now = NULL;
char *created_at = NULL;
gboolean success = FALSE;
g_return_val_if_fail(
error == NULL || *error == NULL,
@ -865,39 +896,20 @@ gboolean evidence_entity_dao_link(
return FALSE;
}
if (!evidence_entity_dao_query_exists(
evidence_entity_dao,
evidence_entity_dao_association_exists_sql,
evidence_identifier,
entity_identifier,
&association_exists,
error
))
{
return FALSE;
}
if (association_exists)
{
evidence_entity_dao_set_error_literal(
error,
EVIDENCE_ENTITY_DAO_ERROR_CONSTRAINT,
"Cette preuve est déjà associée à cette entité."
);
return FALSE;
}
return evidence_entity_dao_execute_pair_statement(
evidence_entity_dao,
evidence_entity_dao_link_sql,
evidence_identifier,
entity_identifier,
"Impossible de préparer la création de l'association",
"Impossible de lier les identifiants de l'association",
"Impossible de créer l'association",
error
);
now = g_date_time_new_now_utc();
created_at = now != NULL
? g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ") : NULL;
if (created_at == NULL)
evidence_entity_dao_set_error_literal(error,
EVIDENCE_ENTITY_DAO_ERROR_MEMORY,
"Impossible de dater l'association preuve-entité.");
else
success = evidence_entity_dao_add_source(evidence_entity_dao,
evidence_identifier, entity_identifier, "manual", NULL,
created_at, error);
g_free(created_at);
g_clear_pointer(&now, g_date_time_unref);
return success;
}
gboolean evidence_entity_dao_unlink(
@ -948,16 +960,103 @@ gboolean evidence_entity_dao_unlink(
return FALSE;
}
return evidence_entity_dao_execute_pair_statement(
evidence_entity_dao,
evidence_entity_dao_unlink_sql,
evidence_identifier,
entity_identifier,
"Impossible de préparer la suppression de l'association",
"Impossible de lier les identifiants de l'association",
"Impossible de supprimer l'association",
error
);
return evidence_entity_dao_remove_source(evidence_entity_dao,
evidence_identifier, entity_identifier, "manual", NULL, NULL, error);
}
gboolean evidence_entity_dao_add_source(
EvidenceEntityDao *dao, const char *evidence_identifier,
const char *entity_identifier, const char *source_kind,
const char *source_uuid, const char *created_at, GError **error)
{
static const char *insert_sql =
"INSERT OR IGNORE INTO preuve_entite_sources"
"(id,preuve_id,entite_id,source_kind,source_uuid,created_at)"
"VALUES(?,?,?,?,?,?);";
char *source_identifier = NULL;
DatabaseStatement *statement = NULL;
gboolean success = FALSE;
if (!evidence_entity_dao_validate_association(dao, evidence_identifier,
entity_identifier, error) ||
source_kind == NULL || created_at == NULL ||
(g_strcmp0(source_kind, "eml_observation") == 0 &&
(source_uuid == NULL || !g_uuid_string_is_valid(source_uuid))))
return FALSE;
if (!evidence_entity_dao_execute_pair_statement(dao,
evidence_entity_dao_link_sql, evidence_identifier,
entity_identifier, "Impossible de préparer l'association",
"Impossible de lier l'association",
"Impossible de matérialiser l'association", error))
return FALSE;
source_identifier = g_uuid_string_random();
statement = database_statement_prepare(dao->database, insert_sql);
success = statement != NULL &&
database_statement_bind_text(statement, 1, source_identifier) &&
database_statement_bind_text(statement, 2, evidence_identifier) &&
database_statement_bind_text(statement, 3, entity_identifier) &&
database_statement_bind_text(statement, 4, source_kind);
if (success && source_uuid != NULL)
success = database_statement_bind_text(statement, 5, source_uuid);
if (success)
success = database_statement_bind_text(statement, 6, created_at) &&
database_statement_step(statement) == DATABASE_STATEMENT_STEP_DONE;
if (!success)
evidence_entity_dao_set_database_error(dao, error,
statement == NULL ? EVIDENCE_ENTITY_DAO_ERROR_PREPARE :
EVIDENCE_ENTITY_DAO_ERROR_EXECUTE,
"Impossible d'enregistrer la provenance de l'association");
database_statement_finalize(statement);
g_free(source_identifier);
return success;
}
gboolean evidence_entity_dao_remove_source(
EvidenceEntityDao *dao, const char *evidence_identifier,
const char *entity_identifier, const char *source_kind,
const char *source_uuid, gboolean *out_link_removed, GError **error)
{
static const char *delete_source_sql =
"DELETE FROM preuve_entite_sources WHERE preuve_id=?1 AND entite_id=?2 "
"AND source_kind=?3 AND COALESCE(source_uuid,'')=COALESCE(?4,'');";
static const char *count_sql =
"SELECT COUNT(*) FROM preuve_entite_sources "
"WHERE preuve_id=?1 AND entite_id=?2;";
DatabaseStatement *statement = NULL;
gint64 count = 0;
gboolean success = FALSE;
if (out_link_removed != NULL) *out_link_removed = FALSE;
if (!evidence_entity_dao_validate_association(dao, evidence_identifier,
entity_identifier, error) || source_kind == NULL)
return FALSE;
if (!evidence_entity_dao_execute_source_statement(dao, delete_source_sql,
evidence_identifier, entity_identifier, source_kind, source_uuid,
NULL, error))
return FALSE;
statement = database_statement_prepare(dao->database, count_sql);
success = statement != NULL &&
database_statement_bind_text(statement, 1, evidence_identifier) &&
database_statement_bind_text(statement, 2, entity_identifier) &&
database_statement_step(statement) == DATABASE_STATEMENT_STEP_ROW &&
database_statement_column_int64(statement, 0, &count);
database_statement_finalize(statement);
if (!success)
{
evidence_entity_dao_set_database_error(dao, error,
EVIDENCE_ENTITY_DAO_ERROR_EXECUTE,
"Impossible de vérifier les provenances de l'association");
return FALSE;
}
if (count == 0)
{
success = evidence_entity_dao_execute_pair_statement(dao,
evidence_entity_dao_unlink_sql, evidence_identifier,
entity_identifier, "Impossible de préparer le détachement",
"Impossible de lier le détachement",
"Impossible de supprimer l'association sans provenance", error);
if (success && out_link_removed != NULL) *out_link_removed = TRUE;
return success;
}
return TRUE;
}
gboolean evidence_entity_dao_exists(

View file

@ -16,12 +16,12 @@
/**
* @brief Version actuelle du schéma SQLite.
*/
#define DATABASE_SCHEMA_VERSION_CURRENT 12
#define DATABASE_SCHEMA_VERSION_CURRENT 13
/**
* @brief Version actuelle sous forme textuelle pour metadata.
*/
#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "12"
#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "13"
/**
* @brief Nom de l'application enregistré dans les métadonnées.
@ -842,6 +842,21 @@ rollback:
return false;
}
static bool database_migrate_v12_to_v13(Database *database)
{
bool transaction_started = false;
if (database == NULL || !database_transaction_begin(database)) return false;
transaction_started = true;
if (!schema_install_v13(database) ||
!database_update_schema_version(database, "13") ||
!database_transaction_commit(database)) goto rollback;
return true;
rollback:
if (transaction_started && !database_transaction_rollback(database))
g_warning("Impossible dannuler la migration SQLite V12 vers V13.");
return false;
}
/**
* @brief Garantit atomiquement la présence des extensions du schéma courant.
*/
@ -1079,6 +1094,10 @@ bool database_migrate_to_latest(
if (!database_migrate_v11_to_v12(database)) return false;
schema_version = 12;
break;
case 12:
if (!database_migrate_v12_to_v13(database)) return false;
schema_version = 13;
break;
default:
database_set_error(

View file

@ -365,6 +365,12 @@ bool schema_install_v12(Database *database)
"la migration SQLite V12");
}
bool schema_install_v13(Database *database)
{
return schema_execute_file(database, "database/schema_v13.sql",
"la migration SQLite V13");
}
bool schema_ensure_current(
Database *database
)

View file

@ -525,7 +525,7 @@ static void test_database_initialize_valid_database(void)
"FROM investigation;"
);
assert(strcmp(schema_version, "12") == 0);
assert(strcmp(schema_version, "13") == 0);
test_database_assert_table_exists(database, "bank_account_entities");
test_database_assert_table_exists(database, "relation_types");
test_database_assert_table_exists(database, "graph_viewport");
@ -992,7 +992,7 @@ static void test_database_migrate_v1_to_v2(void)
assert(
strcmp(
schema_version,
"12"
"13"
) == 0
);
@ -1399,6 +1399,54 @@ static void test_database_migration_rollback(void)
);
}
static void test_database_migrate_v12_to_v13_preserves_legacy_link(void)
{
GError *error = NULL;
char *directory = g_dir_make_tmp("labfy-v13-test-XXXXXX", &error);
char *path = g_build_filename(directory, "Enquete.sqlite", NULL);
sqlite3 *sqlite_database = NULL;
Database *database = NULL;
char *version = NULL;
char *legacy_sources = NULL;
assert(directory != NULL && error == NULL);
assert(database_initialize(path, "Migration V13 synthétique", directory));
assert(sqlite3_open(path, &sqlite_database) == SQLITE_OK);
test_database_execute_sql(sqlite_database,
"DROP TABLE preuve_entite_sources;"
"UPDATE metadata SET value='12' WHERE key='schema_version';"
"INSERT INTO preuves(id,name,relative_path,type_id,size_bytes,sha256,"
"imported_at,updated_at,status,locked,original_name) VALUES("
"'10000000-0000-4000-8000-000000000013','legacy.eml','legacy.eml',"
"5,1,'0000000000000000000000000000000000000000000000000000000000000000',"
"'2026-07-28T08:00:00Z','2026-07-28T08:00:00Z','active',0,'legacy.eml');"
"INSERT INTO entites(id,type_id,valeur,label,confiance,created_at,"
"updated_at,status) VALUES("
"'20000000-0000-4000-8000-000000000013',1,'legacy@example.test',"
"'legacy@example.test',50,'2026-07-28T08:00:00Z',"
"'2026-07-28T08:00:00Z','active');"
"INSERT INTO preuve_entites(preuve_id,entite_id) VALUES("
"'10000000-0000-4000-8000-000000000013',"
"'20000000-0000-4000-8000-000000000013');");
assert(sqlite3_close(sqlite_database) == SQLITE_OK);
database = database_open(path);
assert(database != NULL && database_migrate_to_latest(database));
database_close(database);
assert(sqlite3_open(path, &sqlite_database) == SQLITE_OK);
version = test_database_read_single_text(sqlite_database,
"SELECT value FROM metadata WHERE key='schema_version';");
legacy_sources = test_database_read_single_text(sqlite_database,
"SELECT COUNT(*) FROM preuve_entite_sources "
"WHERE source_kind='legacy_manual';");
assert(strcmp(version, "13") == 0);
assert(strcmp(legacy_sources, "1") == 0);
assert(sqlite3_close(sqlite_database) == SQLITE_OK);
assert(g_remove(path) == 0 && g_rmdir(directory) == 0);
g_free(legacy_sources);
g_free(version);
g_free(path);
g_free(directory);
}
int main(void)
{
test_database_initialize_valid_database();
@ -1406,6 +1454,7 @@ int main(void)
test_database_migrate_v1_to_v2();
test_database_migration_rollback();
test_database_migrate_v12_to_v13_preserves_legacy_link();
test_database_initialize_invalid_parameters();
test_database_initialize_missing_parent();

View file

@ -1,4 +1,5 @@
#include "core/eml_integration.h"
#include "dao/evidence_entity_dao.h"
#include "database/database.h"
#include "database/statement.h"
#include <assert.h>
@ -38,6 +39,20 @@ static char *read_observation_identifier(Database *database, const char *role)
return identifier;
}
static char *read_observation_entity_identifier(
Database *database, const char *observation_identifier)
{
DatabaseStatement *statement = database_statement_prepare(database,
"SELECT entity_id FROM evidence_entity_observations WHERE id=?;");
char *identifier = NULL;
assert(statement != NULL &&
database_statement_bind_text(statement, 1, observation_identifier));
assert(database_statement_step(statement) == DATABASE_STATEMENT_STEP_ROW);
assert(database_statement_column_text(statement, 0, &identifier));
database_statement_finalize(statement);
return identifier;
}
int main(void)
{
GError *error = NULL;
@ -82,13 +97,25 @@ int main(void)
&observations, &created, &reused, &error));
assert(created == 0 && reused == 1 && count_rows(database, "entites") == 1);
char *from_observation = read_observation_identifier(database, "from");
char *from_entity = read_observation_entity_identifier(
database, from_observation);
EvidenceEntityDao *link_dao = evidence_entity_dao_new(database, &error);
assert(link_dao != NULL && error == NULL);
/* Vérifie aussi la coexistence des propriétaires manuel et EML. */
assert(evidence_entity_dao_link(link_dao, evidence_id, from_entity, &error));
gboolean deleted = FALSE, shared = FALSE;
assert(eml_integration_remove_promotion(database, from_observation,
&deleted, &shared, &error));
assert(deleted && !shared);
assert(!deleted && shared);
assert(count_rows(database, "evidence_entity_observations") == 1);
assert(count_rows(database, "entites") == 0);
assert(count_rows(database, "entites") == 1);
assert(count_rows(database, "preuve_entites") == 1);
assert(evidence_entity_dao_unlink(link_dao, evidence_id, from_entity,
&error));
assert(count_rows(database, "preuve_entites") == 0);
execute_done(database, "DELETE FROM entites;");
evidence_entity_dao_free(link_dao);
g_free(from_entity);
assert(eml_integration_apply(database, evidence_id, proposals,
&observations, &created, &reused, &error));
assert(created == 1);
@ -128,6 +155,42 @@ int main(void)
assert(!deleted && shared);
assert(count_rows(database, "relations") == 1);
assert(count_rows(database, "evidence_entity_observations") == 2);
/* Reproduction exacte : rattachement manuel avant la promotion EML. */
execute_done(database,
"INSERT INTO entites(id,type_id,valeur,label,description,confiance,"
"created_at,updated_at,status) VALUES("
"'20000000-0000-4000-8000-000000000107',1,'manual@example.test',"
"'manual@example.test',NULL,50,'2026-07-28T08:00:00Z',"
"'2026-07-28T08:00:00Z','active');");
link_dao = evidence_entity_dao_new(database, &error);
assert(link_dao != NULL &&
evidence_entity_dao_link(link_dao, evidence_id,
"20000000-0000-4000-8000-000000000107", &error));
GPtrArray *manual_first = g_ptr_array_new_with_free_func(
(GDestroyNotify) eml_entity_proposal_free);
EmlEntityProposal *manual_first_proposal =
eml_entity_proposal_new_observation("email_address",
"manual@example.test", "manual@example.test", "to", "to", 1,
"confirmed", "header");
manual_first_proposal->promote_to_entity = TRUE;
g_ptr_array_add(manual_first, manual_first_proposal);
assert(eml_integration_apply(database, evidence_id, manual_first,
&observations, &created, &reused, &error));
assert(created == 0 && reused == 1);
char *manual_first_observation = read_observation_identifier(database, "to");
assert(eml_integration_remove_promotion(database, manual_first_observation,
&deleted, &shared, &error));
gboolean manual_link_exists = FALSE;
assert(!deleted && shared &&
evidence_entity_dao_exists(link_dao, evidence_id,
"20000000-0000-4000-8000-000000000107", &manual_link_exists,
&error));
assert(manual_link_exists);
evidence_entity_dao_free(link_dao);
g_free(manual_first_observation);
g_ptr_array_unref(manual_first);
g_free(reply_observation);
g_free(from_observation);
g_ptr_array_unref(proposals);

View file

@ -597,7 +597,7 @@ static void test_evidence_entity_dao_link_valid(void)
}
/**
* @brief Vérifie le refus d'une association dupliquée.
* @brief Vérifie l'idempotence d'une association manuelle.
*/
static void test_evidence_entity_dao_link_duplicate(void)
{
@ -637,7 +637,7 @@ static void test_evidence_entity_dao_link_duplicate(void)
assert(error == NULL);
assert(
!evidence_entity_dao_link(
evidence_entity_dao_link(
fixture.evidence_entity_dao,
evidence_identifier,
entity_identifier,
@ -645,10 +645,7 @@ static void test_evidence_entity_dao_link_duplicate(void)
)
);
test_evidence_entity_dao_assert_error(
error,
EVIDENCE_ENTITY_DAO_ERROR_CONSTRAINT
);
assert(error == NULL);
assert(
test_evidence_entity_dao_count_rows(
@ -656,10 +653,6 @@ static void test_evidence_entity_dao_link_duplicate(void)
) == 1
);
g_clear_error(
&error
);
test_evidence_entity_dao_fixture_clear(
&fixture
);