From db9be54a1a39a4a415f2e53890f7df65765596ca Mon Sep 17 00:00:00 2001 From: grayTerminal-sh Date: Tue, 28 Jul 2026 09:33:31 +0200 Subject: [PATCH] feat(email): finaliser le pivot EML forensique --- README.md | 39 +++++- database/schema_current.sql | 26 ++++ database/schema_v13.sql | 54 ++++++++ docs/ARCHITECTURE.md | 187 ++++++++++++++++++++----- docs/CONVENTIONS.md | 40 +++++- docs/DEPENDENCE.md | 11 +- docs/DEVELOPMENT.md | 75 ++++++++-- docs/ROADMAP.md | 56 ++++---- docs/testing/EML_PIVOT_MANUAL_TEST.md | 115 ++++++++-------- include/dao/evidence_entity_dao.h | 10 ++ include/database/schema.h | 1 + src/core/application.c | 17 +-- src/core/eml_integration.c | 39 ++---- src/core/extraction_drop_service.c | 7 +- src/dao/evidence_entity_dao.c | 191 +++++++++++++++++++------- src/database/database.c | 23 +++- src/database/schema.c | 6 + tests/test_database.c | 53 ++++++- tests/test_eml_integration.c | 67 ++++++++- tests/test_evidence_entity_dao.c | 13 +- 20 files changed, 781 insertions(+), 249 deletions(-) create mode 100644 database/schema_v13.sql diff --git a/README.md b/README.md index 5a16e3e..b563754 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/database/schema_current.sql b/database/schema_current.sql index 4622d58..9e0d0c2 100644 --- a/database/schema_current.sql +++ b/database/schema_current.sql @@ -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); diff --git a/database/schema_v13.sql b/database/schema_v13.sql new file mode 100644 index 0000000..d7048f7 --- /dev/null +++ b/database/schema_v13.sql @@ -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; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cb719d0..874e77e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. --- diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 7b42cfa..791fd06 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -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. diff --git a/docs/DEPENDENCE.md b/docs/DEPENDENCE.md index 7ac6d94..3096d1a 100644 --- a/docs/DEPENDENCE.md +++ b/docs/DEPENDENCE.md @@ -3,6 +3,9 @@ Ce fichier recense les outils utilisés par LabFy Investigation et leur installation. Il sera complété à chaque intégration d’un nouvel outil. +La migration SQLite V13 et la gestion des propriétaires de +`preuve_entites` n'ajoutent aucune dépendance système. + ## Principe d’installation 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 l’ordre. - diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d8cf68e..4a25ec2 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2be0780..7b5ea88 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. --- diff --git a/docs/testing/EML_PIVOT_MANUAL_TEST.md b/docs/testing/EML_PIVOT_MANUAL_TEST.md index da00526..f8aa5da 100644 --- a/docs/testing/EML_PIVOT_MANUAL_TEST.md +++ b/docs/testing/EML_PIVOT_MANUAL_TEST.md @@ -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 l’action compacte « Analyser l’e-mail » devient active. -6. Lancer l’action et observer « Analyse complète de l’e-mail » dans le - panneau d’activité. -7. Pendant un premier essai, annuler la tâche depuis ce panneau et vérifier - qu’aucun dialogue de succès ni aucune nouvelle entité n’apparaît. -8. Relancer l’analyse 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 l’IBAN commençant par `FR00` est indiqué comme donnée de - démonstration invalide et n’est pas proposé à l’intégration. -10. Vérifier que les valeurs sont sélectionnables et copiables. -11. Fermer avec « Rejeter et fermer » et confirmer l’absence 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 - n’est 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` n’est 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, qu’aucun nœud ni lien - `preuve_entites` n’est 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 l’enquête synthétique et vérifier que les - observations, rôles et origines sont toujours présents sans nœud. -17. Relancer l’analyse, cocher la conservation et « Promouvoir en entité » - pour une seule observation, confirmer et vérifier qu’un seul nœud - apparaît et que la fiche indique la promotion. -18. Répéter la promotion et vérifier qu’aucune observation ni entité n’est - dupliquée. -19. Dans « Observations extraites », utiliser « Retirer du graphe » sur - l’observation promue et confirmer. Vérifier que l’observation, son rôle - et sa provenance restent affichés avec « Graphe : Non ajoutée ». -20. Vérifier que le nœud disparaît lorsqu’il n’a aucune autre référence. -21. Promouvoir deux observations de rôles différents vers la même valeur - canonique, puis n’en retirer qu’une. Vérifier que l’entité partagée et - l’autre observation restent présentes. -22. Fermer puis rouvrir l’enquête synthétique et vérifier que tous les états - de promotion et de retrait persistent. -23. Recalculer l’inté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 l’extraction 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`. diff --git a/include/dao/evidence_entity_dao.h b/include/dao/evidence_entity_dao.h index d185efd..c8506d8 100644 --- a/include/dao/evidence_entity_dao.h +++ b/include/dao/evidence_entity_dao.h @@ -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. * diff --git a/include/database/schema.h b/include/database/schema.h index 8ee90cd..6e3e18e 100644 --- a/include/database/schema.h +++ b/include/database/schema.h @@ -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. diff --git a/src/core/application.c b/src/core/application.c index 096da29..52cfb2b 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -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; diff --git a/src/core/eml_integration.c b/src/core/eml_integration.c index fa0aa7a..1965508 100644 --- a/src/core/eml_integration.c +++ b/src/core/eml_integration.c @@ -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; } diff --git a/src/core/extraction_drop_service.c b/src/core/extraction_drop_service.c index 1a101ab..ab8e447 100644 --- a/src/core/extraction_drop_service.c +++ b/src/core/extraction_drop_service.c @@ -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); diff --git a/src/dao/evidence_entity_dao.c b/src/dao/evidence_entity_dao.c index 981951c..d52c23f 100644 --- a/src/dao/evidence_entity_dao.c +++ b/src/dao/evidence_entity_dao.c @@ -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( diff --git a/src/database/database.c b/src/database/database.c index c0bbbe7..9346b46 100644 --- a/src/database/database.c +++ b/src/database/database.c @@ -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 d’annuler 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( diff --git a/src/database/schema.c b/src/database/schema.c index 46eaa92..1e66945 100644 --- a/src/database/schema.c +++ b/src/database/schema.c @@ -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 ) diff --git a/tests/test_database.c b/tests/test_database.c index 201a834..7527f3f 100644 --- a/tests/test_database.c +++ b/tests/test_database.c @@ -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(); diff --git a/tests/test_eml_integration.c b/tests/test_eml_integration.c index 8d23752..fdc6288 100644 --- a/tests/test_eml_integration.c +++ b/tests/test_eml_integration.c @@ -1,4 +1,5 @@ #include "core/eml_integration.h" +#include "dao/evidence_entity_dao.h" #include "database/database.h" #include "database/statement.h" #include @@ -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); diff --git a/tests/test_evidence_entity_dao.c b/tests/test_evidence_entity_dao.c index c85a43c..117347f 100644 --- a/tests/test_evidence_entity_dao.c +++ b/tests/test_evidence_entity_dao.c @@ -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 );