diff --git a/Makefile b/Makefile index 8dd7b5b..aabdce5 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,10 @@ CC = gcc PKG_CONFIG = pkg-config +REQUIRED_PACKAGES = gtk4 sqlite3 libheif poppler-glib +ifeq ($(shell $(PKG_CONFIG) --exists $(REQUIRED_PACKAGES) && echo yes),) +$(error Dépendances de compilation manquantes : $(REQUIRED_PACKAGES). Voir docs/DEPENDENCE.md) +endif .DEFAULT_GOAL := all @@ -12,9 +16,9 @@ CFLAGS = -std=c17 \ -Iinclude \ -MMD \ -MP \ - $(shell $(PKG_CONFIG) --cflags gtk4 sqlite3) + $(shell $(PKG_CONFIG) --cflags gtk4 sqlite3 libheif poppler-glib) -LDFLAGS = $(shell $(PKG_CONFIG) --libs gtk4 sqlite3) -ljpeg +LDFLAGS = $(shell $(PKG_CONFIG) --libs gtk4 sqlite3 libheif poppler-glib) -ljpeg TEST_CFLAGS = -std=c17 \ -Wall \ @@ -126,12 +130,17 @@ TEST_SOCIAL_ACCOUNT_SERVICE := tests/test_social_account_service TEST_SOCIAL_PLATFORM := tests/test_social_platform TEST_PERSON_ENTITY_SERVICE := tests/test_person_entity_service TEST_EVIDENCE_PREVIEW := tests/test_evidence_preview +TEST_EVIDENCE_VIDEO_PREVIEW_CONTROLLER := tests/test_evidence_video_preview_controller TEST_EVIDENCE_SELECTION_MODEL := tests/test_evidence_selection_model TEST_PERSON_CREATION_GUARD := tests/test_person_creation_guard TEST_PERSON_DIALOG_LIFECYCLE := tests/test_person_dialog_lifecycle TEST_CREATE_PERSON_DIALOG_GTK := tests/test_create_person_dialog_gtk +TEST_EVIDENCE_PREVIEW_WIDGET_GTK := tests/test_evidence_preview_widget_gtk TEST_PERSON_CONFIRMATION_SUMMARY := tests/test_person_confirmation_summary TEST_PERSON_ROLE_ASSIGNMENT_DAO := tests/test_person_role_assignment_dao +TEST_PERSON_EVIDENCE_SELECTION := tests/test_person_evidence_selection +TEST_EVIDENCE_STAGING := tests/test_evidence_staging +TEST_PERSON_CREATION_COORDINATOR := tests/test_person_creation_coordinator TEST_EML_ANALYZER := tests/test_eml_analyzer TEST_EML_INTEGRATION := tests/test_eml_integration TEST_IBAN_ANALYZER := tests/test_iban_analyzer @@ -803,9 +812,15 @@ $(TEST_EVIDENCE_PREVIEW): tests/test_evidence_preview.c \ src/core/evidence_preview_task.c src/core/background_task.c \ src/core/task_manager.c \ src/core/evidence_integrity_verifier.c \ - src/core/file_hash.c src/models/evidence_record.c + src/core/file_hash.c src/core/eml_analyzer.c \ + src/core/eml_mime_extractor.c src/models/evidence_record.c $(CC) $(CFLAGS) -Wpedantic $^ -o $@ $(LDFLAGS) -ljpeg +$(TEST_EVIDENCE_VIDEO_PREVIEW_CONTROLLER): \ + tests/test_evidence_video_preview_controller.c \ + src/core/evidence_video_preview_controller.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) + $(TEST_EVIDENCE_SELECTION_MODEL): tests/test_evidence_selection_model.c \ src/models/evidence_selection_model.c src/models/evidence_record.c $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) @@ -821,16 +836,32 @@ $(TEST_PERSON_DIALOG_LIFECYCLE): tests/test_person_dialog_lifecycle.c \ $(TEST_CREATE_PERSON_DIALOG_GTK): tests/test_create_person_dialog_gtk.c \ src/views/create_person_dialog.c src/core/person_dialog_lifecycle.c \ src/core/person_confirmation_summary.c \ + src/core/evidence_staging.c src/core/evidence_staging_task.c \ src/core/evidence_preview_task.c src/core/evidence_preview.c \ + src/core/eml_analyzer.c src/core/eml_mime_extractor.c \ + src/core/evidence_video_preview_controller.c \ + src/widgets/evidence_preview_widget.c \ src/core/evidence_integrity_verifier.c src/core/file_hash.c \ src/core/background_task.c src/core/task_manager.c \ src/models/evidence_selection_model.c src/models/evidence_record.c \ - src/models/person_role_assignment.c + src/models/person_role_assignment.c \ + src/models/person_evidence_selection.c $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) +$(TEST_EVIDENCE_PREVIEW_WIDGET_GTK): \ + tests/test_evidence_preview_widget_gtk.c \ + src/widgets/evidence_preview_widget.c \ + src/core/evidence_preview_task.c src/core/evidence_preview.c \ + src/core/eml_analyzer.c src/core/eml_mime_extractor.c \ + src/core/evidence_video_preview_controller.c \ + src/core/evidence_integrity_verifier.c src/core/file_hash.c \ + src/core/background_task.c src/core/task_manager.c + $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) -ljpeg + $(TEST_PERSON_CONFIRMATION_SUMMARY): \ tests/test_person_confirmation_summary.c \ - src/core/person_confirmation_summary.c src/models/evidence_record.c + src/core/person_confirmation_summary.c src/models/evidence_record.c \ + src/models/person_evidence_selection.c $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) $(TEST_PERSON_ROLE_ASSIGNMENT_DAO): \ @@ -842,6 +873,28 @@ $(TEST_PERSON_ROLE_ASSIGNMENT_DAO): \ src/database/transaction.c src/database/error.c $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) -lsqlite3 +$(TEST_PERSON_EVIDENCE_SELECTION): \ + tests/test_person_evidence_selection.c \ + src/models/person_evidence_selection.c src/models/evidence_record.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) + +$(TEST_EVIDENCE_STAGING): tests/test_evidence_staging.c \ + src/core/evidence_staging.c src/core/file_hash.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) + +$(TEST_PERSON_CREATION_COORDINATOR): \ + tests/test_person_creation_coordinator.c \ + src/core/person_creation_coordinator.c src/core/evidence_staging.c \ + src/core/file_hash.c \ + src/models/person_evidence_selection.c src/models/evidence_record.c \ + src/models/person_role_assignment.c src/models/entity_record.c \ + src/models/evidence_observation.c \ + src/dao/entity_dao.c src/dao/evidence_dao.c \ + src/dao/evidence_entity_dao.c src/dao/person_role_assignment_dao.c \ + src/database/database.c src/database/schema.c src/database/statement.c \ + src/database/transaction.c src/database/error.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) -lsqlite3 + $(TEST_EML_ANALYZER): tests/test_eml_analyzer.c src/core/eml_analyzer.c $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) @@ -949,12 +1002,17 @@ test: \ $(TEST_SOCIAL_PLATFORM) \ $(TEST_PERSON_ENTITY_SERVICE) \ $(TEST_EVIDENCE_PREVIEW) \ + $(TEST_EVIDENCE_VIDEO_PREVIEW_CONTROLLER) \ $(TEST_EVIDENCE_SELECTION_MODEL) \ $(TEST_PERSON_CREATION_GUARD) \ $(TEST_PERSON_DIALOG_LIFECYCLE) \ $(TEST_CREATE_PERSON_DIALOG_GTK) \ + $(TEST_EVIDENCE_PREVIEW_WIDGET_GTK) \ $(TEST_PERSON_CONFIRMATION_SUMMARY) \ $(TEST_PERSON_ROLE_ASSIGNMENT_DAO) \ + $(TEST_PERSON_EVIDENCE_SELECTION) \ + $(TEST_EVIDENCE_STAGING) \ + $(TEST_PERSON_CREATION_COORDINATOR) \ $(TEST_EML_ANALYZER) \ $(TEST_EML_INTEGRATION) \ $(TEST_IBAN_ANALYZER) \ @@ -1030,12 +1088,17 @@ test: \ @$(TEST_SOCIAL_PLATFORM) @$(TEST_PERSON_ENTITY_SERVICE) @$(TEST_EVIDENCE_PREVIEW) + @$(TEST_EVIDENCE_VIDEO_PREVIEW_CONTROLLER) @$(TEST_EVIDENCE_SELECTION_MODEL) @$(TEST_PERSON_CREATION_GUARD) @$(TEST_PERSON_DIALOG_LIFECYCLE) @$(TEST_CREATE_PERSON_DIALOG_GTK) + @$(TEST_EVIDENCE_PREVIEW_WIDGET_GTK) @$(TEST_PERSON_CONFIRMATION_SUMMARY) @$(TEST_PERSON_ROLE_ASSIGNMENT_DAO) + @$(TEST_PERSON_EVIDENCE_SELECTION) + @$(TEST_EVIDENCE_STAGING) + @$(TEST_PERSON_CREATION_COORDINATOR) @$(TEST_EML_ANALYZER) @$(TEST_EML_INTEGRATION) @$(TEST_IBAN_ANALYZER) @@ -1118,12 +1181,17 @@ clean: $(TEST_SOCIAL_PLATFORM) \ $(TEST_PERSON_ENTITY_SERVICE) \ $(TEST_EVIDENCE_PREVIEW) \ + $(TEST_EVIDENCE_VIDEO_PREVIEW_CONTROLLER) \ $(TEST_EVIDENCE_SELECTION_MODEL) \ $(TEST_PERSON_CREATION_GUARD) \ $(TEST_PERSON_DIALOG_LIFECYCLE) \ $(TEST_CREATE_PERSON_DIALOG_GTK) \ + $(TEST_EVIDENCE_PREVIEW_WIDGET_GTK) \ $(TEST_PERSON_CONFIRMATION_SUMMARY) \ $(TEST_PERSON_ROLE_ASSIGNMENT_DAO) \ + $(TEST_PERSON_EVIDENCE_SELECTION) \ + $(TEST_EVIDENCE_STAGING) \ + $(TEST_PERSON_CREATION_COORDINATOR) \ $(TEST_EML_ANALYZER) \ $(TEST_EML_INTEGRATION) \ $(TEST_EXTRACTION_DROP_SERVICE) \ diff --git a/README.md b/README.md index b563754..9ac78d0 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,14 @@ Le socle actuel comprend notamment : 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. +- assistant de création d’une personne avec sélection multiple de preuves + existantes et import multiple différé : les nouveaux fichiers sont copiés + dans un staging temporaire, qualifiés individuellement et ne deviennent + définitifs qu’après confirmation globale ; +- rattachement transactionnel de toutes les preuves retenues, avec rollback + SQLite et suppression compensatoire des copies définitives en cas d’échec. +- aperçu asynchrone contrôlé PNG/JPEG, HEIC/HEIF, MP4/MOV, texte, EML et + première page PDF, sans OCR ni écriture SQLite ; ### Pivot EML diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b9ee396..1098df2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,25 @@ # Architecture +`EvidencePreviewWidget` est l’adaptateur GTK unique de `EvidencePreview` pour +`CreatePersonDialog` et la fiche directe. Il encapsule les états, le rendu +EML/PDF/image/texte/vidéo, l’annulation, la génération, la garde de session et +l’arrêt du média. Les deux écrans lui transmettent uniquement une source +interne ou de staging accompagnée du SHA-256 attendu. + +## Aperçu contrôlé multi-format + +`EvidencePreview` vérifie l’empreinte du fichier interne ou de staging, +détecte son contenu et produit un résultat borné sans GTK. `BackgroundTask` +travaille hors du thread principal ; `CreatePersonDialog` y crée ensuite +textures, buffers et médias, rejette les générations périmées et arrête tout +média au changement ou à la fermeture. L’aperçu ne persiste rien. + +Le mode aperçu de `EmlMimeExtractor` réutilise le parcours récursif et les +décodages RFC 2047/2231 sans écrire les pièces jointes. Il préfère +`text/plain`, transforme un éventuel HTML en texte inerte et retourne +l’inventaire possédé. Le contrôleur vidéo indépendant de GTK orchestre +pause, retour au début, détachement et libération via des actions injectées. + > **Version :** 3.1 > **Dernière mise à jour :** 2026-07-28 > **Schéma SQLite courant :** V14 @@ -406,6 +426,25 @@ leur preuve source. ## 10. Tâches asynchrones +### 10.1 Création d’une personne avec preuves + +`PersonEvidenceSelection` possède la collection ordonnée des preuves retenues +et distingue les preuves existantes des copies de staging. Il ne dépend ni de +GTK ni de SQLite. `EvidenceStaging` refuse les fichiers spéciaux et liens +symboliques, calcule l’empreinte de la source et de sa copie, détecte le MIME +et nettoie les temporaires lors d’un retrait ou d’une annulation. + +`PersonCreationCoordinator` valide toutes les empreintes avant écriture, crée +la personne, importe les nouvelles preuves et rattache toute la collection +dans une transaction SQLite. Les copies définitives réalisées avant un échec +sont supprimées après rollback. `PersonCreationTask` exécute cette orchestration +hors du thread GTK. Un changement de session annule les tâches et interdit le +rafraîchissement d’une autre enquête. + +Les aperçus PNG/JPEG utilisent exclusivement le fichier interne d’une preuve +existante ou sa copie de staging. PDF, vidéo, EML et autres formats ne sont +pas analysés dans cette tranche. + Les tâches longues utilisent l'infrastructure de tâche d'arrière-plan et le gestionnaire de tâches. diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 791fd06..a03e3f3 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -1,5 +1,13 @@ # Conventions de développement +Toute vue présentant une preuve réutilise `EvidencePreviewWidget` : aucun +parsing EML/PDF ou cycle média ne doit être dupliqué dans un écran. Les +callbacks asynchrones utilisent une génération et une référence faible. + +Un aperçu utilise seulement la copie contrôlée et vérifiée. La signature et +le contenu priment sur le MIME historique et l’extension. Son résultat ne +contient aucun objet GTK et un média ne démarre jamais automatiquement. + > **Version :** 2.1 > **Dernière mise à jour :** 2026-07-28 > **Projet :** Labfy Investigation @@ -265,7 +273,7 @@ présentation graphique. La version courante du schéma est : ```text -V13 +V14 ``` Les scripts versionnés sont conservés dans : @@ -391,6 +399,10 @@ Interdit : Une entrée externe peut être absolue pendant l'import, mais le chemin enregistré dans l'enquête doit respecter le modèle prévu par le schéma. +Un import intégré à un assistant utilise d’abord une copie de staging +temporaire vérifiée. Le fichier source n’est ni renommé ni modifié et ne doit +plus être relu pour l’aperçu ou l’import final après préparation. + ### 7.2 Immutabilité des preuves originales Une preuve originale n'est jamais modifiée. diff --git a/docs/DEPENDENCE.md b/docs/DEPENDENCE.md index 0953186..67418e8 100644 --- a/docs/DEPENDENCE.md +++ b/docs/DEPENDENCE.md @@ -1,8 +1,19 @@ # Dépendances externes -`libjpeg` est utilisée pour le décodage borné des aperçus JPEG du ticket #109. -PNG est décodé avec Cairo. Aucun outil documentaire externe n’est lancé par -l’aperçu et aucun dérivé n’est persisté. +`libjpeg` décode JPEG, `libheif` HEIC/HEIF et Poppler GLib rend la première +page PDF du ticket #109. PNG utilise Cairo et la vidéo `GtkMediaFile` avec le +backend GTK/GStreamer. Aucun outil documentaire n’est lancé et aucun dérivé +n’est persisté. + +| Fonction | Arch Linux | Ubuntu/Debian | +| --- | --- | --- | +| HEIC/HEIF | `libheif` | `libheif-dev` | +| PDF natif | `poppler-glib` | `libpoppler-glib-dev` | +| Vidéo GTK | `gst-plugins-base`, `gst-plugins-good` | `gstreamer1.0-plugins-base`, `gstreamer1.0-plugins-good` | + +Les codecs vidéo supplémentaires restent optionnels. +Le Makefile vérifie explicitement GTK4, SQLite, libheif et Poppler GLib avant +compilation et renvoie vers ce document si une dépendance manque. Ce fichier recense les outils utilisés par LabFy Investigation et leur installation. Il sera complété à chaque intégration d’un nouvel outil. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 121bbf1..3061149 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,5 +1,21 @@ # Guide de développement +La validation ciblée du composant partagé comprend +`test_evidence_preview_widget_gtk`, `test_evidence_preview`, +`test_evidence_video_preview_controller` et +`test_create_person_dialog_gtk`, ce dernier sous +`G_DEBUG=fatal-criticals`. + +Les tests d’aperçu génèrent exclusivement des fixtures `SPECIMEN` temporaires. +Ils couvrent dispatch, limites, annulation et intégrité, puis le parcours GTK +réel avec `G_DEBUG=fatal-criticals`. Aucun aperçu ne lance OCR ou SQLite. + +La fixture HEIC est une matrice RGB `SPECIMEN` encodée en mémoire par +libheif/HEVC. Le test HEIF remplace uniquement la marque compatible du +conteneur synthétique. Les transformations d’orientation libheif restent +appliquées par le décodage standard ; seule l’image principale est rendue et +le nombre d’images de premier niveau est exposé. + > **Version :** 2.1 > **Dernière mise à jour :** 2026-07-28 > **Projet :** Labfy Investigation @@ -240,6 +256,21 @@ make -j8 \ ./tests/test_database ``` +Tests ciblés de la tranche 2 du ticket #109 : + +```sh +make -j8 \ + tests/test_person_evidence_selection \ + tests/test_evidence_staging \ + tests/test_person_creation_coordinator \ + tests/test_person_confirmation_summary \ + tests/test_evidence_preview \ + tests/test_create_person_dialog_gtk +``` + +Ces tests utilisent uniquement des fichiers `SPECIMEN`, des répertoires +temporaires et des bases SQLite temporaires. + 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ff6ba73..bf9e371 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,13 +1,21 @@ # Roadmap +La tranche 2 partage désormais l’aperçu entre l’assistant personne et la +fiche directe. La validation manuelle des codecs installés reste nécessaire ; +elle ne vaut pas achèvement du ticket #109 et n’inclut aucun OCR. + +L’aperçu contrôlé du ticket #109 couvre PNG/JPEG, HEIC/HEIF, MP4/MOV, texte, +EML passif et première page PDF. OCR et analyse avancée restent hors périmètre. + > **Dernière mise à jour :** 2026-07-28 > **État du projet :** développement actif > **Schéma SQLite courant :** V14 -La tranche 1 du ticket #109 couvre les rôles contextuels multiples, la -sélection d’une preuve existante, la création transactionnelle et la garde de -session. Import intégré, PDF, vidéo, EML avancé, OCR et documents d’identité -restent hors périmètre. +Les tranches 1 et 2 du ticket #109 couvrent les rôles contextuels multiples, +la sélection de plusieurs preuves existantes, le staging et l’import multiple +différé, ainsi que la création transactionnelle avec compensation des +fichiers. OCR, extraction de documents d’identité et analyses documentaires +avancées restent hors périmètre. > **Usage opérationnel :** non prêt pour la production --- diff --git a/docs/TEST_MANUEL_TICKET_109_TRANCHE_1.md b/docs/TEST_MANUEL_TICKET_109_TRANCHE_1.md index 17e4220..2beb1ee 100644 --- a/docs/TEST_MANUEL_TICKET_109_TRANCHE_1.md +++ b/docs/TEST_MANUEL_TICKET_109_TRANCHE_1.md @@ -1,5 +1,11 @@ # Test manuel — ticket #109, tranche 1 +> Le parcours « Preuve » est désormais nommé « Preuves » et accepte une +> sélection multiple ainsi qu’un import différé. Pour le scénario historique +> de tranche 1, sélectionner une seule preuve existante avec « Ajouter à la +> sélection ». Le protocole étendu se trouve dans +> `docs/TEST_MANUEL_TICKET_109_TRANCHE_2.md`. + Utiliser uniquement une enquête de démonstration et des fichiers synthétiques marqués `SPECIMEN`. diff --git a/docs/TEST_MANUEL_TICKET_109_TRANCHE_2.md b/docs/TEST_MANUEL_TICKET_109_TRANCHE_2.md new file mode 100644 index 0000000..13d7caa --- /dev/null +++ b/docs/TEST_MANUEL_TICKET_109_TRANCHE_2.md @@ -0,0 +1,73 @@ +# Test manuel — ticket #109, tranche 2 + +Comparer le même EML synthétique dans la fiche directe et dans l’assistant : +From, To, Cc, Date, Subject, Message-ID, corps et inventaire détaillé des +pièces jointes doivent être identiques. Tester ensuite PNG/JPEG/HEIC/HEIF, +PDF, MP4/MOV et texte, les changements rapides, la fermeture pendant +chargement ou vidéo, la réouverture et l’ouverture d’une fiche personne. +Vérifier l’absence de critique GTK, d’écriture SQLite et d’OCR. + +Utiliser exclusivement une enquête temporaire et des fichiers synthétiques +dont le nom ou le contenu porte `SPECIMEN`. + +1. Créer une enquête temporaire et y importer préalablement deux preuves + synthétiques PNG/JPEG. +2. Ouvrir « Ajouter une personne », renseigner une désignation fictive et + sélectionner plusieurs rôles. +3. Dans « Preuves », rechercher puis ajouter les deux preuves existantes. + Vérifier qu’un second ajout est refusé sans modifier la preuve. +4. Choisir « Importer des fichiers » et sélectionner simultanément un PNG, un + JPEG et un PDF synthétiques. Vérifier que rien n’apparaît encore dans + `01_Preuves_Originales` ni dans SQLite. +5. Activer chaque élément retenu. Vérifier les aperçus PNG/JPEG et le message + explicite d’indisponibilité du PDF. +6. Modifier individuellement les types métier, naviguer en arrière puis en + avant et vérifier la conservation de l’état. +7. Retirer une preuve existante et un fichier nouveau. Vérifier que la preuve + existante reste dans l’enquête et que la copie de staging retirée disparaît. +8. Sélectionner deux fois le même fichier, puis deux fichiers de même contenu. + Vérifier le message de doublon et l’unicité de la sélection. +9. Ajouter un fichier identique à une preuve existante. Vérifier la + réutilisation explicite de cette preuve. +10. Atteindre Confirmation et contrôler la liste complète, les origines, + types, MIME, tailles, empreintes abrégées et compteurs. +11. Annuler puis vérifier l’absence de nouvelle personne, preuve, rôle, + rattachement et copie définitive, ainsi que le nettoyage du staging. +12. Recommencer, puis fermer la fenêtre pendant le staging. Vérifier la + fermeture propre, le nettoyage et le maintien de l’application. +13. Recommencer et changer d’enquête pendant une préparation ou une création. + Vérifier l’annulation, l’absence d’écriture dans la nouvelle enquête et + l’absence de rafraîchissement tardif. +14. Confirmer normalement. Vérifier que toutes les preuves sont rattachées à + la personne avec une source `manual`, puis rouvrir l’enquête. +15. Comparer les empreintes des fichiers sources avant et après le parcours. + Elles doivent être identiques. +16. Vérifier qu’aucun OCR ni outil documentaire avancé n’a été lancé. +17. Ouvrir la liste des preuves disponibles, choisir successivement deux + preuves, revenir à « Aucune preuve associée », activer une recherche et un + filtre, puis sélectionner de nouveau une preuve. Vérifier l’absence de + crash et de critique GTK. +18. Ajouter une image JPEG historique synthétique dont le MIME enregistré est + NULL et le type métier `document`. Vérifier que le contenu JPEG intègre est + détecté, que l’aperçu apparaît et que « MIME détecté : image/jpeg » est + affiché sans modification SQLite. +19. Renommer un fichier texte synthétique avec l’extension `.jpg`, puis + vérifier que l’aperçu est refusé malgré l’extension. Refaire avec un MIME + historique `image/jpeg` incohérent et vérifier le même refus. +20. Modifier le type individuel de l’image de `document` vers `email`. + Vérifier immédiatement la même valeur dans la ligne retenue, le sélecteur, + les métadonnées et le résumé après un aller-retour entre les étapes. + +21. Tester des fixtures PNG, JPEG, HEIC, HEIF, MP4, MOV, EML, TXT, CSV, LOG, + Markdown et PDF. Vérifier l’image, le lecteur sans lecture automatique, + le texte passif, les en-têtes EML et la première page PDF. +22. Tester format invalide, extension trompeuse et fichier trop volumineux. +23. Changer rapidement de preuve et vérifier le rejet tardif et l’arrêt vidéo. +24. Fermer puis changer d’enquête pendant un aperçu ; vérifier l’annulation. +25. Vérifier les empreintes inchangées et l’absence d’OCR, d’accès distant et + d’écriture SQLite pendant l’aperçu. +26. Avec un EML multipart synthétique, vérifier que le corps `text/plain` est + préféré au HTML et que nom, MIME, taille, disposition et Content-ID des + pièces jointes sont visibles sans fichier extrait persistant. + +Limites : aucun OCR ni analyse avancée. La vidéo dépend des codecs GStreamer. diff --git a/include/core/eml_mime_extractor.h b/include/core/eml_mime_extractor.h index c59bb77..961fdcf 100644 --- a/include/core/eml_mime_extractor.h +++ b/include/core/eml_mime_extractor.h @@ -49,6 +49,9 @@ typedef struct EmlMimeResult { GPtrArray *attachments; /**< Tableau de EmlAttachment* */ GPtrArray *warnings; /**< Avertissements d'extraction */ + char *body_text; /**< Corps choisi pour un aperçu passif. */ + gboolean body_from_html; /**< VRAI si un repli HTML a été utilisé. */ + gboolean body_truncated; /**< VRAI si le corps a été borné. */ } EmlMimeResult; /** @@ -95,6 +98,10 @@ EmlMimeResult *eml_mime_extract_attachments_cancellable( GError **error ); +/** Analyse MIME sans écrire les contenus décodés sur disque. */ +EmlMimeResult *eml_mime_build_preview(const char *eml_path, + GCancellable *cancellable, GError **error); + G_END_DECLS #endif /* LABFY_INVESTIGATION_EML_MIME_EXTRACTOR_H */ diff --git a/include/core/evidence_preview.h b/include/core/evidence_preview.h index 34abe10..f13075e 100644 --- a/include/core/evidence_preview.h +++ b/include/core/evidence_preview.h @@ -7,6 +7,16 @@ G_BEGIN_DECLS #define EVIDENCE_PREVIEW_MAX_DIMENSION 12000 #define EVIDENCE_PREVIEW_MAX_PIXELS 40000000U #define EVIDENCE_PREVIEW_MAX_EDGE 1024 +#define EVIDENCE_PREVIEW_MAX_TEXT_BYTES (1024U * 1024U) +typedef enum { + EVIDENCE_PREVIEW_KIND_IMAGE, + EVIDENCE_PREVIEW_KIND_VIDEO, + EVIDENCE_PREVIEW_KIND_TEXT, + EVIDENCE_PREVIEW_KIND_EMAIL, + EVIDENCE_PREVIEW_KIND_PDF, + EVIDENCE_PREVIEW_KIND_UNSUPPORTED, + EVIDENCE_PREVIEW_KIND_ERROR +} EvidencePreviewKind; typedef struct { char *investigation_root_path; char *evidence_identifier; @@ -16,11 +26,23 @@ typedef struct { guint64 request_generation; } EvidencePreviewRequest; typedef struct { + EvidencePreviewKind kind; char *evidence_identifier; guint64 request_generation; GBytes *png_bytes; gint width; gint height; + char *effective_mime_type; + char *effective_format; + char *display_name; + char *controlled_path; + char *text; + char *message; + guint64 size_bytes; + guint item_count; + gboolean integrity_valid; + gboolean preview_available; + gboolean truncated; } EvidencePreviewResult; EvidencePreviewRequest *evidence_preview_request_new(const char *root, const char *evidence_identifier, const char *relative_path, @@ -32,5 +54,6 @@ EvidencePreviewResult *evidence_preview_load( void evidence_preview_result_free(EvidencePreviewResult *result); gboolean evidence_preview_result_matches(const EvidencePreviewResult *result, const char *evidence_identifier, guint64 generation); +gboolean evidence_preview_dimensions_are_safe(gint width, gint height); G_END_DECLS #endif diff --git a/include/core/evidence_staging.h b/include/core/evidence_staging.h new file mode 100644 index 0000000..76f641d --- /dev/null +++ b/include/core/evidence_staging.h @@ -0,0 +1,35 @@ +#ifndef LABFY_INVESTIGATION_EVIDENCE_STAGING_H +#define LABFY_INVESTIGATION_EVIDENCE_STAGING_H + +#include +#include + +G_BEGIN_DECLS + +#define EVIDENCE_STAGING_MAX_BYTES (50U * 1024U * 1024U) + +typedef struct EvidenceStaging EvidenceStaging; +typedef struct { + char *source_path; + char *staging_path; + char *original_name; + char *mime_type; + char *suggested_type; + char *sha256; + char *prepared_at; + guint64 size_bytes; +} EvidenceStagingResult; + +EvidenceStaging *evidence_staging_new(GError **error); +EvidenceStaging *evidence_staging_ref(EvidenceStaging *staging); +void evidence_staging_free(EvidenceStaging *staging); +const char *evidence_staging_get_directory(const EvidenceStaging *staging); +EvidenceStagingResult *evidence_staging_prepare(EvidenceStaging *staging, + const char *source_path, GCancellable *cancellable, GError **error); +void evidence_staging_result_free(EvidenceStagingResult *result); +gboolean evidence_staging_remove(EvidenceStaging *staging, + const char *staging_path, GError **error); +gboolean evidence_staging_cleanup(EvidenceStaging *staging, GError **error); + +G_END_DECLS +#endif diff --git a/include/core/evidence_staging_task.h b/include/core/evidence_staging_task.h new file mode 100644 index 0000000..4e82181 --- /dev/null +++ b/include/core/evidence_staging_task.h @@ -0,0 +1,14 @@ +#ifndef LABFY_INVESTIGATION_EVIDENCE_STAGING_TASK_H +#define LABFY_INVESTIGATION_EVIDENCE_STAGING_TASK_H + +#include "core/background_task.h" +#include "core/evidence_staging.h" +#include "core/task_manager.h" + +G_BEGIN_DECLS +BackgroundTask *evidence_staging_task_start(TaskManager *manager, + EvidenceStaging *staging, const GPtrArray *source_paths, + BackgroundTaskCompletionCallback callback, gpointer callback_data, + GDestroyNotify callback_data_destroy, GError **error); +G_END_DECLS +#endif diff --git a/include/core/evidence_video_preview_controller.h b/include/core/evidence_video_preview_controller.h new file mode 100644 index 0000000..30c57be --- /dev/null +++ b/include/core/evidence_video_preview_controller.h @@ -0,0 +1,25 @@ +#ifndef LABFY_EVIDENCE_VIDEO_PREVIEW_CONTROLLER_H +#define LABFY_EVIDENCE_VIDEO_PREVIEW_CONTROLLER_H +#include +G_BEGIN_DECLS +typedef struct EvidenceVideoPreviewController EvidenceVideoPreviewController; +typedef struct { + void (*pause)(gpointer media); + void (*seek_start)(gpointer media); + void (*detach)(gpointer owner); + void (*release)(gpointer media); +} EvidenceVideoPreviewActions; +EvidenceVideoPreviewController *evidence_video_preview_controller_new( + gpointer owner, const EvidenceVideoPreviewActions *actions); +void evidence_video_preview_controller_replace( + EvidenceVideoPreviewController *controller, gpointer media); +void evidence_video_preview_controller_stop( + EvidenceVideoPreviewController *controller); +void evidence_video_preview_controller_free( + EvidenceVideoPreviewController *controller); +void evidence_video_preview_controller_abandon_owner( + EvidenceVideoPreviewController *controller); +gboolean evidence_video_preview_controller_has_media( + const EvidenceVideoPreviewController *controller); +G_END_DECLS +#endif diff --git a/include/core/person_confirmation_summary.h b/include/core/person_confirmation_summary.h index 83e0a9a..c2aca26 100644 --- a/include/core/person_confirmation_summary.h +++ b/include/core/person_confirmation_summary.h @@ -2,6 +2,7 @@ #define LABFY_INVESTIGATION_PERSON_CONFIRMATION_SUMMARY_H #include "models/evidence_record.h" +#include "models/person_evidence_selection.h" #include G_BEGIN_DECLS @@ -10,6 +11,11 @@ char *person_confirmation_summary_build(const char *designation, const char *declared_name, const char *pseudonym, const char *identification_status, gint confidence, const char *notes, const GPtrArray *role_labels, const EvidenceRecord *evidence); +char *person_confirmation_summary_build_multiple(const char *designation, + const char *declared_name, const char *pseudonym, + const char *identification_status, gint confidence, const char *notes, + const GPtrArray *role_labels, + const PersonEvidenceSelection *selection); G_END_DECLS diff --git a/include/core/person_creation_coordinator.h b/include/core/person_creation_coordinator.h new file mode 100644 index 0000000..d2cb919 --- /dev/null +++ b/include/core/person_creation_coordinator.h @@ -0,0 +1,24 @@ +#ifndef LABFY_INVESTIGATION_PERSON_CREATION_COORDINATOR_H +#define LABFY_INVESTIGATION_PERSON_CREATION_COORDINATOR_H + +#include "core/person_entity_service.h" +#include "models/person_evidence_selection.h" +#include + +G_BEGIN_DECLS + +typedef struct { + char *person_identifier; + GPtrArray *evidence_identifiers; +} PersonCreationCoordinatorResult; + +PersonCreationCoordinatorResult *person_creation_coordinator_execute( + Database *database, const char *investigation_root_path, + const PersonEntityInput *person, + const PersonEvidenceSelection *selection, + GCancellable *cancellable, GError **error); +void person_creation_coordinator_result_free( + PersonCreationCoordinatorResult *result); + +G_END_DECLS +#endif diff --git a/include/core/person_creation_task.h b/include/core/person_creation_task.h new file mode 100644 index 0000000..80fa1d1 --- /dev/null +++ b/include/core/person_creation_task.h @@ -0,0 +1,20 @@ +#ifndef LABFY_INVESTIGATION_PERSON_CREATION_TASK_H +#define LABFY_INVESTIGATION_PERSON_CREATION_TASK_H + +#include "core/background_task.h" +#include "core/person_creation_coordinator.h" +#include "core/task_manager.h" + +G_BEGIN_DECLS +typedef struct PersonCreationTaskRequest PersonCreationTaskRequest; +PersonCreationTaskRequest *person_creation_task_request_new( + const char *database_path, const char *investigation_root_path, + const PersonEntityInput *person, + const PersonEvidenceSelection *selection); +void person_creation_task_request_free(PersonCreationTaskRequest *request); +BackgroundTask *person_creation_task_start(TaskManager *manager, + const PersonCreationTaskRequest *request, + BackgroundTaskCompletionCallback callback, gpointer callback_data, + GDestroyNotify callback_data_destroy, GError **error); +G_END_DECLS +#endif diff --git a/include/dao/evidence_dao.h b/include/dao/evidence_dao.h index 9d0c38e..1043ccc 100644 --- a/include/dao/evidence_dao.h +++ b/include/dao/evidence_dao.h @@ -186,5 +186,7 @@ gboolean evidence_dao_count( guint64 *out_count, GError **error ); +char *evidence_dao_find_identifier_by_sha256(EvidenceDao *evidence_dao, + const char *sha256, GError **error); #endif diff --git a/include/models/evidence_selection_model.h b/include/models/evidence_selection_model.h index 408545f..29c345d 100644 --- a/include/models/evidence_selection_model.h +++ b/include/models/evidence_selection_model.h @@ -19,5 +19,9 @@ const EvidenceRecord *evidence_selection_model_get_selected( const EvidenceSelectionModel *model); GPtrArray *evidence_selection_model_list_type_codes( const EvidenceSelectionModel *model); +const EvidenceRecord *evidence_selection_model_find_by_sha256( + const EvidenceSelectionModel *model, const char *sha256); +const EvidenceRecord *evidence_selection_model_find_by_identifier( + const EvidenceSelectionModel *model, const char *identifier); G_END_DECLS #endif diff --git a/include/models/person_evidence_selection.h b/include/models/person_evidence_selection.h new file mode 100644 index 0000000..f6b00e8 --- /dev/null +++ b/include/models/person_evidence_selection.h @@ -0,0 +1,87 @@ +#ifndef LABFY_INVESTIGATION_PERSON_EVIDENCE_SELECTION_H +#define LABFY_INVESTIGATION_PERSON_EVIDENCE_SELECTION_H + +#include "models/evidence_record.h" +#include + +G_BEGIN_DECLS + +typedef enum { + PERSON_EVIDENCE_ORIGIN_EXISTING, + PERSON_EVIDENCE_ORIGIN_STAGED, + PERSON_EVIDENCE_ORIGIN_IMPORTED +} PersonEvidenceOrigin; + +typedef enum { + PERSON_EVIDENCE_STATE_PREPARING, + PERSON_EVIDENCE_STATE_READY, + PERSON_EVIDENCE_STATE_DUPLICATE, + PERSON_EVIDENCE_STATE_INVALID, + PERSON_EVIDENCE_STATE_ERROR, + PERSON_EVIDENCE_STATE_CANCELLED +} PersonEvidenceState; + +typedef struct PersonEvidenceSelection PersonEvidenceSelection; +typedef struct PersonEvidenceSelectionItem PersonEvidenceSelectionItem; + +PersonEvidenceSelection *person_evidence_selection_new(void); +PersonEvidenceSelection *person_evidence_selection_copy( + const PersonEvidenceSelection *selection); +void person_evidence_selection_free(PersonEvidenceSelection *selection); +guint person_evidence_selection_get_count( + const PersonEvidenceSelection *selection); +const PersonEvidenceSelectionItem *person_evidence_selection_get( + const PersonEvidenceSelection *selection, guint index); +const PersonEvidenceSelectionItem *person_evidence_selection_get_active( + const PersonEvidenceSelection *selection); +gboolean person_evidence_selection_set_active( + PersonEvidenceSelection *selection, const char *item_identifier); +gboolean person_evidence_selection_add_existing( + PersonEvidenceSelection *selection, const EvidenceRecord *record, + GError **error); +gboolean person_evidence_selection_add_staged( + PersonEvidenceSelection *selection, const char *source_path, + const char *staging_path, const char *original_name, + const char *mime_type, const char *type_identifier, guint64 size_bytes, + const char *sha256, const char *description, const char *prepared_at, + GError **error); +gboolean person_evidence_selection_remove(PersonEvidenceSelection *selection, + const char *item_identifier); +gboolean person_evidence_selection_set_type(PersonEvidenceSelection *selection, + const char *item_identifier, const char *type_identifier); +gboolean person_evidence_selection_is_confirmable( + const PersonEvidenceSelection *selection); +const char *person_evidence_selection_find_existing_by_sha256( + const PersonEvidenceSelection *selection, const char *sha256); + +const char *person_evidence_selection_item_get_identifier( + const PersonEvidenceSelectionItem *item); +PersonEvidenceOrigin person_evidence_selection_item_get_origin( + const PersonEvidenceSelectionItem *item); +PersonEvidenceState person_evidence_selection_item_get_state( + const PersonEvidenceSelectionItem *item); +const EvidenceRecord *person_evidence_selection_item_get_record( + const PersonEvidenceSelectionItem *item); +const char *person_evidence_selection_item_get_evidence_identifier( + const PersonEvidenceSelectionItem *item); +const char *person_evidence_selection_item_get_source_path( + const PersonEvidenceSelectionItem *item); +const char *person_evidence_selection_item_get_staging_path( + const PersonEvidenceSelectionItem *item); +const char *person_evidence_selection_item_get_original_name( + const PersonEvidenceSelectionItem *item); +const char *person_evidence_selection_item_get_mime_type( + const PersonEvidenceSelectionItem *item); +const char *person_evidence_selection_item_get_type_identifier( + const PersonEvidenceSelectionItem *item); +const char *person_evidence_selection_item_get_sha256( + const PersonEvidenceSelectionItem *item); +const char *person_evidence_selection_item_get_description( + const PersonEvidenceSelectionItem *item); +const char *person_evidence_selection_item_get_prepared_at( + const PersonEvidenceSelectionItem *item); +guint64 person_evidence_selection_item_get_size_bytes( + const PersonEvidenceSelectionItem *item); + +G_END_DECLS +#endif diff --git a/include/views/create_person_dialog.h b/include/views/create_person_dialog.h index 1bd6d3f..6d7ca73 100644 --- a/include/views/create_person_dialog.h +++ b/include/views/create_person_dialog.h @@ -5,7 +5,9 @@ #ifndef LABFY_INVESTIGATION_CREATE_PERSON_DIALOG_H #define LABFY_INVESTIGATION_CREATE_PERSON_DIALOG_H #include "core/person_entity_service.h" +#include "core/evidence_staging.h" #include "models/evidence_record.h" +#include "models/person_evidence_selection.h" #include "core/task_manager.h" #include G_BEGIN_DECLS @@ -33,5 +35,9 @@ void create_person_dialog_result_free(CreatePersonDialogResult *result); /** @brief Retourne les données empruntées du résultat. */ const PersonEntityInput *create_person_dialog_result_get_input( const CreatePersonDialogResult *result); +const PersonEvidenceSelection *create_person_dialog_result_get_evidence_selection( + const CreatePersonDialogResult *result); +EvidenceStaging *create_person_dialog_result_steal_staging( + CreatePersonDialogResult *result); G_END_DECLS #endif diff --git a/include/views/main_window.h b/include/views/main_window.h index 269206f..1cb3c3d 100644 --- a/include/views/main_window.h +++ b/include/views/main_window.h @@ -509,6 +509,8 @@ void main_window_set_eml_analysis_available( /** @brief Transmet au workspace un chemin absolu validé pour l'aperçu. */ void main_window_set_evidence_preview(MainWindow *main_window, const char *file_path, const char *display_name); +void main_window_show_evidence_preview(MainWindow *main_window, + const char *investigation_root, const EvidenceRecord *record); /** diff --git a/include/widgets/evidence_preview_widget.h b/include/widgets/evidence_preview_widget.h new file mode 100644 index 0000000..a436bf8 --- /dev/null +++ b/include/widgets/evidence_preview_widget.h @@ -0,0 +1,30 @@ +#ifndef LABFY_INVESTIGATION_EVIDENCE_PREVIEW_WIDGET_H +#define LABFY_INVESTIGATION_EVIDENCE_PREVIEW_WIDGET_H + +#include "core/evidence_preview.h" +#include "core/task_manager.h" +#include + +G_BEGIN_DECLS + +typedef struct EvidencePreviewWidget EvidencePreviewWidget; +typedef gboolean (*EvidencePreviewWidgetSessionCheck)(gpointer user_data); + +EvidencePreviewWidget *evidence_preview_widget_new( + TaskManager *task_manager, + EvidencePreviewWidgetSessionCheck session_check, + gpointer session_data); +GtkWidget *evidence_preview_widget_get_widget( + const EvidencePreviewWidget *widget); +void evidence_preview_widget_show(EvidencePreviewWidget *widget, + const EvidencePreviewRequest *request, const char *metadata); +void evidence_preview_widget_clear(EvidencePreviewWidget *widget); +void evidence_preview_widget_cancel(EvidencePreviewWidget *widget); +void evidence_preview_widget_free(EvidencePreviewWidget *widget); +const char *evidence_preview_widget_get_state( + const EvidencePreviewWidget *widget); +char *evidence_preview_widget_dup_text( + const EvidencePreviewWidget *widget); + +G_END_DECLS +#endif diff --git a/include/widgets/workspace.h b/include/widgets/workspace.h index 23ad095..ef1c844 100644 --- a/include/widgets/workspace.h +++ b/include/widgets/workspace.h @@ -11,6 +11,7 @@ #include "models/evidence_observation.h" #include "models/entity_record.h" #include "models/osint_action_catalog.h" +#include "core/task_manager.h" #include @@ -162,7 +163,7 @@ typedef void (*WorkspaceObservationRemoveCallback)( * * @return Une nouvelle zone de travail, ou NULL en cas d'échec. */ -Workspace *workspace_new(void); +Workspace *workspace_new(TaskManager *task_manager); /** * @brief Retourne le widget GTK racine de la zone de travail. @@ -223,6 +224,8 @@ void workspace_set_eml_analysis_available( */ void workspace_set_evidence_preview(Workspace *workspace, const char *file_path, const char *display_name); +void workspace_show_evidence_preview(Workspace *workspace, + const char *investigation_root, const EvidenceRecord *record); /** * @brief Sélectionne une entité dans le graphe affiché. diff --git a/src/core/application.c b/src/core/application.c index 55c6b43..32dd066 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -56,6 +56,8 @@ #include "core/relation_service.h" #include "core/social_account_service.h" #include "core/person_entity_service.h" +#include "core/person_creation_coordinator.h" +#include "core/person_creation_task.h" #include "core/person_creation_guard.h" #include "core/eml_processing.h" #include "core/eml_integration.h" @@ -157,6 +159,14 @@ typedef struct Application *application; PersonCreationGuard *guard; } ApplicationPersonDialogContext; +typedef struct +{ + Application *application; + guint64 generation; + char *root_path; + char *database_path; + EvidenceStaging *staging; +} ApplicationPersonCreationTaskContext; static void application_person_dialog_context_free( ApplicationPersonDialogContext *context) @@ -165,6 +175,15 @@ static void application_person_dialog_context_free( person_creation_guard_free(context->guard); g_free(context); } +static void application_person_creation_task_context_free(gpointer data) +{ + ApplicationPersonCreationTaskContext *context = data; + if (context == NULL) return; + g_free(context->root_path); + g_free(context->database_path); + evidence_staging_free(context->staging); + g_free(context); +} static void application_on_evidence_selected( const char *evidence_identifier, gpointer user_data); @@ -2736,6 +2755,7 @@ static gboolean application_install_session( application->graph_viewport_save_source_id = 0U; } application_save_graph_viewport(application); + task_manager_cancel_all(application->task_manager); investigation_session_close( application->session ); @@ -5394,6 +5414,32 @@ static void application_on_add_social_account_requested(gpointer user_data) } /** @brief Persiste une personne validée puis recharge le graphe. */ +static void application_on_person_creation_task_completed( + BackgroundTask *task, gpointer user_data) +{ + ApplicationPersonCreationTaskContext *context = user_data; + Application *application = context != NULL ? context->application : NULL; + const InvestigationProject *project = application != NULL && + application->session != NULL + ? investigation_session_get_project(application->session) : NULL; + gboolean session_matches = project != NULL && + application->session_generation == context->generation && + g_strcmp0(investigation_project_get_root_path(project), + context->root_path) == 0 && + g_strcmp0(investigation_project_get_database_path(project), + context->database_path) == 0; + if (!session_matches) return; + if (background_task_get_state(task) != BACKGROUND_TASK_STATE_COMPLETED) { + GError *error = background_task_dup_error(task); + application_present_error(application, "Personne non créée", + error != NULL ? error->message : "L’écriture a échoué."); + g_clear_error(&error); + return; + } + main_window_set_status(application->main_window, + "Personne et preuves ajoutées. Actualisation du graphe…"); + application_start_graph_loading(application, context->database_path); +} static void application_on_person_completed( CreatePersonDialogResult *result, gpointer user_data) { @@ -5402,7 +5448,9 @@ static void application_on_person_completed( const PersonEntityInput *input = NULL; const InvestigationProject *project = NULL; GError *error = NULL; - char *identifier = NULL; + PersonCreationTaskRequest *request = NULL; + BackgroundTask *task = NULL; + ApplicationPersonCreationTaskContext *task_context = NULL; const char *active_root = NULL, *active_database = NULL; if (result == NULL) { return; @@ -5416,20 +5464,29 @@ static void application_on_person_completed( if (!person_creation_guard_matches(context->guard, TRUE, application->session_generation, active_root, active_database)) goto changed_session; - if (!person_entity_service_create( - investigation_session_get_database(application->session), - input, &identifier, &error)) + request = person_creation_task_request_new(active_database, active_root, + input, create_person_dialog_result_get_evidence_selection(result)); + task_context = g_new0(ApplicationPersonCreationTaskContext, 1); + task_context->application = application; + task_context->generation = application->session_generation; + task_context->root_path = g_strdup(active_root); + task_context->database_path = g_strdup(active_database); + task_context->staging = + create_person_dialog_result_steal_staging(result); + task = person_creation_task_start(application->task_manager, request, + application_on_person_creation_task_completed, task_context, + application_person_creation_task_context_free, &error); + if (task == NULL) { + application_person_creation_task_context_free(task_context); application_present_error(application, "Personne non créée", error != NULL ? error->message : "L'écriture a échoué."); - else - { - project = investigation_session_get_project(application->session); + } else { main_window_set_status(application->main_window, - "Personne ajoutée. Actualisation du graphe…"); - application_start_graph_loading(application, - investigation_project_get_database_path(project)); + "Création et import des preuves en cours…"); + background_task_unref(task); } - g_clear_error(&error); g_free(identifier); + person_creation_task_request_free(request); + g_clear_error(&error); create_person_dialog_result_free(result); return; changed_session: @@ -5577,10 +5634,6 @@ static void application_on_evidence_selected( const InvestigationProject *project = NULL; const char *investigation_root = NULL; - const char *relative_path = NULL; - char *canonical_root = NULL; - char *candidate_path = NULL; - char *canonical_path = NULL; char *status_message = NULL; @@ -5775,30 +5828,17 @@ static void application_on_evidence_selected( project = investigation_session_get_project(application->session); investigation_root = project != NULL ? investigation_project_get_root_path(project) : NULL; - relative_path = evidence_record_get_relative_path(evidence_record); gboolean eml_analysis_available = FALSE; - if (investigation_root != NULL && relative_path != NULL) - { - canonical_root = g_canonicalize_filename(investigation_root, NULL); - candidate_path = g_build_filename(investigation_root, - relative_path, NULL); - canonical_path = g_canonicalize_filename(candidate_path, NULL); - if (canonical_root != NULL && canonical_path != NULL && - g_str_has_prefix(canonical_path, canonical_root) && - canonical_path[strlen(canonical_root)] == G_DIR_SEPARATOR && - g_file_test(canonical_path, G_FILE_TEST_IS_REGULAR)) - { - const char *selected_name = - evidence_record_get_original_name(evidence_record); - char *lower_name = selected_name != NULL - ? g_ascii_strdown(selected_name, -1) : NULL; - eml_analysis_available = lower_name != NULL && - g_str_has_suffix(lower_name, ".eml"); - g_free(lower_name); - main_window_set_evidence_preview(application->main_window, - canonical_path, - evidence_record_get_original_name(evidence_record)); - } + if (investigation_root != NULL) { + const char *selected_name = + evidence_record_get_original_name(evidence_record); + char *lower_name = selected_name != NULL + ? g_ascii_strdown(selected_name, -1) : NULL; + eml_analysis_available = lower_name != NULL && + g_str_has_suffix(lower_name, ".eml"); + g_free(lower_name); + main_window_show_evidence_preview(application->main_window, + investigation_root, evidence_record); } main_window_set_eml_analysis_available( application->main_window, eml_analysis_available); @@ -5825,9 +5865,6 @@ static void application_on_evidence_selected( g_free( status_message ); - g_free(canonical_path); - g_free(candidate_path); - g_free(canonical_root); evidence_record_free( evidence_record diff --git a/src/core/eml_mime_extractor.c b/src/core/eml_mime_extractor.c index 9f5495a..50c1902 100644 --- a/src/core/eml_mime_extractor.c +++ b/src/core/eml_mime_extractor.c @@ -12,6 +12,7 @@ #define EML_MIME_MAX_COLLISIONS 10000U #define EML_MIME_IO_BLOCK_SIZE 65536U +#define EML_MIME_PREVIEW_BODY_MAX (1024U * 1024U) typedef struct { @@ -26,6 +27,8 @@ typedef struct EmlMimeResult *result; guint part_count; gsize total_decoded_size; + gboolean preview_only; + guint body_rank; } EmlMimeContext; static gboolean eml_mime_check_cancelled( @@ -65,6 +68,7 @@ void eml_mime_result_free(EmlMimeResult *result) return; g_clear_pointer(&result->attachments, g_ptr_array_unref); g_clear_pointer(&result->warnings, g_ptr_array_unref); + g_free(result->body_text); g_free(result); } @@ -1201,7 +1205,10 @@ static gboolean eml_mime_extract_leaf( g_ascii_strcasecmp(disposition, "attachment") == 0 || g_ascii_strcasecmp(disposition, "inline") == 0; - if (!relevant) + gboolean body_candidate = !relevant && + (g_str_equal(content_type, "text/plain") || + g_str_equal(content_type, "text/html")); + if (!relevant && !body_candidate) goto cleanup; if (disposition_malformed) eml_mime_warn( @@ -1239,6 +1246,36 @@ static gboolean eml_mime_extract_leaf( gsize decoded_length = 0; gconstpointer decoded_data = g_bytes_get_data(decoded, &decoded_length); + if (body_candidate) + { + guint rank = g_str_equal(content_type, "text/plain") ? 2U : 1U; + if (rank >= context->body_rank) + { + gsize kept = MIN(decoded_length, + (gsize) EML_MIME_PREVIEW_BODY_MAX); + char *valid = g_utf8_make_valid(decoded_data, (gssize) kept); + if (rank == 1U) + { + GRegex *active = g_regex_new( + "(?is)<(script|style)\\b[^>]*>.*?", + 0, 0, NULL); + char *without_active = g_regex_replace(active, valid, -1, + 0, " ", 0, NULL); + GRegex *tags = g_regex_new("(?s)<[^>]+>", 0, 0, NULL); + char *plain = g_regex_replace(tags, without_active, -1, + 0, " ", 0, NULL); + g_regex_unref(active); g_regex_unref(tags); + g_free(without_active); g_free(valid); valid = plain; + } + g_free(context->result->body_text); + context->result->body_text = valid; + context->result->body_from_html = rank == 1U; + context->result->body_truncated = + decoded_length > EML_MIME_PREVIEW_BODY_MAX; + context->body_rank = rank; + } + goto cleanup; + } if (context->total_decoded_size > EML_MIME_MAX_TOTAL_DECODED_SIZE - decoded_length) { @@ -1262,6 +1299,34 @@ static gboolean eml_mime_extract_leaf( ? raw_filename : decoded_name; char *sanitized = eml_mime_sanitize_filename(decoded_name); + if (context->preview_only) + { + EmlAttachment *attachment = g_new0(EmlAttachment, 1); + attachment->part_index = g_strdup(part_index); + attachment->declared_filename = g_strdup(declared); + attachment->decoded_filename = g_strdup(decoded_name); + attachment->sanitized_filename = g_strdup(sanitized); + attachment->content_type = g_strdup(content_type); + attachment->detected_mime = g_strdup(content_type); + attachment->content_id = g_strdup(content_id); + attachment->normalized_content_id = + eml_mime_normalize_content_id(content_id); + attachment->content_disposition = g_strdup(raw_disposition); + attachment->normalized_disposition = disposition[0] != '\0' + ? g_ascii_strdown(disposition, -1) : NULL; + attachment->transfer_encoding = g_strdup( + encoding != NULL ? encoding : "7bit"); + attachment->is_inline = + g_ascii_strcasecmp(disposition, "inline") == 0; + attachment->is_attachment = + g_ascii_strcasecmp(disposition, "attachment") == 0; + attachment->encoded_size = body_length; + attachment->decoded_size = decoded_length; + context->total_decoded_size += decoded_length; + g_ptr_array_add(context->result->attachments, attachment); + g_free(sanitized); g_free(synthetic); + goto cleanup; + } char *unique = eml_mime_unique_name( context->target_dir, sanitized, @@ -1726,3 +1791,46 @@ EmlMimeResult *eml_mime_extract_attachments( error ); } + +EmlMimeResult *eml_mime_build_preview(const char *eml_path, + GCancellable *cancellable, GError **error) +{ + g_return_val_if_fail(error == NULL || *error == NULL, NULL); + if (eml_path == NULL || eml_path[0] == '\0') + { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT, + "Le chemin de l'EML est invalide."); + return NULL; + } + if (eml_mime_check_cancelled(cancellable, error)) + return NULL; + GMappedFile *mapped = g_mapped_file_new(eml_path, FALSE, error); + if (mapped == NULL) + return NULL; + gsize size = g_mapped_file_get_length(mapped); + if (size == 0 || size > EML_MIME_MAX_FILE_SIZE) + { + g_mapped_file_unref(mapped); + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA, + "Le fichier EML est vide ou dépasse la limite autorisée."); + return NULL; + } + EmlMimeResult *result = g_new0(EmlMimeResult, 1); + result->attachments = g_ptr_array_new_with_free_func( + (GDestroyNotify) eml_attachment_free); + result->warnings = g_ptr_array_new_with_free_func(g_free); + EmlMimeContext context = { + .cancellable = cancellable, + .result = result, + .preview_only = TRUE + }; + if (!eml_mime_parse_entity(&context, g_mapped_file_get_contents(mapped), + size, "1", 1, error)) + { + g_mapped_file_unref(mapped); + eml_mime_result_free(result); + return NULL; + } + g_mapped_file_unref(mapped); + return result; +} diff --git a/src/core/evidence_preview.c b/src/core/evidence_preview.c index 840b0ec..2775df8 100644 --- a/src/core/evidence_preview.c +++ b/src/core/evidence_preview.c @@ -1,35 +1,117 @@ #include "core/evidence_preview.h" #include "core/evidence_integrity_verifier.h" +#include "core/eml_analyzer.h" +#include "core/eml_mime_extractor.h" #include +#include +#include #include #include #include +#include + +typedef enum { + FORMAT_UNKNOWN, FORMAT_PNG, FORMAT_JPEG, FORMAT_HEIF, FORMAT_HEIC, + FORMAT_MP4, FORMAT_MOV, FORMAT_PDF, FORMAT_EML, FORMAT_TEXT +} PreviewFormat; +typedef struct { struct jpeg_error_mgr base; jmp_buf jump; } PreviewJpegError; static GQuark preview_error(void) { return g_quark_from_static_string("evidence-preview-error"); } - -typedef struct { - struct jpeg_error_mgr base; - jmp_buf jump; -} PreviewJpegError; static void jpeg_failed(j_common_ptr info) +{ longjmp(((PreviewJpegError *) info->err)->jump, 1); } +static cairo_status_t write_png(void *closure, const unsigned char *data, + unsigned int length) +{ g_byte_array_append(closure, data, length); return CAIRO_STATUS_SUCCESS; } +static gboolean cancelled(GCancellable *cancellable, GError **error) +{ return cancellable != NULL && + g_cancellable_set_error_if_cancelled(cancellable, error); } +static gboolean brand_is(const guint8 *data, gsize size, const char *brand) { - PreviewJpegError *error = (PreviewJpegError *) info->err; - longjmp(error->jump, 1); + if (size < 12 || memcmp(data + 4, "ftyp", 4) != 0) return FALSE; + for (gsize offset = 8; offset + 4 <= size; offset += 4) + if (memcmp(data + offset, brand, 4) == 0) return TRUE; + return FALSE; } -static cairo_status_t write_png(void *closure, - const unsigned char *data, unsigned int length) +static gboolean looks_like_eml(const guint8 *data, gsize size) { - g_byte_array_append(closure, data, length); - return CAIRO_STATUS_SUCCESS; + char *text = g_strndup((const char *) data, MIN(size, 65536U)); + gboolean result = (g_str_has_prefix(text, "From:") || + strstr(text, "\nFrom:") != NULL) && + (strstr(text, "\nSubject:") != NULL || + strstr(text, "\nDate:") != NULL) && + (strstr(text, "\r\n\r\n") != NULL || strstr(text, "\n\n") != NULL); + g_free(text); return result; +} +static gboolean looks_textual(const guint8 *data, gsize size) +{ + if (memchr(data, '\0', size) != NULL) return FALSE; + gsize invalid = 0; + for (gsize i = 0; i < size; i++) + if (data[i] < 0x09 || (data[i] > 0x0d && data[i] < 0x20)) invalid++; + return size == 0 || invalid * 100U <= size; +} +static PreviewFormat detect_format(const guint8 *data, gsize size, + const char *path, const char *historical_mime) +{ + if (size >= 8 && memcmp(data, "\x89PNG\r\n\x1a\n", 8) == 0) + return FORMAT_PNG; + if (size >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff) + return FORMAT_JPEG; + if (size >= 5 && memcmp(data, "%PDF-", 5) == 0) return FORMAT_PDF; + if (brand_is(data, size, "heic") || brand_is(data, size, "heix") || + brand_is(data, size, "hevc") || brand_is(data, size, "hevx")) + return FORMAT_HEIC; + if (brand_is(data, size, "mif1") || brand_is(data, size, "msf1") || + brand_is(data, size, "heif")) return FORMAT_HEIF; + if (brand_is(data, size, "qt ")) return FORMAT_MOV; + if (brand_is(data, size, "isom") || brand_is(data, size, "mp41") || + brand_is(data, size, "mp42") || brand_is(data, size, "avc1")) + return FORMAT_MP4; + if (looks_like_eml(data, size)) return FORMAT_EML; + if (looks_textual(data, size)) { + const char *suffix = strrchr(path, '.'); + gboolean misleading = suffix != NULL && + (g_ascii_strcasecmp(suffix, ".png") == 0 || + g_ascii_strcasecmp(suffix, ".jpg") == 0 || + g_ascii_strcasecmp(suffix, ".jpeg") == 0 || + g_ascii_strcasecmp(suffix, ".heic") == 0 || + g_ascii_strcasecmp(suffix, ".heif") == 0 || + g_ascii_strcasecmp(suffix, ".mp4") == 0 || + g_ascii_strcasecmp(suffix, ".mov") == 0 || + g_ascii_strcasecmp(suffix, ".pdf") == 0 || + g_ascii_strcasecmp(suffix, ".eml") == 0); + if (misleading || (historical_mime != NULL && + !g_str_has_prefix(historical_mime, "text/"))) return FORMAT_UNKNOWN; + char *content_type = g_content_type_guess(path, data, size, NULL); + char *mime = content_type != NULL + ? g_content_type_get_mime_type(content_type) : NULL; + gboolean indicated = mime != NULL && g_str_has_prefix(mime, "text/"); + indicated = indicated || (historical_mime != NULL && + g_str_has_prefix(historical_mime, "text/")); + g_free(mime); g_free(content_type); + if (indicated || size == 0) return FORMAT_TEXT; + } + return FORMAT_UNKNOWN; +} +static EvidencePreviewResult *result_new(const EvidencePreviewRequest *request, + const char *path, guint64 size) +{ + EvidencePreviewResult *result = g_new0(EvidencePreviewResult, 1); + result->kind = EVIDENCE_PREVIEW_KIND_UNSUPPORTED; + result->evidence_identifier = g_strdup(request->evidence_identifier); + result->request_generation = request->request_generation; + result->display_name = g_path_get_basename(path); + result->controlled_path = g_strdup(path); + result->size_bytes = size; + result->integrity_valid = TRUE; + return result; } static gboolean surface_to_result(cairo_surface_t *source, gint source_width, - gint source_height, const EvidencePreviewRequest *request, - EvidencePreviewResult **out_result, GError **error) + gint source_height, EvidencePreviewResult *result, GError **error) { - double scale = MIN(1.0, MIN( - (double) EVIDENCE_PREVIEW_MAX_EDGE / source_width, - (double) EVIDENCE_PREVIEW_MAX_EDGE / source_height)); + double scale = MIN(1.0, MIN((double) EVIDENCE_PREVIEW_MAX_EDGE / + source_width, (double) EVIDENCE_PREVIEW_MAX_EDGE / source_height)); gint width = MAX(1, (gint) (source_width * scale)); gint height = MAX(1, (gint) (source_height * scale)); cairo_surface_t *target = cairo_image_surface_create( @@ -37,177 +119,356 @@ static gboolean surface_to_result(cairo_surface_t *source, gint source_width, cairo_t *cr = cairo_create(target); GByteArray *bytes = g_byte_array_new(); cairo_scale(cr, scale, scale); - cairo_set_source_surface(cr, source, 0, 0); - cairo_paint(cr); + cairo_set_source_surface(cr, source, 0, 0); cairo_paint(cr); cairo_destroy(cr); if (cairo_surface_write_to_png_stream(target, write_png, bytes) != CAIRO_STATUS_SUCCESS) { g_set_error_literal(error, preview_error(), 4, "Impossible de produire l'aperçu en mémoire."); - g_byte_array_unref(bytes); cairo_surface_destroy(target); - return FALSE; + g_byte_array_unref(bytes); cairo_surface_destroy(target); return FALSE; } cairo_surface_destroy(target); - *out_result = g_new0(EvidencePreviewResult, 1); - (*out_result)->evidence_identifier = - g_strdup(request->evidence_identifier); - (*out_result)->request_generation = request->request_generation; - (*out_result)->width = width; (*out_result)->height = height; - (*out_result)->png_bytes = g_byte_array_free_to_bytes(bytes); + result->width = width; result->height = height; + result->png_bytes = g_byte_array_free_to_bytes(bytes); + result->preview_available = TRUE; return TRUE; } +static gboolean valid_dimensions(gint width, gint height, GError **error) +{ + if (!evidence_preview_dimensions_are_safe(width, height)) { + g_set_error_literal(error, preview_error(), 5, + "Les dimensions dépassent les limites sûres."); + return FALSE; + } + return TRUE; +} +gboolean evidence_preview_dimensions_are_safe(gint width, gint height) +{ + return width > 0 && height > 0 && + width <= EVIDENCE_PREVIEW_MAX_DIMENSION && + height <= EVIDENCE_PREVIEW_MAX_DIMENSION && + (guint64) width * (guint64) height <= EVIDENCE_PREVIEW_MAX_PIXELS; +} +static gboolean load_png(const char *path, EvidencePreviewResult *result, + GError **error) +{ + cairo_surface_t *surface = cairo_image_surface_create_from_png(path); + gint width = cairo_image_surface_get_width(surface); + gint height = cairo_image_surface_get_height(surface); + if (cairo_surface_status(surface) != CAIRO_STATUS_SUCCESS || + !valid_dimensions(width, height, error)) { + if (error != NULL && *error == NULL) g_set_error_literal(error, + preview_error(), 4, "Le fichier PNG est illisible ou corrompu."); + cairo_surface_destroy(surface); return FALSE; + } + gboolean ok = surface_to_result(surface, width, height, result, error); + cairo_surface_destroy(surface); return ok; +} +static gboolean load_jpeg(const char *path, EvidencePreviewResult *result, + GCancellable *cancellable, GError **error) +{ + FILE *file = fopen(path, "rb"); struct jpeg_decompress_struct jpeg = {0}; + PreviewJpegError jpeg_error; cairo_surface_t *surface = NULL; + if (file == NULL) goto invalid; + jpeg.err = jpeg_std_error(&jpeg_error.base); + jpeg_error.base.error_exit = jpeg_failed; + if (setjmp(jpeg_error.jump) != 0) { + jpeg_destroy_decompress(&jpeg); fclose(file); + if (surface != NULL) cairo_surface_destroy(surface); + goto invalid; + } + jpeg_create_decompress(&jpeg); jpeg_stdio_src(&jpeg, file); + jpeg_read_header(&jpeg, TRUE); + gint width = (gint) jpeg.image_width, height = (gint) jpeg.image_height; + if (!valid_dimensions(width, height, error)) { + jpeg_destroy_decompress(&jpeg); fclose(file); return FALSE; + } + jpeg.out_color_space = JCS_RGB; jpeg_start_decompress(&jpeg); + surface = cairo_image_surface_create(CAIRO_FORMAT_RGB24, width, height); + unsigned char *pixels = cairo_image_surface_get_data(surface); + gint stride = cairo_image_surface_get_stride(surface); + JSAMPARRAY row = (*jpeg.mem->alloc_sarray)((j_common_ptr) &jpeg, + JPOOL_IMAGE, (JDIMENSION) width * 3U, 1); + while (jpeg.output_scanline < jpeg.output_height) { + jpeg_read_scanlines(&jpeg, row, 1); + guint32 *destination = (guint32 *) (pixels + + (jpeg.output_scanline - 1) * stride); + for (gint x = 0; x < width; x++) destination[x] = + ((guint32) row[0][x * 3] << 16) | + ((guint32) row[0][x * 3 + 1] << 8) | row[0][x * 3 + 2]; + if (cancellable != NULL && g_cancellable_is_cancelled(cancellable)) + break; + } + cairo_surface_mark_dirty(surface); jpeg_finish_decompress(&jpeg); + jpeg_destroy_decompress(&jpeg); fclose(file); + if (cancelled(cancellable, error)) { + cairo_surface_destroy(surface); return FALSE; + } + gboolean ok = surface_to_result(surface, width, height, result, error); + cairo_surface_destroy(surface); return ok; +invalid: + g_set_error_literal(error, preview_error(), 4, + "Le fichier JPEG est illisible ou corrompu."); return FALSE; +} +static gboolean load_heif(const char *path, EvidencePreviewResult *result, + GError **error) +{ + struct heif_context *context = heif_context_alloc(); + struct heif_image_handle *handle = NULL; struct heif_image *image = NULL; + struct heif_error heif_error = heif_context_read_from_file( + context, path, NULL); + if (heif_error.code == heif_error_Ok) + heif_error = heif_context_get_primary_image_handle(context, &handle); + gint width = handle != NULL ? heif_image_handle_get_width(handle) : 0; + gint height = handle != NULL ? heif_image_handle_get_height(handle) : 0; + result->item_count = (guint) + MAX(0, heif_context_get_number_of_top_level_images(context)); + if (heif_error.code != heif_error_Ok || + !valid_dimensions(width, height, error)) goto failed; + heif_error = heif_decode_image(handle, &image, heif_colorspace_RGB, + heif_chroma_interleaved_RGBA, NULL); + if (heif_error.code != heif_error_Ok) goto failed; + gint stride = 0; + const guint8 *plane = heif_image_get_plane_readonly(image, + heif_channel_interleaved, &stride); + cairo_surface_t *surface = cairo_image_surface_create( + CAIRO_FORMAT_ARGB32, width, height); + guint8 *target = cairo_image_surface_get_data(surface); + gint target_stride = cairo_image_surface_get_stride(surface); + for (gint y = 0; y < height; y++) { + guint32 *row = (guint32 *) (target + y * target_stride); + for (gint x = 0; x < width; x++) { + const guint8 *pixel = plane + y * stride + x * 4; + row[x] = ((guint32) pixel[3] << 24) | + ((guint32) pixel[0] << 16) | + ((guint32) pixel[1] << 8) | pixel[2]; + } + } + cairo_surface_mark_dirty(surface); + gboolean ok = surface_to_result(surface, width, height, result, error); + cairo_surface_destroy(surface); heif_image_release(image); + heif_image_handle_release(handle); heif_context_free(context); return ok; +failed: + if (error != NULL && *error == NULL) g_set_error(error, preview_error(), 6, + "HEIC/HEIF illisible : %s", heif_error.message); + if (image != NULL) heif_image_release(image); + if (handle != NULL) heif_image_handle_release(handle); + heif_context_free(context); return FALSE; +} +static gboolean load_pdf(const char *path, EvidencePreviewResult *result, + GError **error) +{ + char *uri = g_filename_to_uri(path, NULL, error); + if (uri == NULL) return FALSE; + PopplerDocument *document = poppler_document_new_from_file(uri, NULL, error); + g_free(uri); if (document == NULL) return FALSE; + gint pages = poppler_document_get_n_pages(document); + if (pages < 1) { g_object_unref(document); g_set_error_literal(error, + preview_error(), 7, "Le PDF ne contient aucune page."); return FALSE; } + PopplerPage *page = poppler_document_get_page(document, 0); + double page_width = 0, page_height = 0; + poppler_page_get_size(page, &page_width, &page_height); + if (!valid_dimensions((gint) page_width, (gint) page_height, error)) { + g_object_unref(page); g_object_unref(document); return FALSE; + } + double scale = MIN(1.0, MIN(EVIDENCE_PREVIEW_MAX_EDGE / page_width, + EVIDENCE_PREVIEW_MAX_EDGE / page_height)); + gint width = MAX(1, (gint) (page_width * scale)); + gint height = MAX(1, (gint) (page_height * scale)); + cairo_surface_t *surface = cairo_image_surface_create( + CAIRO_FORMAT_ARGB32, width, height); + cairo_t *cr = cairo_create(surface); cairo_set_source_rgb(cr, 1, 1, 1); + cairo_paint(cr); cairo_scale(cr, scale, scale); poppler_page_render(page, cr); + cairo_destroy(cr); result->item_count = (guint) pages; + gboolean ok = surface_to_result(surface, width, height, result, error); + cairo_surface_destroy(surface); g_object_unref(page); + g_object_unref(document); return ok; +} +static char *header_line(EmlAnalysis *analysis, const char *name, + const char *label) +{ + const char *value = eml_analysis_get_first_header(analysis, name); + return g_strdup_printf("%s : %s\n", label, + value != NULL ? value : "Non renseigné"); +} +static gboolean load_email(const char *path, const guint8 *data, gsize size, + EvidencePreviewResult *result, GError **error) +{ + EmlAnalysis *analysis = eml_analyzer_analyze_file(path, error); + if (analysis == NULL) return FALSE; + EmlMimeResult *mime = eml_mime_build_preview(path, NULL, error); + if (mime == NULL) { eml_analysis_free(analysis); return FALSE; } + GString *text = g_string_new("APERÇU EML — contenu passif\n\n"); + static const struct { const char *name; const char *label; } headers[] = { + {"from","From"}, {"to","To"}, {"cc","Cc"}, {"date","Date"}, + {"subject","Subject"}, {"message-id","Message-ID"}}; + for (guint i = 0; i < G_N_ELEMENTS(headers); i++) { + char *line = header_line(analysis, headers[i].name, headers[i].label); + g_string_append(text, line); g_free(line); + } + (void) data; (void) size; + g_string_append(text, "\nCorps sélectionné :\n"); + g_string_append(text, mime->body_text != NULL + ? mime->body_text : "Corps absent."); + g_string_append_printf(text, "\n\nPièces jointes (%u) :\n", + mime->attachments->len); + for (guint i = 0; i < mime->attachments->len; i++) { + EmlAttachment *attachment = g_ptr_array_index(mime->attachments, i); + g_string_append_printf(text, + "• %s — %s — %" G_GSIZE_FORMAT " octets — %s%s%s\n", + attachment->decoded_filename, + attachment->content_type, + attachment->decoded_size, + attachment->normalized_disposition != NULL + ? attachment->normalized_disposition : "sans disposition", + attachment->normalized_content_id != NULL ? " — Content-ID: " : "", + attachment->normalized_content_id != NULL + ? attachment->normalized_content_id : ""); + } + if (mime->warnings->len > 0) { + g_string_append(text, "\nAvertissements :\n"); + for (guint i = 0; i < mime->warnings->len; i++) + g_string_append_printf(text, "• %s\n", + (const char *) g_ptr_array_index(mime->warnings, i)); + } + result->item_count = mime->attachments->len; + result->truncated = mime->body_truncated; + result->text = g_string_free(text, FALSE); + result->preview_available = TRUE; + eml_mime_result_free(mime); eml_analysis_free(analysis); return TRUE; +} EvidencePreviewRequest *evidence_preview_request_new(const char *root, - const char *evidence_identifier, const char *relative_path, - const char *sha256, const char *mime_type, guint64 generation) + const char *identifier, const char *relative_path, const char *sha256, + const char *mime_type, guint64 generation) { - if (root == NULL || !g_uuid_string_is_valid(evidence_identifier) || - relative_path == NULL || sha256 == NULL || mime_type == NULL) - return NULL; - EvidencePreviewRequest *r = g_new0(EvidencePreviewRequest, 1); - r->investigation_root_path = g_strdup(root); - r->evidence_identifier = g_strdup(evidence_identifier); - r->relative_path = g_strdup(relative_path); - r->expected_sha256 = g_strdup(sha256); - r->mime_type = g_strdup(mime_type); - r->request_generation = generation; - return r; + if (root == NULL || !g_uuid_string_is_valid(identifier) || + relative_path == NULL || sha256 == NULL) return NULL; + EvidencePreviewRequest *request = g_new0(EvidencePreviewRequest, 1); + request->investigation_root_path = g_strdup(root); + request->evidence_identifier = g_strdup(identifier); + request->relative_path = g_strdup(relative_path); + request->expected_sha256 = g_strdup(sha256); + request->mime_type = g_strdup(mime_type); + request->request_generation = generation; return request; } -void evidence_preview_request_free(EvidencePreviewRequest *r) +void evidence_preview_request_free(EvidencePreviewRequest *request) { - if (r == NULL) return; - g_free(r->investigation_root_path); g_free(r->evidence_identifier); - g_free(r->relative_path); g_free(r->expected_sha256); - g_free(r->mime_type); g_free(r); + if (request == NULL) return; + g_free(request->investigation_root_path); g_free(request->evidence_identifier); + g_free(request->relative_path); g_free(request->expected_sha256); + g_free(request->mime_type); g_free(request); } -void evidence_preview_result_free(EvidencePreviewResult *r) +void evidence_preview_result_free(EvidencePreviewResult *result) { - if (r == NULL) return; - g_free(r->evidence_identifier); - g_clear_pointer(&r->png_bytes, g_bytes_unref); - g_free(r); + if (result == NULL) return; + g_free(result->evidence_identifier); g_free(result->effective_mime_type); + g_free(result->effective_format); g_free(result->display_name); + g_free(result->controlled_path); g_free(result->text); + g_free(result->message); g_clear_pointer(&result->png_bytes, g_bytes_unref); + g_free(result); } -gboolean evidence_preview_result_matches(const EvidencePreviewResult *r, +gboolean evidence_preview_result_matches(const EvidencePreviewResult *result, const char *identifier, guint64 generation) -{ - return r != NULL && r->request_generation == generation && - g_strcmp0(r->evidence_identifier, identifier) == 0; -} +{ return result != NULL && result->request_generation == generation && + g_strcmp0(result->evidence_identifier, identifier) == 0; } EvidencePreviewResult *evidence_preview_load( const EvidencePreviewRequest *request, GCancellable *cancellable, GError **error) { - EvidenceIntegrityVerificationResult *verification = NULL; - EvidencePreviewResult *result = NULL; - char *path = NULL; - gint width = 0, height = 0; - if (request == NULL || - !(g_str_equal(request->mime_type, "image/png") || - g_str_equal(request->mime_type, "image/jpeg"))) { - g_set_error_literal(error, preview_error(), 1, - "Aperçu indisponible pour ce format."); - return NULL; - } - verification = evidence_integrity_verifier_verify( - request->investigation_root_path, request->relative_path, - request->expected_sha256, cancellable, error); + if (request == NULL) { g_set_error_literal(error, preview_error(), 1, + "Aperçu indisponible."); return NULL; } + EvidenceIntegrityVerificationResult *verification = + evidence_integrity_verifier_verify(request->investigation_root_path, + request->relative_path, request->expected_sha256, + cancellable, error); if (verification == NULL) return NULL; if (evidence_integrity_verification_result_get_status(verification) != EVIDENCE_INTEGRITY_STATUS_VALID) { + evidence_integrity_verification_result_free(verification); g_set_error_literal(error, preview_error(), 2, "Aperçu refusé : l'intégrité de la preuve est invalide."); - goto cleanup; + return NULL; } - if (evidence_integrity_verification_result_get_size_bytes(verification) > - EVIDENCE_PREVIEW_MAX_FILE_BYTES) { - g_set_error_literal(error, preview_error(), 3, - "L'image dépasse la taille maximale autorisée."); - goto cleanup; - } - if (cancellable != NULL && - g_cancellable_set_error_if_cancelled(cancellable, error)) goto cleanup; - path = g_build_filename(request->investigation_root_path, + guint64 size = evidence_integrity_verification_result_get_size_bytes( + verification); + char *path = g_build_filename(request->investigation_root_path, request->relative_path, NULL); - if (g_str_equal(request->mime_type, "image/png")) { - cairo_surface_t *surface = cairo_image_surface_create_from_png(path); - if (cairo_surface_status(surface) != CAIRO_STATUS_SUCCESS) { - cairo_surface_destroy(surface); - g_set_error_literal(error, preview_error(), 4, - "Le fichier image est illisible ou corrompu."); - goto cleanup; - } - width = cairo_image_surface_get_width(surface); - height = cairo_image_surface_get_height(surface); - if (width > EVIDENCE_PREVIEW_MAX_DIMENSION || - height > EVIDENCE_PREVIEW_MAX_DIMENSION || - (guint64) width * (guint64) height > EVIDENCE_PREVIEW_MAX_PIXELS) { - g_set_error_literal(error, preview_error(), 5, - "Les dimensions de l'image dépassent les limites sûres."); - cairo_surface_destroy(surface); - goto cleanup; - } - surface_to_result(surface, width, height, request, &result, error); - cairo_surface_destroy(surface); - goto cleanup; + EvidencePreviewResult *result = result_new(request, path, size); + gsize read_limit = (gsize) MIN(size, EVIDENCE_PREVIEW_MAX_TEXT_BYTES); + guint8 *data = g_malloc(read_limit + 1); gsize read_size = 0; + GFile *file = g_file_new_for_path(path); + GFileInputStream *stream = g_file_read(file, cancellable, error); + if (stream == NULL || !g_input_stream_read_all(G_INPUT_STREAM(stream), + data, read_limit, &read_size, cancellable, error)) goto failed; + data[read_size] = '\0'; g_object_unref(stream); stream = NULL; + PreviewFormat format = detect_format(data, read_size, path, + request->mime_type); + if ((format == FORMAT_PNG || format == FORMAT_JPEG || + format == FORMAT_HEIF || format == FORMAT_HEIC) && + size > EVIDENCE_PREVIEW_MAX_FILE_BYTES) { + result->kind = EVIDENCE_PREVIEW_KIND_IMAGE; + result->message = g_strdup( + "Fichier importable, mais aperçu désactivé en raison de sa taille."); + goto done; } - FILE *jpeg_file = fopen(path, "rb"); - struct jpeg_decompress_struct jpeg = {0}; - PreviewJpegError jpeg_error; - cairo_surface_t *surface = NULL; - if (jpeg_file == NULL) { - g_set_error_literal(error, preview_error(), 4, - "Le fichier image est illisible ou corrompu."); - goto cleanup; + gboolean ok = TRUE; + switch (format) { + case FORMAT_PNG: + result->kind = EVIDENCE_PREVIEW_KIND_IMAGE; + result->effective_mime_type = g_strdup("image/png"); + result->effective_format = g_strdup("PNG"); + ok = load_png(path, result, error); break; + case FORMAT_JPEG: + result->kind = EVIDENCE_PREVIEW_KIND_IMAGE; + result->effective_mime_type = g_strdup("image/jpeg"); + result->effective_format = g_strdup("JPEG"); + ok = load_jpeg(path, result, cancellable, error); break; + case FORMAT_HEIC: case FORMAT_HEIF: + result->kind = EVIDENCE_PREVIEW_KIND_IMAGE; + result->effective_mime_type = g_strdup(format == FORMAT_HEIC + ? "image/heic" : "image/heif"); + result->effective_format = g_strdup(format == FORMAT_HEIC + ? "HEIC" : "HEIF"); + ok = load_heif(path, result, error); break; + case FORMAT_MP4: case FORMAT_MOV: + result->kind = EVIDENCE_PREVIEW_KIND_VIDEO; + result->effective_mime_type = g_strdup(format == FORMAT_MOV + ? "video/quicktime" : "video/mp4"); + result->effective_format = g_strdup(format == FORMAT_MOV + ? "MOV" : "MP4"); + result->preview_available = TRUE; + result->message = g_strdup("Vidéo reconnue. Lecture manuelle uniquement."); + break; + case FORMAT_TEXT: + result->kind = EVIDENCE_PREVIEW_KIND_TEXT; + result->effective_mime_type = g_strdup("text/plain"); + result->effective_format = g_strdup("Texte brut"); + result->text = g_utf8_make_valid((const char *) data, read_size); + result->truncated = size > read_size; result->preview_available = TRUE; + break; + case FORMAT_EML: + result->kind = EVIDENCE_PREVIEW_KIND_EMAIL; + result->effective_mime_type = g_strdup("message/rfc822"); + result->effective_format = g_strdup("EML"); + ok = load_email(path, data, read_size, result, error); break; + case FORMAT_PDF: + result->kind = EVIDENCE_PREVIEW_KIND_PDF; + result->effective_mime_type = g_strdup("application/pdf"); + result->effective_format = g_strdup("PDF"); + ok = load_pdf(path, result, error); break; + default: + result->message = g_strdup("Format non pris en charge ou contenu invalide."); + result->effective_format = g_strdup("Inconnu"); break; } - jpeg.err = jpeg_std_error(&jpeg_error.base); - jpeg_error.base.error_exit = jpeg_failed; - if (setjmp(jpeg_error.jump) != 0) { - jpeg_destroy_decompress(&jpeg); fclose(jpeg_file); - if (surface != NULL) cairo_surface_destroy(surface); - g_set_error_literal(error, preview_error(), 4, - "Le fichier JPEG est illisible ou corrompu."); - goto cleanup; - } - jpeg_create_decompress(&jpeg); jpeg_stdio_src(&jpeg, jpeg_file); - jpeg_read_header(&jpeg, TRUE); - width = (gint) jpeg.image_width; height = (gint) jpeg.image_height; - if (width > EVIDENCE_PREVIEW_MAX_DIMENSION || - height > EVIDENCE_PREVIEW_MAX_DIMENSION || - (guint64) width * (guint64) height > EVIDENCE_PREVIEW_MAX_PIXELS) { - g_set_error_literal(error, preview_error(), 5, - "Les dimensions de l'image dépassent les limites sûres."); - jpeg_destroy_decompress(&jpeg); fclose(jpeg_file); - goto cleanup; - } - jpeg.out_color_space = JCS_RGB; - jpeg_start_decompress(&jpeg); - surface = cairo_image_surface_create(CAIRO_FORMAT_RGB24, width, height); - unsigned char *pixels = cairo_image_surface_get_data(surface); - gint stride = cairo_image_surface_get_stride(surface); - JSAMPARRAY row = (*jpeg.mem->alloc_sarray)( - (j_common_ptr) &jpeg, JPOOL_IMAGE, (JDIMENSION) width * 3U, 1); - while (jpeg.output_scanline < jpeg.output_height) { - jpeg_read_scanlines(&jpeg, row, 1); - guint y = jpeg.output_scanline - 1; - guint32 *destination = (guint32 *) (pixels + y * stride); - for (gint x = 0; x < width; x++) - destination[x] = ((guint32) row[0][x * 3] << 16) | - ((guint32) row[0][x * 3 + 1] << 8) | row[0][x * 3 + 2]; - if (cancellable != NULL && g_cancellable_is_cancelled(cancellable)) - break; - } - cairo_surface_mark_dirty(surface); - jpeg_finish_decompress(&jpeg); jpeg_destroy_decompress(&jpeg); - fclose(jpeg_file); - if (cancellable != NULL && - g_cancellable_set_error_if_cancelled(cancellable, error)) { - cairo_surface_destroy(surface); goto cleanup; - } - surface_to_result(surface, width, height, request, &result, error); - cairo_surface_destroy(surface); -cleanup: - g_free(path); - evidence_integrity_verification_result_free(verification); - return result; + if (!ok) goto failed; +done: + g_free(data); g_object_unref(file); g_free(path); + evidence_integrity_verification_result_free(verification); return result; +failed: + if (stream != NULL) g_object_unref(stream); + evidence_preview_result_free(result); g_free(data); g_object_unref(file); + g_free(path); evidence_integrity_verification_result_free(verification); + return NULL; } diff --git a/src/core/evidence_staging.c b/src/core/evidence_staging.c new file mode 100644 index 0000000..2ba4d67 --- /dev/null +++ b/src/core/evidence_staging.c @@ -0,0 +1,194 @@ +#include "core/evidence_staging.h" +#include "core/file_hash.h" +#include +#include +#include +#include + +struct EvidenceStaging { + gint reference_count; + char *directory; + GPtrArray *paths; +}; +static GQuark staging_error(void) +{ + return g_quark_from_static_string("evidence-staging-error"); +} +static const char *suggest_type(const char *mime) +{ + if (g_strcmp0(mime, "image/png") == 0) return "screenshot"; + if (g_strcmp0(mime, "image/jpeg") == 0) return "photo"; + if (g_strcmp0(mime, "application/pdf") == 0) return "document"; + if (mime != NULL && g_str_has_prefix(mime, "video/")) return "video"; + if (mime != NULL && g_str_has_prefix(mime, "audio/")) return "audio"; + if (mime != NULL && g_str_has_prefix(mime, "text/")) return "text"; + if (g_strcmp0(mime, "message/rfc822") == 0) return "email"; + return "other"; +} +EvidenceStaging *evidence_staging_new(GError **error) +{ + EvidenceStaging *staging = g_new0(EvidenceStaging, 1); + staging->directory = g_dir_make_tmp("labfy-person-staging-XXXXXX", error); + if (staging->directory == NULL) { + g_free(staging); + return NULL; + } + staging->paths = g_ptr_array_new_with_free_func(g_free); + staging->reference_count = 1; + return staging; +} +EvidenceStaging *evidence_staging_ref(EvidenceStaging *staging) +{ + if (staging != NULL) g_atomic_int_inc(&staging->reference_count); + return staging; +} +const char *evidence_staging_get_directory(const EvidenceStaging *staging) +{ + return staging != NULL ? staging->directory : NULL; +} +static char *detect_mime(const char *path, const char *name) +{ + gboolean uncertain = FALSE; + char header[512]; + gsize length = 0; + char *content = NULL; + char *mime; + if (!g_file_get_contents(path, &content, &length, NULL)) length = 0; + length = MIN(length, sizeof header); + if (length > 0) memcpy(header, content, length); + g_free(content); + mime = g_content_type_guess(name, + length > 0 ? (const guchar *) header : NULL, length, &uncertain); + (void) uncertain; + if (mime == NULL) return g_strdup("application/octet-stream"); + char *result = g_content_type_get_mime_type(mime); + g_free(mime); + return result != NULL ? result : g_strdup("application/octet-stream"); +} +EvidenceStagingResult *evidence_staging_prepare(EvidenceStaging *staging, + const char *source_path, GCancellable *cancellable, GError **error) +{ + struct stat metadata; + EvidenceStagingResult *result = NULL; + GFile *source = NULL, *destination = NULL; + char *source_hash = NULL, *copy_hash = NULL, *internal = NULL; + guint64 hashed_size = 0; + GDateTime *now = NULL; + if (staging == NULL || source_path == NULL || + g_file_test(source_path, G_FILE_TEST_IS_SYMLINK) || + g_stat(source_path, &metadata) != 0 || + !S_ISREG(metadata.st_mode)) { + g_set_error_literal(error, staging_error(), 1, + "Le fichier source est absent, spécial ou non autorisé."); + return NULL; + } + if ((guint64) metadata.st_size > EVIDENCE_STAGING_MAX_BYTES) { + g_set_error_literal(error, staging_error(), 2, + "Le fichier dépasse la taille maximale de staging."); + return NULL; + } + if (!file_hash_compute_sha256(source_path, cancellable, + &source_hash, &hashed_size, error)) goto cleanup; + { + char *identifier = g_uuid_string_random(); + internal = g_strdup_printf("%s.stage", identifier); + g_free(identifier); + } + result = g_new0(EvidenceStagingResult, 1); + result->source_path = g_canonicalize_filename(source_path, NULL); + result->staging_path = + g_build_filename(staging->directory, internal, NULL); + result->original_name = g_path_get_basename(source_path); + source = g_file_new_for_path(source_path); + destination = g_file_new_for_path(result->staging_path); + if (!g_file_copy(source, destination, G_FILE_COPY_NONE, + cancellable, NULL, NULL, error)) goto cleanup; + if (!file_hash_compute_sha256(result->staging_path, cancellable, + ©_hash, &hashed_size, error) || + g_strcmp0(source_hash, copy_hash) != 0) { + if (error != NULL && *error == NULL) + g_set_error_literal(error, staging_error(), 3, + "La copie de staging ne correspond pas à la source."); + goto cleanup; + } + result->mime_type = detect_mime(result->staging_path, + result->original_name); + result->suggested_type = g_strdup(suggest_type(result->mime_type)); + result->sha256 = g_steal_pointer(©_hash); + result->size_bytes = (guint64) metadata.st_size; + now = g_date_time_new_now_utc(); + result->prepared_at = g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ"); + g_ptr_array_add(staging->paths, g_strdup(result->staging_path)); +cleanup: + if (result != NULL && result->sha256 == NULL) { + g_unlink(result->staging_path); + evidence_staging_result_free(result); + result = NULL; + } + g_clear_object(&source); + g_clear_object(&destination); + g_clear_pointer(&now, g_date_time_unref); + g_free(source_hash); + g_free(copy_hash); + g_free(internal); + return result; +} +void evidence_staging_result_free(EvidenceStagingResult *result) +{ + if (result == NULL) return; + g_free(result->source_path); + g_free(result->staging_path); + g_free(result->original_name); + g_free(result->mime_type); + g_free(result->suggested_type); + g_free(result->sha256); + g_free(result->prepared_at); + g_free(result); +} +gboolean evidence_staging_remove(EvidenceStaging *staging, + const char *staging_path, GError **error) +{ + if (staging == NULL || staging_path == NULL || + !g_str_has_prefix(staging_path, staging->directory)) return FALSE; + if (g_unlink(staging_path) != 0 && errno != ENOENT) { + g_set_error(error, staging_error(), 4, + "Impossible de supprimer la copie de staging : %s.", + g_strerror(errno)); + return FALSE; + } + for (guint i = 0; i < staging->paths->len; i++) + if (g_strcmp0(g_ptr_array_index(staging->paths, i), + staging_path) == 0) { + g_ptr_array_remove_index(staging->paths, i); + break; + } + return TRUE; +} +gboolean evidence_staging_cleanup(EvidenceStaging *staging, GError **error) +{ + gboolean success = TRUE; + if (staging == NULL) return TRUE; + while (staging->paths->len > 0) { + char *path = g_strdup(g_ptr_array_index(staging->paths, 0)); + if (!evidence_staging_remove(staging, path, error)) success = FALSE; + g_free(path); + if (!success) break; + } + if (success && staging->directory != NULL && + g_rmdir(staging->directory) != 0 && errno != ENOENT) { + g_set_error(error, staging_error(), 5, + "Impossible de supprimer le répertoire de staging : %s.", + g_strerror(errno)); + success = FALSE; + } + return success; +} +void evidence_staging_free(EvidenceStaging *staging) +{ + if (staging == NULL) return; + if (!g_atomic_int_dec_and_test(&staging->reference_count)) return; + evidence_staging_cleanup(staging, NULL); + g_ptr_array_unref(staging->paths); + g_free(staging->directory); + g_free(staging); +} diff --git a/src/core/evidence_staging_task.c b/src/core/evidence_staging_task.c new file mode 100644 index 0000000..b1acd88 --- /dev/null +++ b/src/core/evidence_staging_task.c @@ -0,0 +1,66 @@ +#include "core/evidence_staging_task.h" + +typedef struct { + EvidenceStaging *staging; + GPtrArray *paths; +} StagingTaskRequest; +static void request_free(gpointer data) +{ + StagingTaskRequest *request = data; + if (request == NULL) return; + evidence_staging_free(request->staging); + g_ptr_array_unref(request->paths); + g_free(request); +} +static gboolean worker(BackgroundTask *task, GCancellable *cancellable, + gpointer data, gpointer *result, GError **error) +{ + StagingTaskRequest *request = data; + GPtrArray *prepared = g_ptr_array_new_with_free_func( + (GDestroyNotify) evidence_staging_result_free); + for (guint i = 0; i < request->paths->len; i++) { + EvidenceStagingResult *item; + if (g_cancellable_set_error_if_cancelled(cancellable, error)) { + g_ptr_array_unref(prepared); + return FALSE; + } + item = evidence_staging_prepare(request->staging, + g_ptr_array_index(request->paths, i), cancellable, error); + if (item == NULL) { + g_ptr_array_unref(prepared); + return FALSE; + } + g_ptr_array_add(prepared, item); + background_task_report_progress(task, + (double) (i + 1) / request->paths->len, + "Préparation des copies de staging"); + } + *result = prepared; + return TRUE; +} +BackgroundTask *evidence_staging_task_start(TaskManager *manager, + EvidenceStaging *staging, const GPtrArray *source_paths, + BackgroundTaskCompletionCallback callback, gpointer callback_data, + GDestroyNotify callback_data_destroy, GError **error) +{ + BackgroundTask *task; + StagingTaskRequest *request; + if (manager == NULL || staging == NULL || source_paths == NULL || + source_paths->len == 0) return NULL; + task = background_task_new("Préparation des preuves"); + request = g_new0(StagingTaskRequest, 1); + request->staging = evidence_staging_ref(staging); + request->paths = g_ptr_array_new_with_free_func(g_free); + for (guint i = 0; i < source_paths->len; i++) + g_ptr_array_add(request->paths, + g_strdup(g_ptr_array_index((GPtrArray *) source_paths, i))); + if (task == NULL || !task_manager_add(manager, task, error) || + !background_task_start(task, worker, request, request_free, + (GDestroyNotify) g_ptr_array_unref, callback, callback_data, + callback_data_destroy, error)) { + request_free(request); + background_task_unref(task); + return NULL; + } + return task; +} diff --git a/src/core/evidence_video_preview_controller.c b/src/core/evidence_video_preview_controller.c new file mode 100644 index 0000000..f979ef4 --- /dev/null +++ b/src/core/evidence_video_preview_controller.c @@ -0,0 +1,50 @@ +#include "core/evidence_video_preview_controller.h" +struct EvidenceVideoPreviewController { + gpointer owner; + gpointer media; + EvidenceVideoPreviewActions actions; +}; +EvidenceVideoPreviewController *evidence_video_preview_controller_new( + gpointer owner, const EvidenceVideoPreviewActions *actions) +{ + if (actions == NULL || actions->pause == NULL || + actions->detach == NULL || actions->release == NULL) return NULL; + EvidenceVideoPreviewController *controller = g_new0( + EvidenceVideoPreviewController, 1); + controller->owner = owner; controller->actions = *actions; + return controller; +} +void evidence_video_preview_controller_stop( + EvidenceVideoPreviewController *controller) +{ + if (controller == NULL || controller->media == NULL) return; + controller->actions.pause(controller->media); + if (controller->actions.seek_start != NULL) + controller->actions.seek_start(controller->media); + if (controller->owner != NULL) + controller->actions.detach(controller->owner); + controller->actions.release(controller->media); + controller->media = NULL; +} +void evidence_video_preview_controller_replace( + EvidenceVideoPreviewController *controller, gpointer media) +{ + if (controller == NULL) return; + evidence_video_preview_controller_stop(controller); + controller->media = media; +} +void evidence_video_preview_controller_free( + EvidenceVideoPreviewController *controller) +{ + if (controller == NULL) return; + evidence_video_preview_controller_stop(controller); g_free(controller); +} +void evidence_video_preview_controller_abandon_owner( + EvidenceVideoPreviewController *controller) +{ + if (controller == NULL) return; + controller->owner = NULL; +} +gboolean evidence_video_preview_controller_has_media( + const EvidenceVideoPreviewController *controller) +{ return controller != NULL && controller->media != NULL; } diff --git a/src/core/person_confirmation_summary.c b/src/core/person_confirmation_summary.c index dabe3bf..4c40ae3 100644 --- a/src/core/person_confirmation_summary.c +++ b/src/core/person_confirmation_summary.c @@ -76,3 +76,69 @@ char *person_confirmation_summary_build(const char *designation, g_free(short_sha); return g_string_free(summary, FALSE); } + +char *person_confirmation_summary_build_multiple(const char *designation, + const char *declared_name, const char *pseudonym, + const char *identification_status, gint confidence, const char *notes, + const GPtrArray *role_labels, + const PersonEvidenceSelection *selection) +{ + GString *summary = g_string_new(NULL); + guint existing_count = 0, staged_count = 0; + g_string_append_printf(summary, + "PERSONNE\nDésignation : %s\nNom déclaré : %s\nPseudonyme : %s\n" + "Statut d’identification : %s\nConfiance : %d %%\n" + "Notes factuelles : %s\n\nRÔLES\n", + optional(designation, "Non renseignée"), + optional(declared_name, "Non renseigné"), + optional(pseudonym, "Non renseigné"), + optional(identification_status, "Non renseigné"), + confidence, optional(notes, "Aucune")); + if (role_labels == NULL || role_labels->len == 0) + g_string_append(summary, "Aucun rôle sélectionné\n"); + else + for (guint i = 0; i < role_labels->len; i++) + g_string_append_printf(summary, "• %s\n", + optional(g_ptr_array_index((GPtrArray *) role_labels, i), + "Rôle non renseigné")); + g_string_append(summary, "\nPREUVES\n"); + if (person_evidence_selection_get_count(selection) == 0) + g_string_append(summary, "Aucune preuve retenue.\n"); + for (guint i = 0; i < person_evidence_selection_get_count(selection); + i++) { + const PersonEvidenceSelectionItem *item = + person_evidence_selection_get(selection, i); + char *size = g_format_size( + person_evidence_selection_item_get_size_bytes(item)); + char *short_sha = g_strndup( + person_evidence_selection_item_get_sha256(item), 12); + gboolean staged = person_evidence_selection_item_get_origin(item) == + PERSON_EVIDENCE_ORIGIN_STAGED; + if (staged) staged_count++; else existing_count++; + g_string_append_printf(summary, + "• %s\n Origine : %s\n Type métier : %s\n MIME : %s\n" + " Taille : %s\n SHA-256 : %s…\n Intégrité : Valide\n" + " Description : %s\n Statut : %s\n", + optional(person_evidence_selection_item_get_original_name(item), + "Non renseigné"), + staged ? "Nouvelle" : "Existante", + optional(person_evidence_selection_item_get_type_identifier(item), + "Non renseigné"), + optional(person_evidence_selection_item_get_mime_type(item), + "Non renseigné"), size, optional(short_sha, "Non renseigné"), + optional(person_evidence_selection_item_get_description(item), + "Aucune"), + staged ? "Prête à importer" : "Déjà importée"); + g_free(size); + g_free(short_sha); + } + g_string_append_printf(summary, + "\nAVERTISSEMENTS\n" + "%u nouvelle(s) preuve(s) à importer.\n" + "%u preuve(s) existante(s) à rattacher.\n" + "Aucune écriture n’a encore été effectuée.\n" + "L’annulation ne réalise aucun import définitif.\n" + "Aucun OCR ne sera lancé dans cette tranche.", + staged_count, existing_count); + return g_string_free(summary, FALSE); +} diff --git a/src/core/person_creation_coordinator.c b/src/core/person_creation_coordinator.c new file mode 100644 index 0000000..6f29104 --- /dev/null +++ b/src/core/person_creation_coordinator.c @@ -0,0 +1,276 @@ +#include "core/person_creation_coordinator.h" +#include "core/file_hash.h" +#include "dao/entity_dao.h" +#include "dao/evidence_dao.h" +#include "dao/evidence_entity_dao.h" +#include "dao/person_role_assignment_dao.h" +#include "database/transaction.h" +#include +#include +#include + +static GQuark coordinator_error(void) +{ + return g_quark_from_static_string("person-creation-coordinator-error"); +} +static gboolean cancelled(GCancellable *cancellable, GError **error) +{ + return cancellable != NULL && + g_cancellable_set_error_if_cancelled(cancellable, error); +} +static char *timestamp_now(void) +{ + GDateTime *now = g_date_time_new_now_utc(); + char *timestamp = now != NULL + ? g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ") : NULL; + g_clear_pointer(&now, g_date_time_unref); + return timestamp; +} +static char *safe_extension(const char *name) +{ + const char *dot = name != NULL ? strrchr(name, '.') : NULL; + if (dot == NULL || strlen(dot) > 12) return g_strdup(""); + for (const char *cursor = dot + 1; *cursor != '\0'; cursor++) + if (!g_ascii_isalnum(*cursor)) return g_strdup(""); + return g_ascii_strdown(dot, -1); +} +static gboolean copy_staged(const char *root, + const PersonEvidenceSelectionItem *item, char **out_relative, + char **out_internal, char **out_absolute, GCancellable *cancellable, + GError **error) +{ + const char *type = + person_evidence_selection_item_get_type_identifier(item); + char *extension = safe_extension( + person_evidence_selection_item_get_original_name(item)); + char *uuid = g_uuid_string_random(); + char *directory = g_build_filename( + root, "01_Preuves_Originales", type, NULL); + GFile *source = NULL, *destination = NULL; + char *computed = NULL; + guint64 computed_size = 0; + gboolean success = FALSE; + *out_internal = g_strconcat(uuid, extension, NULL); + *out_relative = g_build_filename("01_Preuves_Originales", type, + *out_internal, NULL); + *out_absolute = g_build_filename(root, *out_relative, NULL); + if (g_mkdir_with_parents(directory, 0700) != 0) { + g_set_error(error, coordinator_error(), 1, + "Impossible de préparer le dossier définitif : %s.", + g_strerror(errno)); + goto cleanup; + } + source = g_file_new_for_path( + person_evidence_selection_item_get_staging_path(item)); + destination = g_file_new_for_path(*out_absolute); + if (!g_file_copy(source, destination, G_FILE_COPY_NONE, cancellable, + NULL, NULL, error)) goto cleanup; + if (!file_hash_compute_sha256(*out_absolute, cancellable, + &computed, &computed_size, error) || + g_strcmp0(computed, + person_evidence_selection_item_get_sha256(item)) != 0) { + if (error != NULL && *error == NULL) + g_set_error_literal(error, coordinator_error(), 2, + "La copie définitive ne correspond pas au staging."); + goto cleanup; + } + success = TRUE; +cleanup: + if (!success && *out_absolute != NULL) g_unlink(*out_absolute); + g_clear_object(&source); + g_clear_object(&destination); + g_free(computed); + g_free(extension); + g_free(uuid); + g_free(directory); + return success; +} +static EntityRecord *build_person(const PersonEntityInput *input, + const char *identifier, const char *timestamp, GError **error) +{ + const char *value = input->declared_name != NULL && + input->declared_name[0] != '\0' ? input->declared_name : + input->pseudonym != NULL && input->pseudonym[0] != '\0' + ? input->pseudonym : input->designation; + char *description = g_strdup_printf( + "Statut d'identification : %s.%s%s%s%s", + input->identification_status, + input->pseudonym != NULL ? "\nPseudonyme déclaré : " : "", + input->pseudonym != NULL ? input->pseudonym : "", + input->notes != NULL ? "\nNotes factuelles : " : "", + input->notes != NULL ? input->notes : ""); + EntityRecord *record = entity_record_new(identifier, "person", value, + input->designation, description, input->confidence, + timestamp, timestamp, ENTITY_STATUS_ACTIVE, error); + g_free(description); + return record; +} +static gboolean validate_selection_files(const char *root, + const PersonEvidenceSelection *selection, GCancellable *cancellable, + GError **error) +{ + for (guint i = 0; i < person_evidence_selection_get_count(selection); + i++) { + const PersonEvidenceSelectionItem *item = + person_evidence_selection_get(selection, i); + const char *expected = + person_evidence_selection_item_get_sha256(item); + char *path = NULL, *computed = NULL; + guint64 size = 0; + if (person_evidence_selection_item_get_origin(item) == + PERSON_EVIDENCE_ORIGIN_EXISTING) { + const EvidenceRecord *record = + person_evidence_selection_item_get_record(item); + path = g_build_filename(root, + evidence_record_get_relative_path(record), NULL); + } else + path = g_strdup( + person_evidence_selection_item_get_staging_path(item)); + if (!file_hash_compute_sha256(path, cancellable, + &computed, &size, error) || + g_strcmp0(computed, expected) != 0 || + size != person_evidence_selection_item_get_size_bytes(item)) { + if (error != NULL && *error == NULL) + g_set_error_literal(error, coordinator_error(), 3, + "Une preuve a changé depuis sa sélection."); + g_free(path); g_free(computed); + return FALSE; + } + g_free(path); g_free(computed); + } + return TRUE; +} +PersonCreationCoordinatorResult *person_creation_coordinator_execute( + Database *database, const char *root, const PersonEntityInput *person, + const PersonEvidenceSelection *selection, GCancellable *cancellable, + GError **error) +{ + PersonCreationCoordinatorResult *result = NULL; + EntityDao *entity_dao = NULL; + EvidenceDao *evidence_dao = NULL; + EvidenceEntityDao *link_dao = NULL; + PersonRoleAssignmentDao *role_dao = NULL; + EntityRecord *person_record = NULL; + GPtrArray *created_paths = g_ptr_array_new_with_free_func(g_free); + char *timestamp = NULL; + gboolean transaction_active = FALSE, success = FALSE; + if (database == NULL || root == NULL || person == NULL || + person->designation == NULL || person->designation[0] == '\0' || + !person_evidence_selection_is_confirmable(selection)) { + g_set_error_literal(error, coordinator_error(), 1, + "La création de personne ou la sélection est invalide."); + goto cleanup; + } + if (cancelled(cancellable, error)) goto cleanup; + if (!validate_selection_files(root, selection, cancellable, error)) + goto cleanup; + timestamp = timestamp_now(); + result = g_new0(PersonCreationCoordinatorResult, 1); + result->person_identifier = g_uuid_string_random(); + result->evidence_identifiers = + g_ptr_array_new_with_free_func(g_free); + entity_dao = entity_dao_new(database, error); + evidence_dao = evidence_dao_new(database, error); + link_dao = evidence_entity_dao_new(database, error); + role_dao = person_role_assignment_dao_new(database, error); + person_record = build_person(person, result->person_identifier, + timestamp, error); + if (timestamp == NULL || entity_dao == NULL || evidence_dao == NULL || + link_dao == NULL || role_dao == NULL || person_record == NULL || + !database_transaction_begin(database)) goto cleanup; + transaction_active = TRUE; + if (!entity_dao_insert(entity_dao, person_record, error) || + !person_role_assignment_dao_insert_all(role_dao, + result->person_identifier, person->role_assignments, error)) + goto cleanup; + for (guint i = 0; i < person_evidence_selection_get_count(selection); + i++) { + const PersonEvidenceSelectionItem *item = + person_evidence_selection_get(selection, i); + const char *evidence_identifier = + person_evidence_selection_item_get_evidence_identifier(item); + char *identifier = NULL, *relative = NULL, *internal = NULL; + char *absolute = NULL, *duplicate_identifier = NULL; + EvidenceRecord *record = NULL; + if (cancelled(cancellable, error)) goto cleanup; + if (person_evidence_selection_item_get_origin(item) == + PERSON_EVIDENCE_ORIGIN_STAGED) { + duplicate_identifier = evidence_dao_find_identifier_by_sha256( + evidence_dao, + person_evidence_selection_item_get_sha256(item), error); + if (error != NULL && *error != NULL) { + g_free(duplicate_identifier); + goto cleanup; + } + if (duplicate_identifier != NULL) + evidence_identifier = duplicate_identifier; + else { + identifier = g_uuid_string_random(); + if (!copy_staged(root, item, &relative, &internal, &absolute, + cancellable, error)) { + g_free(identifier); g_free(relative); g_free(internal); + g_free(absolute); goto cleanup; + } + g_ptr_array_add(created_paths, g_strdup(absolute)); + record = evidence_record_new(identifier, + person_evidence_selection_item_get_original_name(item), + internal, relative, + person_evidence_selection_item_get_type_identifier(item), + person_evidence_selection_item_get_size_bytes(item), + person_evidence_selection_item_get_sha256(item), timestamp, + NULL, "Import depuis l’assistant de création d’une personne", + person_evidence_selection_item_get_description(item), + EVIDENCE_INTEGRITY_STATUS_VALID, error); + if (record == NULL || + !evidence_dao_insert(evidence_dao, record, error)) { + evidence_record_free(record); g_free(identifier); + g_free(relative); g_free(internal); g_free(absolute); + goto cleanup; + } + evidence_identifier = identifier; + } + } + if (!evidence_entity_dao_link(link_dao, evidence_identifier, + result->person_identifier, error)) { + evidence_record_free(record); g_free(identifier); + g_free(duplicate_identifier); + g_free(relative); g_free(internal); g_free(absolute); + goto cleanup; + } + g_ptr_array_add(result->evidence_identifiers, + g_strdup(evidence_identifier)); + evidence_record_free(record); + g_free(duplicate_identifier); + g_free(identifier); g_free(relative); g_free(internal); + g_free(absolute); + } + if (!database_transaction_commit(database)) goto cleanup; + transaction_active = FALSE; + success = TRUE; +cleanup: + if (!success && transaction_active) + database_transaction_rollback(database); + if (!success) + for (guint i = 0; i < created_paths->len; i++) + g_unlink(g_ptr_array_index(created_paths, i)); + g_ptr_array_unref(created_paths); + entity_record_free(person_record); + entity_dao_free(entity_dao); + evidence_dao_free(evidence_dao); + evidence_entity_dao_free(link_dao); + person_role_assignment_dao_free(role_dao); + g_free(timestamp); + if (!success) { + person_creation_coordinator_result_free(result); + result = NULL; + } + return result; +} +void person_creation_coordinator_result_free( + PersonCreationCoordinatorResult *result) +{ + if (result == NULL) return; + g_free(result->person_identifier); + g_clear_pointer(&result->evidence_identifiers, g_ptr_array_unref); + g_free(result); +} diff --git a/src/core/person_creation_task.c b/src/core/person_creation_task.c new file mode 100644 index 0000000..002c84c --- /dev/null +++ b/src/core/person_creation_task.c @@ -0,0 +1,98 @@ +#include "core/person_creation_task.h" + +struct PersonCreationTaskRequest { + char *database_path; + char *root; + PersonEntityInput person; + char *designation; + char *declared_name; + char *pseudonym; + char *status; + char *notes; + GPtrArray *roles; + PersonEvidenceSelection *selection; +}; +PersonCreationTaskRequest *person_creation_task_request_new( + const char *database_path, const char *root, + const PersonEntityInput *person, + const PersonEvidenceSelection *selection) +{ + PersonCreationTaskRequest *request; + if (database_path == NULL || root == NULL || person == NULL) return NULL; + request = g_new0(PersonCreationTaskRequest, 1); + request->database_path = g_strdup(database_path); + request->root = g_strdup(root); + request->designation = g_strdup(person->designation); + request->declared_name = g_strdup(person->declared_name); + request->pseudonym = g_strdup(person->pseudonym); + request->status = g_strdup(person->identification_status); + request->notes = g_strdup(person->notes); + request->roles = g_ptr_array_new_with_free_func( + (GDestroyNotify) person_role_assignment_input_free); + for (guint i = 0; person->role_assignments != NULL && + i < person->role_assignments->len; i++) + g_ptr_array_add(request->roles, + person_role_assignment_input_copy(g_ptr_array_index( + (GPtrArray *) person->role_assignments, i))); + request->person = *person; + request->person.designation = request->designation; + request->person.declared_name = request->declared_name; + request->person.pseudonym = request->pseudonym; + request->person.identification_status = request->status; + request->person.notes = request->notes; + request->person.evidence_identifier = NULL; + request->person.role_assignments = request->roles; + request->selection = person_evidence_selection_copy(selection); + return request; +} +void person_creation_task_request_free(PersonCreationTaskRequest *request) +{ + if (request == NULL) return; + g_free(request->database_path); g_free(request->root); + g_free(request->designation); g_free(request->declared_name); + g_free(request->pseudonym); g_free(request->status); + g_free(request->notes); g_ptr_array_unref(request->roles); + person_evidence_selection_free(request->selection); + g_free(request); +} +static gboolean worker(BackgroundTask *task, GCancellable *cancellable, + gpointer data, gpointer *result, GError **error) +{ + PersonCreationTaskRequest *request = data; + Database *database; + (void) task; + database = database_open(request->database_path); + if (database == NULL) { + g_set_error_literal(error, + g_quark_from_static_string("person-creation-task-error"), 1, + "Impossible d’ouvrir la base de l’enquête."); + return FALSE; + } + *result = person_creation_coordinator_execute(database, request->root, + &request->person, request->selection, cancellable, error); + database_close(database); + return *result != NULL; +} +BackgroundTask *person_creation_task_start(TaskManager *manager, + const PersonCreationTaskRequest *request, + BackgroundTaskCompletionCallback callback, gpointer callback_data, + GDestroyNotify callback_data_destroy, GError **error) +{ + BackgroundTask *task; + PersonCreationTaskRequest *copy; + if (manager == NULL || request == NULL) return NULL; + task = background_task_new("Création de la personne et import des preuves"); + copy = person_creation_task_request_new(request->database_path, + request->root, &request->person, request->selection); + if (task == NULL || copy == NULL || + !task_manager_add(manager, task, error) || + !background_task_start(task, worker, copy, + (GDestroyNotify) person_creation_task_request_free, + (GDestroyNotify) person_creation_coordinator_result_free, + callback, callback_data, callback_data_destroy, error)) { + person_creation_task_request_free(copy); + background_task_unref(task); + return NULL; + } + return task; +} diff --git a/src/dao/evidence_dao.c b/src/dao/evidence_dao.c index 9d91710..2b5841a 100644 --- a/src/dao/evidence_dao.c +++ b/src/dao/evidence_dao.c @@ -1562,6 +1562,38 @@ cleanup: return success; } +char *evidence_dao_find_identifier_by_sha256(EvidenceDao *evidence_dao, + const char *sha256, GError **error) +{ + static const char sql[] = + "SELECT id FROM preuves WHERE sha256=? ORDER BY imported_at,id LIMIT 1;"; + DatabaseStatement *statement; + char *identifier = NULL; + if (evidence_dao == NULL || sha256 == NULL || strlen(sha256) != 64) { + evidence_dao_set_error_literal(error, + EVIDENCE_DAO_ERROR_INVALID_ARGUMENT, + "L’empreinte recherchée est invalide."); + return NULL; + } + statement = database_statement_prepare(evidence_dao->database, sql); + if (statement == NULL || + !database_statement_bind_text(statement, 1, sha256)) { + evidence_dao_set_database_error(evidence_dao, error, + statement == NULL ? EVIDENCE_DAO_ERROR_PREPARE : + EVIDENCE_DAO_ERROR_BIND, + "Impossible de rechercher le doublon de preuve"); + database_statement_finalize(statement); + return NULL; + } + if (database_statement_step(statement) == DATABASE_STATEMENT_STEP_ROW && + !database_statement_column_text(statement, 0, &identifier)) + evidence_dao_set_database_error(evidence_dao, error, + EVIDENCE_DAO_ERROR_READ, + "Impossible de lire le doublon de preuve"); + database_statement_finalize(statement); + return identifier; +} + GPtrArray *evidence_dao_list_all( EvidenceDao *evidence_dao, GError **error diff --git a/src/models/evidence_selection_model.c b/src/models/evidence_selection_model.c index fc8131d..a01663b 100644 --- a/src/models/evidence_selection_model.c +++ b/src/models/evidence_selection_model.c @@ -136,3 +136,25 @@ GPtrArray *evidence_selection_model_list_type_codes( g_ptr_array_sort(types, compare_strings); return types; } +const EvidenceRecord *evidence_selection_model_find_by_sha256( + const EvidenceSelectionModel *model, const char *sha256) +{ + for (guint i = 0; model != NULL && sha256 != NULL && + i < model->records->len; i++) { + EvidenceRecord *record = g_ptr_array_index(model->records, i); + if (g_strcmp0(evidence_record_get_sha256(record), sha256) == 0) + return record; + } + return NULL; +} +const EvidenceRecord *evidence_selection_model_find_by_identifier( + const EvidenceSelectionModel *model, const char *identifier) +{ + for (guint i = 0; model != NULL && identifier != NULL && + i < model->records->len; i++) { + EvidenceRecord *record = g_ptr_array_index(model->records, i); + if (g_strcmp0(evidence_record_get_identifier(record), identifier) == 0) + return record; + } + return NULL; +} diff --git a/src/models/person_evidence_selection.c b/src/models/person_evidence_selection.c new file mode 100644 index 0000000..187411a --- /dev/null +++ b/src/models/person_evidence_selection.c @@ -0,0 +1,295 @@ +#include "models/person_evidence_selection.h" + +struct PersonEvidenceSelectionItem { + char *identifier; + PersonEvidenceOrigin origin; + PersonEvidenceState state; + EvidenceRecord *record; + char *evidence_identifier; + char *source_path; + char *staging_path; + char *original_name; + char *mime_type; + char *type_identifier; + char *sha256; + char *description; + char *prepared_at; + guint64 size_bytes; +}; +struct PersonEvidenceSelection { + GPtrArray *items; + char *active_identifier; +}; + +static GQuark selection_error(void) +{ + return g_quark_from_static_string("person-evidence-selection-error"); +} +static void item_free(gpointer data) +{ + PersonEvidenceSelectionItem *item = data; + if (item == NULL) return; + g_free(item->identifier); + evidence_record_free(item->record); + g_free(item->evidence_identifier); + g_free(item->source_path); + g_free(item->staging_path); + g_free(item->original_name); + g_free(item->mime_type); + g_free(item->type_identifier); + g_free(item->sha256); + g_free(item->description); + g_free(item->prepared_at); + g_free(item); +} +static PersonEvidenceSelectionItem *find_item( + const PersonEvidenceSelection *selection, const char *identifier) +{ + for (guint i = 0; selection != NULL && identifier != NULL && + i < selection->items->len; i++) { + PersonEvidenceSelectionItem *item = + g_ptr_array_index(selection->items, i); + if (g_strcmp0(item->identifier, identifier) == 0) return item; + } + return NULL; +} +static gboolean duplicate(const PersonEvidenceSelection *selection, + const char *evidence_identifier, const char *source_path, + const char *sha256) +{ + for (guint i = 0; selection != NULL && i < selection->items->len; i++) { + PersonEvidenceSelectionItem *item = + g_ptr_array_index(selection->items, i); + if ((evidence_identifier != NULL && + g_strcmp0(item->evidence_identifier, evidence_identifier) == 0) || + (source_path != NULL && + g_strcmp0(item->source_path, source_path) == 0) || + (sha256 != NULL && g_strcmp0(item->sha256, sha256) == 0)) + return TRUE; + } + return FALSE; +} +PersonEvidenceSelection *person_evidence_selection_new(void) +{ + PersonEvidenceSelection *selection = + g_new0(PersonEvidenceSelection, 1); + selection->items = g_ptr_array_new_with_free_func(item_free); + return selection; +} +PersonEvidenceSelection *person_evidence_selection_copy( + const PersonEvidenceSelection *source) +{ + PersonEvidenceSelection *copy = person_evidence_selection_new(); + for (guint i = 0; source != NULL && i < source->items->len; i++) { + PersonEvidenceSelectionItem *item = + g_ptr_array_index(source->items, i); + if (item->origin == PERSON_EVIDENCE_ORIGIN_EXISTING) { + if (person_evidence_selection_add_existing( + copy, item->record, NULL)) + person_evidence_selection_set_type(copy, item->identifier, + item->type_identifier); + } else + person_evidence_selection_add_staged(copy, item->source_path, + item->staging_path, item->original_name, item->mime_type, + item->type_identifier, item->size_bytes, item->sha256, + item->description, item->prepared_at, NULL); + } + if (source != NULL && source->active_identifier != NULL) { + PersonEvidenceSelectionItem *active = + find_item(source, source->active_identifier); + for (guint i = 0; active != NULL && i < copy->items->len; i++) { + PersonEvidenceSelectionItem *candidate = + g_ptr_array_index(copy->items, i); + if (g_strcmp0(candidate->evidence_identifier, + active->evidence_identifier) == 0 || + (candidate->source_path != NULL && + g_strcmp0(candidate->source_path, active->source_path) == 0)) + person_evidence_selection_set_active( + copy, candidate->identifier); + } + } + return copy; +} +void person_evidence_selection_free(PersonEvidenceSelection *selection) +{ + if (selection == NULL) return; + g_ptr_array_unref(selection->items); + g_free(selection->active_identifier); + g_free(selection); +} +guint person_evidence_selection_get_count( + const PersonEvidenceSelection *selection) +{ + return selection != NULL ? selection->items->len : 0; +} +const PersonEvidenceSelectionItem *person_evidence_selection_get( + const PersonEvidenceSelection *selection, guint index) +{ + return selection != NULL && index < selection->items->len + ? g_ptr_array_index(selection->items, index) : NULL; +} +const PersonEvidenceSelectionItem *person_evidence_selection_get_active( + const PersonEvidenceSelection *selection) +{ + return find_item(selection, + selection != NULL ? selection->active_identifier : NULL); +} +gboolean person_evidence_selection_set_active( + PersonEvidenceSelection *selection, const char *item_identifier) +{ + if (selection == NULL || + (item_identifier != NULL && + find_item(selection, item_identifier) == NULL)) return FALSE; + g_free(selection->active_identifier); + selection->active_identifier = g_strdup(item_identifier); + return TRUE; +} +gboolean person_evidence_selection_add_existing( + PersonEvidenceSelection *selection, const EvidenceRecord *record, + GError **error) +{ + PersonEvidenceSelectionItem *item; + const char *identifier; + if (selection == NULL || record == NULL || + (identifier = evidence_record_get_identifier(record)) == NULL) { + g_set_error_literal(error, selection_error(), 1, + "La preuve existante est invalide."); + return FALSE; + } + if (duplicate(selection, identifier, NULL, + evidence_record_get_sha256(record))) { + g_set_error_literal(error, selection_error(), 2, + "Cette preuve est déjà dans la sélection."); + return FALSE; + } + item = g_new0(PersonEvidenceSelectionItem, 1); + item->identifier = g_strdup(identifier); + item->origin = PERSON_EVIDENCE_ORIGIN_EXISTING; + item->state = PERSON_EVIDENCE_STATE_READY; + item->record = evidence_record_copy(record); + item->evidence_identifier = g_strdup(identifier); + item->original_name = g_strdup(evidence_record_get_original_name(record)); + item->mime_type = g_strdup(evidence_record_get_mime_type(record)); + item->type_identifier = + g_strdup(evidence_record_get_type_identifier(record)); + item->sha256 = g_strdup(evidence_record_get_sha256(record)); + item->description = g_strdup(evidence_record_get_description(record)); + item->size_bytes = evidence_record_get_size_bytes(record); + g_ptr_array_add(selection->items, item); + if (selection->active_identifier == NULL) + selection->active_identifier = g_strdup(item->identifier); + return TRUE; +} +gboolean person_evidence_selection_add_staged( + PersonEvidenceSelection *selection, const char *source_path, + const char *staging_path, const char *original_name, + const char *mime_type, const char *type_identifier, guint64 size_bytes, + const char *sha256, const char *description, const char *prepared_at, + GError **error) +{ + PersonEvidenceSelectionItem *item; + if (selection == NULL || source_path == NULL || staging_path == NULL || + original_name == NULL || mime_type == NULL || type_identifier == NULL || + sha256 == NULL || prepared_at == NULL) { + g_set_error_literal(error, selection_error(), 1, + "Le fichier préparé est invalide."); + return FALSE; + } + if (duplicate(selection, NULL, source_path, sha256)) { + g_set_error_literal(error, selection_error(), 2, + "Ce contenu est déjà dans la sélection."); + return FALSE; + } + item = g_new0(PersonEvidenceSelectionItem, 1); + item->identifier = g_uuid_string_random(); + item->origin = PERSON_EVIDENCE_ORIGIN_STAGED; + item->state = PERSON_EVIDENCE_STATE_READY; + item->source_path = g_strdup(source_path); + item->staging_path = g_strdup(staging_path); + item->original_name = g_strdup(original_name); + item->mime_type = g_strdup(mime_type); + item->type_identifier = g_strdup(type_identifier); + item->sha256 = g_strdup(sha256); + item->description = g_strdup(description); + item->prepared_at = g_strdup(prepared_at); + item->size_bytes = size_bytes; + g_ptr_array_add(selection->items, item); + if (selection->active_identifier == NULL) + selection->active_identifier = g_strdup(item->identifier); + return TRUE; +} +gboolean person_evidence_selection_remove(PersonEvidenceSelection *selection, + const char *item_identifier) +{ + for (guint i = 0; selection != NULL && item_identifier != NULL && + i < selection->items->len; i++) { + PersonEvidenceSelectionItem *item = + g_ptr_array_index(selection->items, i); + if (g_strcmp0(item->identifier, item_identifier) == 0) { + gboolean active = g_strcmp0(selection->active_identifier, + item_identifier) == 0; + g_ptr_array_remove_index(selection->items, i); + if (active) { + g_clear_pointer(&selection->active_identifier, g_free); + if (selection->items->len > 0) + selection->active_identifier = g_strdup( + ((PersonEvidenceSelectionItem *) g_ptr_array_index( + selection->items, MIN(i, + selection->items->len - 1)))->identifier); + } + return TRUE; + } + } + return FALSE; +} +gboolean person_evidence_selection_set_type(PersonEvidenceSelection *selection, + const char *item_identifier, const char *type_identifier) +{ + PersonEvidenceSelectionItem *item = find_item(selection, item_identifier); + if (item == NULL || type_identifier == NULL || + type_identifier[0] == '\0') return FALSE; + g_free(item->type_identifier); + item->type_identifier = g_strdup(type_identifier); + return TRUE; +} +gboolean person_evidence_selection_is_confirmable( + const PersonEvidenceSelection *selection) +{ + for (guint i = 0; selection != NULL && i < selection->items->len; i++) + if (((PersonEvidenceSelectionItem *) g_ptr_array_index( + selection->items, i))->state != PERSON_EVIDENCE_STATE_READY) + return FALSE; + return selection != NULL; +} +const char *person_evidence_selection_find_existing_by_sha256( + const PersonEvidenceSelection *selection, const char *sha256) +{ + for (guint i = 0; selection != NULL && sha256 != NULL && + i < selection->items->len; i++) { + PersonEvidenceSelectionItem *item = + g_ptr_array_index(selection->items, i); + if (item->origin == PERSON_EVIDENCE_ORIGIN_EXISTING && + g_strcmp0(item->sha256, sha256) == 0) + return item->evidence_identifier; + } + return NULL; +} +#define ITEM_GETTER(name, type, field) \ +type person_evidence_selection_item_get_##name( \ + const PersonEvidenceSelectionItem *item) { \ + return item != NULL ? item->field : (type) 0; \ +} +ITEM_GETTER(identifier, const char *, identifier) +ITEM_GETTER(origin, PersonEvidenceOrigin, origin) +ITEM_GETTER(state, PersonEvidenceState, state) +ITEM_GETTER(record, const EvidenceRecord *, record) +ITEM_GETTER(evidence_identifier, const char *, evidence_identifier) +ITEM_GETTER(source_path, const char *, source_path) +ITEM_GETTER(staging_path, const char *, staging_path) +ITEM_GETTER(original_name, const char *, original_name) +ITEM_GETTER(mime_type, const char *, mime_type) +ITEM_GETTER(type_identifier, const char *, type_identifier) +ITEM_GETTER(sha256, const char *, sha256) +ITEM_GETTER(description, const char *, description) +ITEM_GETTER(prepared_at, const char *, prepared_at) +ITEM_GETTER(size_bytes, guint64, size_bytes) diff --git a/src/views/create_person_dialog.c b/src/views/create_person_dialog.c index 5ba5017..cf91b63 100644 --- a/src/views/create_person_dialog.c +++ b/src/views/create_person_dialog.c @@ -3,10 +3,13 @@ * @brief Formulaire GTK de création d'une personne observée. ******************************************************************************/ #include "views/create_person_dialog.h" -#include "core/evidence_preview_task.h" +#include "core/evidence_staging.h" +#include "core/evidence_staging_task.h" #include "core/person_confirmation_summary.h" #include "core/person_dialog_lifecycle.h" #include "models/evidence_selection_model.h" +#include "models/person_evidence_selection.h" +#include "widgets/evidence_preview_widget.h" struct CreatePersonDialogResult { @@ -18,6 +21,8 @@ struct CreatePersonDialogResult char *notes; char *evidence_identifier; GPtrArray *role_assignments; + PersonEvidenceSelection *evidence_selection; + EvidenceStaging *staging; }; typedef struct { @@ -28,44 +33,57 @@ typedef struct GtkDropDown *status; GtkSpinButton *confidence; GtkDropDown *evidence; + GtkDropDown *retained; + GtkDropDown *retained_type; GtkDropDown *type_filter; GtkEntry *search; GtkTextView *notes; GtkLabel *error; - GtkLabel *metadata; - GtkLabel *preview_status; - GtkPicture *preview_picture; + EvidencePreviewWidget *preview; GtkLabel *progress; GtkLabel *summary; GtkStack *stack; GtkButton *previous; GtkButton *next; GtkButton *create; + GtkButton *add_existing; + GtkButton *remove_retained; + GtkButton *import_files; + GtkStringList *evidence_labels; + GtkStringList *retained_labels; + GtkStringList *type_filter_labels; GtkCheckButton *roles[9]; guint step; GPtrArray *evidence_identifiers; GPtrArray *visible_records; GPtrArray *type_codes; EvidenceSelectionModel *selection_model; + PersonEvidenceSelection *person_evidence_selection; + EvidenceStaging *staging; char *investigation_root_path; TaskManager *task_manager; - BackgroundTask *preview_task; + BackgroundTask *staging_task; PersonDialogLifecycle *lifecycle; CreatePersonDialogCallback callback; CreatePersonDialogSessionCheck session_check; gpointer user_data; GDestroyNotify user_data_destroy; + gulong evidence_selected_handler; + gulong retained_selected_handler; + gboolean updating_evidence; + gboolean updating_retained; } CreatePersonDialogState; typedef struct { GWeakRef window; - char *evidence_identifier; guint64 generation; -} CreatePersonPreviewContext; +} CreatePersonStagingContext; static const char *const status_codes[] = {"unknown", "suspected", "confirmed"}; static void create_person_dialog_clear_preview(CreatePersonDialogState *state); static void create_person_dialog_select_record(CreatePersonDialogState *state, - const EvidenceRecord *record); + const EvidenceRecord *record, const char *business_type); +static void create_person_dialog_on_retained_changed( + GtkDropDown *dropdown, GParamSpec *pspec, gpointer data); /** @brief Copie et nettoie un texte facultatif. */ static char *create_person_dialog_copy(const char *text) @@ -89,45 +107,392 @@ static void create_person_dialog_state_free(gpointer data) { CreatePersonDialogState *state = data; if (state == NULL) return; - if (state->preview_task != NULL) { - background_task_cancel(state->preview_task); - background_task_unref(state->preview_task); + g_clear_pointer(&state->preview, evidence_preview_widget_free); + if (state->staging_task != NULL) { + background_task_cancel(state->staging_task); + background_task_unref(state->staging_task); } person_dialog_lifecycle_cancel(state->lifecycle); g_clear_pointer(&state->evidence_identifiers, g_ptr_array_unref); g_clear_pointer(&state->visible_records, g_ptr_array_unref); g_clear_pointer(&state->type_codes, g_ptr_array_unref); g_clear_pointer(&state->selection_model, evidence_selection_model_free); + g_clear_pointer(&state->person_evidence_selection, + person_evidence_selection_free); + g_clear_pointer(&state->staging, evidence_staging_free); g_clear_pointer(&state->lifecycle, person_dialog_lifecycle_free); + g_clear_object(&state->evidence_labels); + g_clear_object(&state->retained_labels); + g_clear_object(&state->type_filter_labels); g_free(state->investigation_root_path); if (state->user_data_destroy != NULL) state->user_data_destroy(state->user_data); g_free(state); } +static void create_person_dialog_rebuild_retained( + CreatePersonDialogState *state) +{ + GPtrArray *labels = g_ptr_array_new_with_free_func(g_free); + const PersonEvidenceSelectionItem *active = + person_evidence_selection_get_active( + state->person_evidence_selection); + const char *active_identifier = active != NULL + ? person_evidence_selection_item_get_identifier(active) : NULL; + guint selected = GTK_INVALID_LIST_POSITION; + for (guint i = 0; i < person_evidence_selection_get_count( + state->person_evidence_selection); i++) { + const PersonEvidenceSelectionItem *item = + person_evidence_selection_get(state->person_evidence_selection, i); + char *label = g_strdup_printf("%s — %s — %s", + person_evidence_selection_item_get_original_name(item), + person_evidence_selection_item_get_origin(item) == + PERSON_EVIDENCE_ORIGIN_STAGED ? "nouvelle" : "existante", + person_evidence_selection_item_get_type_identifier(item)); + g_ptr_array_add(labels, label); + if (g_strcmp0(active_identifier, + person_evidence_selection_item_get_identifier(item)) == 0) + selected = i; + } + g_ptr_array_add(labels, NULL); + state->updating_retained = TRUE; + if (state->retained_selected_handler != 0) + g_signal_handler_block(state->retained, + state->retained_selected_handler); + gtk_string_list_splice(state->retained_labels, 0, + g_list_model_get_n_items(G_LIST_MODEL(state->retained_labels)), + (const char * const *) labels->pdata); + gtk_drop_down_set_selected(state->retained, selected); + if (state->retained_selected_handler != 0) + g_signal_handler_unblock(state->retained, + state->retained_selected_handler); + state->updating_retained = FALSE; + g_ptr_array_unref(labels); + if (selected != GTK_INVALID_LIST_POSITION) + create_person_dialog_on_retained_changed( + state->retained, NULL, state); +} + +static void create_person_dialog_on_add_existing( + GtkButton *button, gpointer data) +{ + CreatePersonDialogState *state = data; + const EvidenceRecord *record = + evidence_selection_model_get_selected(state->selection_model); + GError *error = NULL; + (void) button; + if (record == NULL && state->visible_records->len == 1) + record = g_ptr_array_index(state->visible_records, 0); + if (record == NULL) { + gtk_label_set_text(state->error, + "Sélectionnez d’abord une preuve existante."); + gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); + return; + } + if (!person_evidence_selection_add_existing( + state->person_evidence_selection, record, &error)) { + gtk_label_set_text(state->error, error->message); + gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); + } else { + gtk_widget_set_visible(GTK_WIDGET(state->error), FALSE); + create_person_dialog_rebuild_retained(state); + } + g_clear_error(&error); +} + +static void create_person_dialog_on_retained_changed( + GtkDropDown *dropdown, GParamSpec *pspec, gpointer data) +{ + CreatePersonDialogState *state = data; + guint selected = gtk_drop_down_get_selected(dropdown); + const PersonEvidenceSelectionItem *item = + person_evidence_selection_get(state->person_evidence_selection, + selected); + (void) pspec; + if (state->updating_retained || + selected == GTK_INVALID_LIST_POSITION || item == NULL) return; + person_evidence_selection_set_active(state->person_evidence_selection, + person_evidence_selection_item_get_identifier(item)); + static const char *const types[] = { + "screenshot", "photo", "video", "document", "email", + "archive", "audio", "text", "other"}; + for (guint i = 0; i < G_N_ELEMENTS(types); i++) + if (g_strcmp0(types[i], + person_evidence_selection_item_get_type_identifier(item)) == 0) + gtk_drop_down_set_selected(state->retained_type, i); + if (person_evidence_selection_item_get_origin(item) == + PERSON_EVIDENCE_ORIGIN_EXISTING) + create_person_dialog_select_record(state, + person_evidence_selection_item_get_record(item), + person_evidence_selection_item_get_type_identifier(item)); + else { + char *size = g_format_size( + person_evidence_selection_item_get_size_bytes(item)); + char *text = g_strdup_printf( + "Nom : %s\nOrigine : nouvelle (staging)\nMIME : %s\n" + "Type : %s\nTaille : %s\nSHA-256 : %.12s…", + person_evidence_selection_item_get_original_name(item), + person_evidence_selection_item_get_mime_type(item), + person_evidence_selection_item_get_type_identifier(item), size, + person_evidence_selection_item_get_sha256(item)); + create_person_dialog_clear_preview(state); + { + char *staging_root = g_path_get_dirname( + person_evidence_selection_item_get_staging_path(item)); + char *staging_name = g_path_get_basename( + person_evidence_selection_item_get_staging_path(item)); + EvidencePreviewRequest *request = evidence_preview_request_new( + staging_root, + person_evidence_selection_item_get_identifier(item), + staging_name, + person_evidence_selection_item_get_sha256(item), + person_evidence_selection_item_get_mime_type(item), + person_dialog_lifecycle_get_generation(state->lifecycle)); + evidence_preview_widget_show(state->preview, request, text); + evidence_preview_request_free(request); + g_free(staging_root); g_free(staging_name); + } + g_free(text); g_free(size); + } +} + +static void create_person_dialog_on_retained_type_changed( + GtkDropDown *dropdown, GParamSpec *pspec, gpointer data) +{ + static const char *const types[] = { + "screenshot", "photo", "video", "document", "email", + "archive", "audio", "text", "other"}; + CreatePersonDialogState *state = data; + guint selected = gtk_drop_down_get_selected(dropdown); + const PersonEvidenceSelectionItem *item = + person_evidence_selection_get_active( + state->person_evidence_selection); + (void) pspec; + if (item != NULL && selected < G_N_ELEMENTS(types)) { + person_evidence_selection_set_type(state->person_evidence_selection, + person_evidence_selection_item_get_identifier(item), + types[selected]); + create_person_dialog_rebuild_retained(state); + } +} + +static void create_person_dialog_on_remove_retained( + GtkButton *button, gpointer data) +{ + CreatePersonDialogState *state = data; + const PersonEvidenceSelectionItem *item = + person_evidence_selection_get_active( + state->person_evidence_selection); + char *identifier = item != NULL ? g_strdup( + person_evidence_selection_item_get_identifier(item)) : NULL; + char *staging_path = item != NULL && + person_evidence_selection_item_get_origin(item) == + PERSON_EVIDENCE_ORIGIN_STAGED + ? g_strdup(person_evidence_selection_item_get_staging_path(item)) + : NULL; + (void) button; + if (identifier != NULL) { + person_evidence_selection_remove( + state->person_evidence_selection, identifier); + if (staging_path != NULL) + evidence_staging_remove(state->staging, staging_path, NULL); + create_person_dialog_rebuild_retained(state); + create_person_dialog_clear_preview(state); + } + g_free(identifier); g_free(staging_path); +} + +static void staging_context_free(gpointer data) +{ + CreatePersonStagingContext *context = data; + if (context == NULL) return; + g_weak_ref_clear(&context->window); + g_free(context); +} +static void create_person_dialog_staging_completed( + BackgroundTask *task, gpointer data) +{ + CreatePersonStagingContext *context = data; + GtkWindow *window = g_weak_ref_get(&context->window); + CreatePersonDialogState *state = window != NULL + ? g_object_get_data(G_OBJECT(window), "person-dialog-state") : NULL; + GPtrArray *prepared = background_task_get_result(task); + if (state != NULL && state->session_check != NULL && + !state->session_check(state->user_data)) { + person_dialog_lifecycle_cancel(state->lifecycle); + evidence_staging_cleanup(state->staging, NULL); + gtk_label_set_text(state->error, + "Préparation annulée : l’enquête active a changé."); + gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); + } else if (state != NULL && + person_dialog_lifecycle_accepts_preview( + state->lifecycle, context->generation) && prepared != NULL) { + for (guint i = 0; i < prepared->len; i++) { + EvidenceStagingResult *item = g_ptr_array_index(prepared, i); + const EvidenceRecord *existing = + evidence_selection_model_find_by_sha256( + state->selection_model, item->sha256); + GError *error = NULL; + if (existing != NULL) { + if (!person_evidence_selection_add_existing( + state->person_evidence_selection, + existing, &error) && error != NULL && + error->code != 2) + gtk_label_set_text(state->error, error->message); + evidence_staging_remove(state->staging, + item->staging_path, NULL); + gtk_label_set_text(state->error, + "Doublon détecté : la preuve existante a été réutilisée."); + gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); + } else if (!person_evidence_selection_add_staged( + state->person_evidence_selection, + item->source_path, item->staging_path, + item->original_name, item->mime_type, + item->suggested_type, item->size_bytes, item->sha256, + NULL, item->prepared_at, &error)) { + evidence_staging_remove(state->staging, + item->staging_path, NULL); + gtk_label_set_text(state->error, + error != NULL ? error->message : + "Doublon refusé dans la sélection."); + gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); + } + g_clear_error(&error); + } + create_person_dialog_rebuild_retained(state); + } else if (state != NULL) { + GError *error = background_task_dup_error(task); + gtk_label_set_text(state->error, + error != NULL ? error->message : + "La préparation des preuves a échoué."); + gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); + g_clear_error(&error); + } + if (state != NULL && state->staging_task == task) { + background_task_unref(state->staging_task); + state->staging_task = NULL; + } + g_clear_object(&window); +} +static void create_person_dialog_files_selected( + GObject *source, GAsyncResult *async_result, gpointer data) +{ + CreatePersonStagingContext *selection_context = data; + GtkWindow *window = g_weak_ref_get(&selection_context->window); + CreatePersonDialogState *state = window != NULL + ? g_object_get_data(G_OBJECT(window), "person-dialog-state") : NULL; + GListModel *files; + GPtrArray *paths; + GError *error = NULL; + CreatePersonStagingContext *context; + files = gtk_file_dialog_open_multiple_finish( + GTK_FILE_DIALOG(source), async_result, &error); + if (state == NULL) { + g_clear_object(&files); + g_clear_error(&error); + staging_context_free(selection_context); + g_clear_object(&window); + return; + } + if (files == NULL) { + if (!g_error_matches(error, GTK_DIALOG_ERROR, + GTK_DIALOG_ERROR_DISMISSED)) { + gtk_label_set_text(state->error, error->message); + gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); + } + g_clear_error(&error); + staging_context_free(selection_context); + g_clear_object(&window); + return; + } + paths = g_ptr_array_new_with_free_func(g_free); + for (guint i = 0; i < g_list_model_get_n_items(files); i++) { + GFile *file = g_list_model_get_item(files, i); + char *path = g_file_get_path(file); + if (path != NULL) g_ptr_array_add(paths, path); + g_object_unref(file); + } + g_object_unref(files); + if (paths->len == 0) { + g_ptr_array_unref(paths); + staging_context_free(selection_context); + g_clear_object(&window); + return; + } + context = g_new0(CreatePersonStagingContext, 1); + g_weak_ref_init(&context->window, G_OBJECT(state->window)); + context->generation = + person_dialog_lifecycle_begin_preview(state->lifecycle); + state->staging_task = evidence_staging_task_start(state->task_manager, + state->staging, paths, create_person_dialog_staging_completed, + context, staging_context_free, &error); + if (state->staging_task == NULL) { + staging_context_free(context); + gtk_label_set_text(state->error, + error != NULL ? error->message : + "Impossible de démarrer le staging."); + gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); + } + g_clear_error(&error); + g_ptr_array_unref(paths); + staging_context_free(selection_context); + g_clear_object(&window); +} +static void create_person_dialog_on_import_files( + GtkButton *button, gpointer data) +{ + CreatePersonDialogState *state = data; + GtkFileDialog *dialog; + CreatePersonStagingContext *context; + (void) button; + if (state->staging_task != NULL) return; + dialog = gtk_file_dialog_new(); + gtk_file_dialog_set_title(dialog, + "Importer des fichiers dans le staging"); + context = g_new0(CreatePersonStagingContext, 1); + g_weak_ref_init(&context->window, G_OBJECT(state->window)); + gtk_file_dialog_open_multiple(dialog, state->window, NULL, + create_person_dialog_files_selected, context); + g_object_unref(dialog); +} + static void create_person_dialog_rebuild_evidence(CreatePersonDialogState *state) { - GtkStringList *labels = gtk_string_list_new(NULL); + GPtrArray *labels = g_ptr_array_new_with_free_func(g_free); const EvidenceRecord *selected = evidence_selection_model_get_selected(state->selection_model); const char *selected_id = selected != NULL ? evidence_record_get_identifier(selected) : NULL; guint selected_index = 0; - gtk_string_list_append(labels, "Aucune preuve associée"); + g_ptr_array_add(labels, g_strdup("Aucune preuve associée")); + g_ptr_array_set_size(state->evidence_identifiers, 0); g_clear_pointer(&state->visible_records, g_ptr_array_unref); state->visible_records = evidence_selection_model_list_visible(state->selection_model); for (guint i = 0; i < state->visible_records->len; i++) { EvidenceRecord *record = g_ptr_array_index(state->visible_records, i); - gtk_string_list_append(labels, - evidence_record_get_original_name(record)); + g_ptr_array_add(labels, + g_strdup(evidence_record_get_original_name(record))); + g_ptr_array_add(state->evidence_identifiers, + g_strdup(evidence_record_get_identifier(record))); if (g_strcmp0(selected_id, evidence_record_get_identifier(record)) == 0) selected_index = i + 1; } - gtk_drop_down_set_model(state->evidence, G_LIST_MODEL(labels)); + g_ptr_array_add(labels, NULL); + state->updating_evidence = TRUE; + if (state->evidence_selected_handler != 0) + g_signal_handler_block(state->evidence, + state->evidence_selected_handler); + gtk_string_list_splice(state->evidence_labels, 0, + g_list_model_get_n_items(G_LIST_MODEL(state->evidence_labels)), + (const char * const *) labels->pdata); gtk_drop_down_set_selected(state->evidence, selected_index); - g_object_unref(labels); + if (state->evidence_selected_handler != 0) + g_signal_handler_unblock(state->evidence, + state->evidence_selected_handler); + state->updating_evidence = FALSE; + g_ptr_array_unref(labels); if (selected_index == 0) { evidence_selection_model_select(state->selection_model, NULL); create_person_dialog_clear_preview(state); @@ -160,29 +525,28 @@ static void create_person_dialog_on_evidence_changed( { CreatePersonDialogState *state = data; guint selected = gtk_drop_down_get_selected(dropdown); + const char *identifier = NULL; + const EvidenceRecord *record = NULL; (void) pspec; - create_person_dialog_select_record(state, - selected > 0 && selected - 1 < state->visible_records->len - ? g_ptr_array_index(state->visible_records, selected - 1) : NULL); + if (state->updating_evidence) return; + if (selected != GTK_INVALID_LIST_POSITION && selected > 0 && + selected - 1 < state->evidence_identifiers->len) + identifier = g_ptr_array_index( + state->evidence_identifiers, selected - 1); + if (identifier != NULL) + record = evidence_selection_model_find_by_identifier( + state->selection_model, identifier); + create_person_dialog_select_record(state, record, NULL); } /** @brief Termine le dialogue comme une annulation. */ static void create_person_dialog_cancel(CreatePersonDialogState *state) { if (state == NULL || !person_dialog_lifecycle_cancel(state->lifecycle)) return; - if (state->preview_task != NULL) background_task_cancel(state->preview_task); + evidence_preview_widget_cancel(state->preview); if (state->callback != NULL) state->callback(NULL, state->user_data); } -static void preview_context_free(gpointer data) -{ - CreatePersonPreviewContext *context = data; - if (context == NULL) return; - g_weak_ref_clear(&context->window); - g_free(context->evidence_identifier); - g_free(context); -} - static const char *integrity_label(EvidenceIntegrityStatus status) { switch (status) { @@ -196,72 +560,17 @@ static const char *integrity_label(EvidenceIntegrityStatus status) static void create_person_dialog_clear_preview(CreatePersonDialogState *state) { - if (state->preview_task != NULL) { - background_task_cancel(state->preview_task); - background_task_unref(state->preview_task); - state->preview_task = NULL; - } person_dialog_lifecycle_begin_preview(state->lifecycle); - gtk_picture_set_paintable(state->preview_picture, NULL); - gtk_label_set_text(state->preview_status, "Aucune preuve sélectionnée."); - gtk_label_set_text(state->metadata, ""); -} - -static void create_person_dialog_preview_completed( - BackgroundTask *task, gpointer data) -{ - CreatePersonPreviewContext *context = data; - GtkWindow *window = g_weak_ref_get(&context->window); - CreatePersonDialogState *state = window != NULL - ? g_object_get_data(G_OBJECT(window), "person-dialog-state") : NULL; - EvidencePreviewResult *result = background_task_get_result(task); - if (state != NULL && state->session_check != NULL && - !state->session_check(state->user_data)) { - person_dialog_lifecycle_cancel(state->lifecycle); - gtk_label_set_text(state->preview_status, - "Aperçu annulé : l'enquête active a changé."); - gtk_widget_set_sensitive(GTK_WIDGET(state->create), FALSE); - } else if (state != NULL && - person_dialog_lifecycle_accepts_preview( - state->lifecycle, context->generation) && - evidence_preview_result_matches(result, context->evidence_identifier, - context->generation)) { - GError *error = NULL; - GdkTexture *texture = gdk_texture_new_from_bytes( - result->png_bytes, &error); - if (texture != NULL) { - gtk_picture_set_paintable(state->preview_picture, - GDK_PAINTABLE(texture)); - gtk_label_set_text(state->preview_status, "Aperçu intègre."); - g_object_unref(texture); - } else { - gtk_label_set_text(state->preview_status, - error != NULL ? error->message : "Aperçu illisible."); - } - g_clear_error(&error); - } else if (state != NULL && - person_dialog_lifecycle_accepts_preview( - state->lifecycle, context->generation)) { - GError *error = background_task_dup_error(task); - gtk_label_set_text(state->preview_status, - error != NULL ? error->message : "Aperçu annulé."); - g_clear_error(&error); - } - if (state != NULL && state->preview_task == task) { - background_task_unref(state->preview_task); - state->preview_task = NULL; - } - g_clear_object(&window); + evidence_preview_widget_clear(state->preview); } static void create_person_dialog_select_record(CreatePersonDialogState *state, - const EvidenceRecord *record) + const EvidenceRecord *record, const char *business_type) { char *size = NULL, *sha = NULL, *text = NULL; const char *mime; - CreatePersonPreviewContext *context; + const char *type_identifier; EvidencePreviewRequest *request; - GError *error = NULL; create_person_dialog_clear_preview(state); if (record == NULL) return; evidence_selection_model_select(state->selection_model, @@ -269,48 +578,25 @@ static void create_person_dialog_select_record(CreatePersonDialogState *state, size = g_format_size(evidence_record_get_size_bytes(record)); sha = g_strndup(evidence_record_get_sha256(record), 12); mime = evidence_record_get_mime_type(record); + type_identifier = business_type != NULL ? business_type : + evidence_record_get_type_identifier(record); text = g_strdup_printf( "Nom : %s\nType : %s (%s)\nMIME : %s\nTaille : %s\n" "Import : %s\nSHA-256 : %s…\nIntégrité : %s\nDescription : %s", evidence_record_get_original_name(record), - evidence_record_get_type_label(record), - evidence_record_get_type_identifier(record), + type_identifier, type_identifier, mime != NULL ? mime : "Non renseigné", size, evidence_record_get_imported_at(record), sha, integrity_label(evidence_record_get_integrity_status(record)), evidence_record_get_description(record) != NULL ? evidence_record_get_description(record) : "Aucune"); - gtk_label_set_text(state->metadata, text); - if (mime == NULL || !(g_str_equal(mime, "image/png") || - g_str_equal(mime, "image/jpeg"))) { - gtk_label_set_text(state->preview_status, - "Aperçu indisponible pour ce format."); - goto cleanup; - } - gtk_label_set_text(state->preview_status, - "Vérification et chargement en cours…"); request = evidence_preview_request_new(state->investigation_root_path, evidence_record_get_identifier(record), evidence_record_get_relative_path(record), evidence_record_get_sha256(record), mime, person_dialog_lifecycle_get_generation(state->lifecycle)); - context = g_new0(CreatePersonPreviewContext, 1); - g_weak_ref_init(&context->window, G_OBJECT(state->window)); - context->evidence_identifier = - g_strdup(evidence_record_get_identifier(record)); - context->generation = - person_dialog_lifecycle_get_generation(state->lifecycle); - state->preview_task = evidence_preview_task_start(state->task_manager, - request, create_person_dialog_preview_completed, context, - preview_context_free, &error); + evidence_preview_widget_show(state->preview, request, text); evidence_preview_request_free(request); - if (state->preview_task == NULL) { - preview_context_free(context); - gtk_label_set_text(state->preview_status, - error != NULL ? error->message : "Aperçu impossible."); - } - g_clear_error(&error); -cleanup: g_free(size); g_free(sha); g_free(text); } /** @brief Traite la fermeture native. */ @@ -331,8 +617,6 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) CreatePersonDialogResult *result = NULL; char *notes = NULL; guint status = gtk_drop_down_get_selected(state->status); - const EvidenceRecord *selected_record = - evidence_selection_model_get_selected(state->selection_model); (void) button; if (state->session_check != NULL && !state->session_check(state->user_data)) { @@ -357,9 +641,14 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) gtk_editable_get_text(GTK_EDITABLE(state->pseudonym))); result->status = g_strdup(status_codes[status]); result->notes = create_person_dialog_copy(notes); - if (selected_record != NULL) + if (person_evidence_selection_get_count( + state->person_evidence_selection) > 0) { + const PersonEvidenceSelectionItem *first = + person_evidence_selection_get( + state->person_evidence_selection, 0); result->evidence_identifier = g_strdup( - evidence_record_get_identifier(selected_record)); + person_evidence_selection_item_get_evidence_identifier(first)); + } result->input.designation = result->designation; result->input.declared_name = result->declared_name; result->input.pseudonym = result->pseudonym; @@ -385,6 +674,10 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) person_role_assignment_input_copy(&assignment)); } result->input.role_assignments = result->role_assignments; + result->evidence_selection = state->person_evidence_selection; + state->person_evidence_selection = NULL; + result->staging = state->staging; + state->staging = NULL; if (!person_dialog_lifecycle_complete(state->lifecycle)) { create_person_dialog_result_free(result); g_free(notes); @@ -399,7 +692,7 @@ static void create_person_dialog_update_navigation(CreatePersonDialogState *stat { static const char *const pages[] = {"person", "roles", "evidence", "summary"}; static const char *const steps[] = { - "1 Personne", "2 Rôles", "3 Preuve", "4 Confirmation"}; + "1 Personne", "2 Rôles", "3 Preuves", "4 Confirmation"}; GString *progress = g_string_new(NULL); gtk_stack_set_visible_child_name(state->stack, pages[state->step]); for (guint i = 0; i < G_N_ELEMENTS(steps); i++) { @@ -417,15 +710,13 @@ static void create_person_dialog_update_navigation(CreatePersonDialogState *stat static const char *const status_labels[] = { "Inconnu", "Présumé", "Confirmé"}; GPtrArray *role_labels = g_ptr_array_new(); - const EvidenceRecord *evidence = - evidence_selection_model_get_selected(state->selection_model); char *notes = create_person_dialog_get_notes(state); char *text; for (guint i = 0; i < G_N_ELEMENTS(state->roles); i++) if (gtk_check_button_get_active(state->roles[i])) g_ptr_array_add(role_labels, (gpointer) gtk_check_button_get_label(state->roles[i])); - text = person_confirmation_summary_build( + text = person_confirmation_summary_build_multiple( gtk_editable_get_text(GTK_EDITABLE(state->designation)), gtk_editable_get_text(GTK_EDITABLE(state->name)), gtk_editable_get_text(GTK_EDITABLE(state->pseudonym)), @@ -434,7 +725,7 @@ static void create_person_dialog_update_navigation(CreatePersonDialogState *stat ? status_labels[gtk_drop_down_get_selected(state->status)] : "Non renseigné", gtk_spin_button_get_value_as_int(state->confidence), notes, - role_labels, evidence); + role_labels, state->person_evidence_selection); gtk_label_set_text(state->summary, text); g_free(text); g_free(notes); @@ -443,6 +734,10 @@ static void create_person_dialog_update_navigation(CreatePersonDialogState *stat gtk_widget_set_sensitive(GTK_WIDGET(state->previous), state->step > 0); gtk_widget_set_visible(GTK_WIDGET(state->next), state->step < 3); gtk_widget_set_visible(GTK_WIDGET(state->create), state->step == 3); + gtk_widget_set_sensitive(GTK_WIDGET(state->create), + state->step == 3 && + person_evidence_selection_is_confirmable( + state->person_evidence_selection)); } static void create_person_dialog_on_previous(GtkButton *button, gpointer data) { @@ -484,7 +779,6 @@ gboolean create_person_dialog_present(GtkWindow *parent, CreatePersonDialogState *state = NULL; GtkWidget *box = NULL, *grid = NULL, *actions = NULL, *cancel = NULL; GtkWidget *roles_box = NULL, *evidence_box = NULL, *summary = NULL; - GtkStringList *labels = NULL, *type_labels = NULL; if (parent == NULL || investigation_root_path == NULL || task_manager == NULL) return FALSE; state = g_new0(CreatePersonDialogState, 1); @@ -492,6 +786,13 @@ gboolean create_person_dialog_present(GtkWindow *parent, state->user_data = data; state->user_data_destroy = data_destroy; state->evidence_identifiers = g_ptr_array_new_with_free_func(g_free); state->selection_model = evidence_selection_model_new(records); + state->person_evidence_selection = person_evidence_selection_new(); + state->staging = evidence_staging_new(NULL); + if (state->selection_model == NULL || + state->person_evidence_selection == NULL || state->staging == NULL) { + create_person_dialog_state_free(state); + return FALSE; + } state->visible_records = evidence_selection_model_list_visible(state->selection_model); state->type_codes = @@ -505,7 +806,7 @@ gboolean create_person_dialog_present(GtkWindow *parent, gtk_window_set_title(state->window, "Ajouter une personne"); gtk_window_set_transient_for(state->window, parent); gtk_window_set_modal(state->window, TRUE); - gtk_window_set_default_size(state->window, 580, 500); + gtk_window_set_default_size(state->window, 820, 680); box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); gtk_widget_set_margin_start(box, 16); gtk_widget_set_margin_end(box, 16); gtk_widget_set_margin_top(box, 16); gtk_widget_set_margin_bottom(box, 16); @@ -519,35 +820,36 @@ gboolean create_person_dialog_present(GtkWindow *parent, gtk_drop_down_set_selected(state->status, 1); state->confidence = GTK_SPIN_BUTTON(gtk_spin_button_new_with_range(0, 100, 5)); gtk_spin_button_set_value(state->confidence, 30); - labels = gtk_string_list_new(NULL); - gtk_string_list_append(labels, "Aucune preuve associée"); + state->evidence_labels = gtk_string_list_new(NULL); + gtk_string_list_append(state->evidence_labels, + "Aucune preuve associée"); for (guint i = 0; i < state->visible_records->len; i++) { EvidenceRecord *record = g_ptr_array_index(state->visible_records, i); const char *id = evidence_record_get_identifier(record); const char *name = evidence_record_get_original_name(record); if (id == NULL || name == NULL) continue; - gtk_string_list_append(labels, name); + gtk_string_list_append(state->evidence_labels, name); g_ptr_array_add(state->evidence_identifiers, g_strdup(id)); } - state->evidence = GTK_DROP_DOWN(gtk_drop_down_new(G_LIST_MODEL(labels), NULL)); - labels = NULL; + state->evidence = GTK_DROP_DOWN(gtk_drop_down_new( + G_LIST_MODEL(g_object_ref(state->evidence_labels)), NULL)); state->search = GTK_ENTRY(gtk_entry_new()); gtk_entry_set_placeholder_text(state->search, "Rechercher par nom, description ou type"); - type_labels = gtk_string_list_new(NULL); - gtk_string_list_append(type_labels, "Tous les types"); + state->type_filter_labels = gtk_string_list_new(NULL); + gtk_string_list_append(state->type_filter_labels, "Tous les types"); for (guint i = 0; i < state->type_codes->len; i++) - gtk_string_list_append(type_labels, + gtk_string_list_append(state->type_filter_labels, g_ptr_array_index(state->type_codes, i)); state->type_filter = GTK_DROP_DOWN( - gtk_drop_down_new(G_LIST_MODEL(type_labels), NULL)); + gtk_drop_down_new(G_LIST_MODEL( + g_object_ref(state->type_filter_labels)), NULL)); /* * gtk_drop_down_new() prend la propriété complète du modèle. Une * libération ici laisserait les vues internes du GtkDropDown avec un * GListModel détruit et ferait échouer leur nettoyage à la fermeture. */ - type_labels = NULL; state->notes = GTK_TEXT_VIEW(gtk_text_view_new()); gtk_text_view_set_wrap_mode(state->notes, GTK_WRAP_WORD_CHAR); gtk_widget_set_size_request(GTK_WIDGET(state->notes), -1, 90); @@ -574,24 +876,63 @@ gboolean create_person_dialog_present(GtkWindow *parent, gtk_stack_add_titled(state->stack, roles_box, "roles", "2 — Rôles"); evidence_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); gtk_box_append(GTK_BOX(evidence_box), gtk_label_new( - "Sélectionnez une preuve existante (aucun import dans cette version).")); + "Sélectionnez des preuves existantes et/ou préparez de nouveaux fichiers.")); gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->search)); gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->type_filter)); gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->evidence)); - state->metadata = GTK_LABEL(gtk_label_new("")); - gtk_label_set_xalign(state->metadata, 0.0f); - gtk_label_set_selectable(state->metadata, TRUE); - state->preview_status = GTK_LABEL(gtk_label_new( - "Aucune preuve sélectionnée.")); - state->preview_picture = GTK_PICTURE(gtk_picture_new()); - gtk_picture_set_can_shrink(state->preview_picture, TRUE); - gtk_picture_set_content_fit(state->preview_picture, - GTK_CONTENT_FIT_CONTAIN); - gtk_widget_set_size_request(GTK_WIDGET(state->preview_picture), 480, 260); - gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->metadata)); - gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->preview_status)); - gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->preview_picture)); - gtk_stack_add_titled(state->stack, evidence_box, "evidence", "3 — Preuve"); + { + GtkWidget *evidence_actions = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + state->add_existing = GTK_BUTTON( + gtk_button_new_with_label("Ajouter à la sélection")); + state->import_files = GTK_BUTTON( + gtk_button_new_with_label("Importer des fichiers")); + gtk_widget_set_tooltip_text(GTK_WIDGET(state->add_existing), + "Retenir la preuve existante affichée"); + gtk_widget_set_tooltip_text(GTK_WIDGET(state->import_files), + "Créer des copies temporaires sans import définitif"); + gtk_box_append(GTK_BOX(evidence_actions), + GTK_WIDGET(state->add_existing)); + gtk_box_append(GTK_BOX(evidence_actions), + GTK_WIDGET(state->import_files)); + gtk_box_append(GTK_BOX(evidence_box), evidence_actions); + } + gtk_box_append(GTK_BOX(evidence_box), + gtk_label_new("Preuves retenues")); + { + state->retained_labels = gtk_string_list_new(NULL); + state->retained = GTK_DROP_DOWN(gtk_drop_down_new( + G_LIST_MODEL(g_object_ref(state->retained_labels)), NULL)); + } + gtk_box_append(GTK_BOX(evidence_box), GTK_WIDGET(state->retained)); + { + static const char *const type_labels_fr[] = { + "Capture d’écran", "Photo", "Vidéo", "Document", "E-mail", + "Archive", "Audio", "Texte", "Autre", NULL}; + GtkWidget *retained_actions = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + state->retained_type = GTK_DROP_DOWN( + gtk_drop_down_new_from_strings(type_labels_fr)); + state->remove_retained = GTK_BUTTON( + gtk_button_new_with_label("Retirer")); + gtk_widget_set_tooltip_text(GTK_WIDGET(state->remove_retained), + "Retirer uniquement de la sélection"); + gtk_box_append(GTK_BOX(retained_actions), + GTK_WIDGET(state->retained_type)); + gtk_box_append(GTK_BOX(retained_actions), + GTK_WIDGET(state->remove_retained)); + gtk_box_append(GTK_BOX(evidence_box), retained_actions); + } + state->preview = evidence_preview_widget_new(state->task_manager, + (EvidencePreviewWidgetSessionCheck) state->session_check, + state->user_data); + if (state->preview == NULL) { + create_person_dialog_state_free(state); + return FALSE; + } + gtk_box_append(GTK_BOX(evidence_box), + evidence_preview_widget_get_widget(state->preview)); + gtk_stack_add_titled(state->stack, evidence_box, "evidence", "3 — Preuves"); state->summary = GTK_LABEL(gtk_label_new("")); summary = GTK_WIDGET(state->summary); gtk_label_set_wrap(state->summary, TRUE); @@ -627,8 +968,20 @@ gboolean create_person_dialog_present(GtkWindow *parent, G_CALLBACK(create_person_dialog_on_search_changed), state); g_signal_connect(state->type_filter, "notify::selected", G_CALLBACK(create_person_dialog_on_type_changed), state); - g_signal_connect(state->evidence, "notify::selected", + state->evidence_selected_handler = g_signal_connect( + state->evidence, "notify::selected", G_CALLBACK(create_person_dialog_on_evidence_changed), state); + g_signal_connect(state->add_existing, "clicked", + G_CALLBACK(create_person_dialog_on_add_existing), state); + state->retained_selected_handler = g_signal_connect( + state->retained, "notify::selected", + G_CALLBACK(create_person_dialog_on_retained_changed), state); + g_signal_connect(state->retained_type, "notify::selected", + G_CALLBACK(create_person_dialog_on_retained_type_changed), state); + g_signal_connect(state->remove_retained, "clicked", + G_CALLBACK(create_person_dialog_on_remove_retained), state); + g_signal_connect(state->import_files, "clicked", + G_CALLBACK(create_person_dialog_on_import_files), state); create_person_dialog_update_navigation(state); g_object_set_data_full(G_OBJECT(state->window), "person-dialog-state", state, create_person_dialog_state_free); @@ -641,6 +994,9 @@ void create_person_dialog_result_free(CreatePersonDialogResult *result) g_free(result->pseudonym); g_free(result->status); g_free(result->notes); g_free(result->evidence_identifier); g_clear_pointer(&result->role_assignments, g_ptr_array_unref); + g_clear_pointer(&result->evidence_selection, + person_evidence_selection_free); + g_clear_pointer(&result->staging, evidence_staging_free); g_free(result); } const PersonEntityInput *create_person_dialog_result_get_input( @@ -648,3 +1004,16 @@ const PersonEntityInput *create_person_dialog_result_get_input( { return result != NULL ? &result->input : NULL; } +const PersonEvidenceSelection * +create_person_dialog_result_get_evidence_selection( + const CreatePersonDialogResult *result) +{ + return result != NULL ? result->evidence_selection : NULL; +} +EvidenceStaging *create_person_dialog_result_steal_staging( + CreatePersonDialogResult *result) +{ + EvidenceStaging *staging = result != NULL ? result->staging : NULL; + if (result != NULL) result->staging = NULL; + return staging; +} diff --git a/src/views/main_window.c b/src/views/main_window.c index 09df1f3..bf69afd 100644 --- a/src/views/main_window.c +++ b/src/views/main_window.c @@ -958,7 +958,7 @@ MainWindow *main_window_new( return NULL; } - main_window->workspace = workspace_new(); + main_window->workspace = workspace_new(task_manager); if (main_window->workspace == NULL) { @@ -2028,6 +2028,14 @@ void main_window_set_evidence_preview(MainWindow *main_window, display_name); } +void main_window_show_evidence_preview(MainWindow *main_window, + const char *investigation_root, const EvidenceRecord *record) +{ + if (main_window == NULL) return; + workspace_show_evidence_preview(main_window->workspace, + investigation_root, record); +} + void main_window_free( MainWindow *main_window ) diff --git a/src/widgets/evidence_preview_widget.c b/src/widgets/evidence_preview_widget.c new file mode 100644 index 0000000..18cfe2f --- /dev/null +++ b/src/widgets/evidence_preview_widget.c @@ -0,0 +1,346 @@ +#include "widgets/evidence_preview_widget.h" +#include "core/evidence_preview_task.h" +#include "core/evidence_video_preview_controller.h" + +struct EvidencePreviewWidget { + GtkWidget *root; + GtkLabel *metadata; + GtkLabel *status; + GtkStack *stack; + GtkPicture *image; + GtkPicture *pdf; + GtkVideo *video; + GtkTextView *text; + GtkTextView *email; + TaskManager *task_manager; + BackgroundTask *task; + EvidenceVideoPreviewController *video_controller; + EvidencePreviewWidgetSessionCheck session_check; + gpointer session_data; + guint64 generation; + char *identifier; +}; + +typedef struct { + GWeakRef root; + char *identifier; + guint64 generation; +} PreviewWidgetContext; + +static void preview_widget_root_finalized(gpointer data, GObject *object) +{ + EvidencePreviewWidget *widget = data; + (void) object; + if (widget != NULL) widget->root = NULL; +} + +static void preview_widget_media_pause(gpointer media) +{ gtk_media_stream_pause(GTK_MEDIA_STREAM(media)); } +static void preview_widget_media_seek(gpointer media) +{ gtk_media_stream_seek(GTK_MEDIA_STREAM(media), 0); } +static void preview_widget_media_detach(gpointer owner) +{ gtk_video_set_media_stream(GTK_VIDEO(owner), NULL); } +static void preview_widget_media_release(gpointer media) +{ g_object_unref(media); } + +static void preview_widget_context_free(gpointer data) +{ + PreviewWidgetContext *context = data; + if (context == NULL) return; + g_weak_ref_clear(&context->root); + g_free(context->identifier); + g_free(context); +} + +static void preview_widget_media_error(GtkMediaStream *media, + GParamSpec *pspec, gpointer data) +{ + EvidencePreviewWidget *widget = data; + const GError *error; + (void) pspec; + if (widget == NULL || widget->root == NULL) return; + error = gtk_media_stream_get_error(media); + if (error != NULL) + gtk_label_set_text(widget->status, + "Vidéo reconnue, mais codec de lecture indisponible."); +} + +static void preview_widget_reset_content(EvidencePreviewWidget *widget) +{ + if (widget->video != NULL) + evidence_video_preview_controller_stop(widget->video_controller); + if (widget->image != NULL) gtk_picture_set_paintable(widget->image, NULL); + if (widget->pdf != NULL) gtk_picture_set_paintable(widget->pdf, NULL); + if (widget->text != NULL) + gtk_text_buffer_set_text(gtk_text_view_get_buffer(widget->text), "", -1); + if (widget->email != NULL) + gtk_text_buffer_set_text(gtk_text_view_get_buffer(widget->email), "", -1); +} + +void evidence_preview_widget_cancel(EvidencePreviewWidget *widget) +{ + if (widget == NULL) return; + widget->generation++; + if (widget->task != NULL) { + background_task_cancel(widget->task); + background_task_unref(widget->task); + widget->task = NULL; + } + if (widget->root != NULL) + preview_widget_reset_content(widget); + g_clear_pointer(&widget->identifier, g_free); +} + +void evidence_preview_widget_clear(EvidencePreviewWidget *widget) +{ + if (widget == NULL) return; + evidence_preview_widget_cancel(widget); + gtk_label_set_text(widget->metadata, ""); + gtk_label_set_text(widget->status, "Aucune preuve sélectionnée."); + gtk_stack_set_visible_child_name(widget->stack, "empty"); +} + +static void preview_widget_completed(BackgroundTask *task, gpointer data) +{ + PreviewWidgetContext *context = data; + GtkWidget *root = GTK_WIDGET(g_weak_ref_get(&context->root)); + EvidencePreviewWidget *widget = root != NULL + ? g_object_get_data(G_OBJECT(root), "evidence-preview-widget") : NULL; + EvidencePreviewResult *result = background_task_get_result(task); + gboolean current = widget != NULL && + widget->generation == context->generation && + g_strcmp0(widget->identifier, context->identifier) == 0 && + (widget->session_check == NULL || + widget->session_check(widget->session_data)); + if (current && evidence_preview_result_matches(result, + context->identifier, context->generation)) { + GError *error = NULL; + if ((result->kind == EVIDENCE_PREVIEW_KIND_IMAGE || + result->kind == EVIDENCE_PREVIEW_KIND_PDF) && + result->png_bytes != NULL) { + GdkTexture *texture = gdk_texture_new_from_bytes( + result->png_bytes, &error); + if (texture != NULL) { + GtkPicture *picture = result->kind == EVIDENCE_PREVIEW_KIND_PDF + ? widget->pdf : widget->image; + gtk_picture_set_paintable(picture, GDK_PAINTABLE(texture)); + gtk_stack_set_visible_child_name(widget->stack, + result->kind == EVIDENCE_PREVIEW_KIND_PDF + ? "pdf" : "image"); + gtk_label_set_text(widget->status, "Aperçu intègre."); + g_object_unref(texture); + } + } else if (result->kind == EVIDENCE_PREVIEW_KIND_VIDEO) { + GtkMediaStream *media = gtk_media_file_new_for_filename( + result->controlled_path); + gtk_media_stream_set_volume(media, 0.0); + gtk_media_stream_pause(media); + g_signal_connect(media, "notify::error", + G_CALLBACK(preview_widget_media_error), widget); + gtk_video_set_autoplay(widget->video, FALSE); + gtk_video_set_media_stream(widget->video, media); + evidence_video_preview_controller_replace( + widget->video_controller, media); + gtk_stack_set_visible_child_name(widget->stack, "video"); + gtk_label_set_text(widget->status, result->message); + } else if (result->kind == EVIDENCE_PREVIEW_KIND_TEXT || + result->kind == EVIDENCE_PREVIEW_KIND_EMAIL) { + GtkTextView *view = result->kind == EVIDENCE_PREVIEW_KIND_EMAIL + ? widget->email : widget->text; + gtk_text_buffer_set_text(gtk_text_view_get_buffer(view), + result->text != NULL ? result->text : "", -1); + gtk_stack_set_visible_child_name(widget->stack, + result->kind == EVIDENCE_PREVIEW_KIND_EMAIL + ? "email" : "text"); + gtk_label_set_text(widget->status, + result->truncated ? "Aperçu tronqué." : "Aperçu intègre."); + } else { + gtk_stack_set_visible_child_name(widget->stack, + result->kind == EVIDENCE_PREVIEW_KIND_UNSUPPORTED + ? "unsupported" : "error"); + gtk_label_set_text(widget->status, + result->message != NULL ? result->message : + "Aperçu illisible."); + } + if (error != NULL) { + gtk_stack_set_visible_child_name(widget->stack, "error"); + gtk_label_set_text(widget->status, error->message); + } + g_clear_error(&error); + } else if (widget != NULL && widget->generation == context->generation) { + GError *error = background_task_dup_error(task); + gtk_stack_set_visible_child_name(widget->stack, "error"); + gtk_label_set_text(widget->status, + error != NULL ? error->message : + "Aperçu annulé : l’enquête active a changé."); + g_clear_error(&error); + } + if (widget != NULL && widget->task == task) { + background_task_unref(widget->task); + widget->task = NULL; + } + g_clear_object(&root); +} + +EvidencePreviewWidget *evidence_preview_widget_new( + TaskManager *task_manager, + EvidencePreviewWidgetSessionCheck session_check, + gpointer session_data) +{ + EvidencePreviewWidget *widget; + GtkWidget *text_scroll; + GtkWidget *email_scroll; + const EvidenceVideoPreviewActions actions = { + preview_widget_media_pause, preview_widget_media_seek, + preview_widget_media_detach, preview_widget_media_release + }; + if (task_manager == NULL) return NULL; + widget = g_new0(EvidencePreviewWidget, 1); + widget->task_manager = task_manager; + widget->session_check = session_check; + widget->session_data = session_data; + widget->root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + g_object_weak_ref(G_OBJECT(widget->root), + preview_widget_root_finalized, widget); + widget->metadata = GTK_LABEL(gtk_label_new("")); + gtk_label_set_xalign(widget->metadata, 0.0f); + gtk_label_set_selectable(widget->metadata, TRUE); + widget->status = GTK_LABEL(gtk_label_new("Aucune preuve sélectionnée.")); + gtk_label_set_xalign(widget->status, 0.0f); + widget->image = GTK_PICTURE(gtk_picture_new()); + widget->pdf = GTK_PICTURE(gtk_picture_new()); + widget->video = GTK_VIDEO(gtk_video_new()); + g_object_add_weak_pointer(G_OBJECT(widget->image), + (gpointer *) &widget->image); + g_object_add_weak_pointer(G_OBJECT(widget->pdf), + (gpointer *) &widget->pdf); + g_object_add_weak_pointer(G_OBJECT(widget->video), + (gpointer *) &widget->video); + for (guint i = 0; i < 2; i++) { + GtkPicture *picture = i == 0 ? widget->image : widget->pdf; + gtk_picture_set_can_shrink(picture, TRUE); + gtk_picture_set_content_fit(picture, GTK_CONTENT_FIT_CONTAIN); + gtk_widget_set_size_request(GTK_WIDGET(picture), 480, 260); + } + gtk_widget_set_size_request(GTK_WIDGET(widget->video), 480, 260); + gtk_video_set_autoplay(widget->video, FALSE); + widget->video_controller = evidence_video_preview_controller_new( + widget->video, &actions); + widget->text = GTK_TEXT_VIEW(gtk_text_view_new()); + widget->email = GTK_TEXT_VIEW(gtk_text_view_new()); + g_object_add_weak_pointer(G_OBJECT(widget->text), + (gpointer *) &widget->text); + g_object_add_weak_pointer(G_OBJECT(widget->email), + (gpointer *) &widget->email); + gtk_text_view_set_monospace(widget->text, TRUE); + for (guint i = 0; i < 2; i++) { + GtkTextView *view = i == 0 ? widget->text : widget->email; + gtk_text_view_set_editable(view, FALSE); + gtk_text_view_set_cursor_visible(view, FALSE); + gtk_text_view_set_wrap_mode(view, GTK_WRAP_WORD_CHAR); + } + text_scroll = gtk_scrolled_window_new(); + email_scroll = gtk_scrolled_window_new(); + gtk_widget_set_size_request(text_scroll, 480, 260); + gtk_widget_set_size_request(email_scroll, 480, 260); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(text_scroll), + GTK_WIDGET(widget->text)); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(email_scroll), + GTK_WIDGET(widget->email)); + widget->stack = GTK_STACK(gtk_stack_new()); + gtk_stack_add_named(widget->stack, gtk_label_new("Aucun aperçu."), "empty"); + gtk_stack_add_named(widget->stack, + gtk_label_new("Chargement de l’aperçu…"), "loading"); + gtk_stack_add_named(widget->stack, GTK_WIDGET(widget->image), "image"); + gtk_stack_add_named(widget->stack, GTK_WIDGET(widget->video), "video"); + gtk_stack_add_named(widget->stack, text_scroll, "text"); + gtk_stack_add_named(widget->stack, email_scroll, "email"); + gtk_stack_add_named(widget->stack, GTK_WIDGET(widget->pdf), "pdf"); + gtk_stack_add_named(widget->stack, + gtk_label_new("Format reconnu mais aperçu indisponible."), + "unsupported"); + gtk_stack_add_named(widget->stack, + gtk_label_new("Erreur pendant la préparation de l’aperçu."), "error"); + gtk_stack_set_visible_child_name(widget->stack, "empty"); + gtk_box_append(GTK_BOX(widget->root), GTK_WIDGET(widget->metadata)); + gtk_box_append(GTK_BOX(widget->root), GTK_WIDGET(widget->status)); + gtk_box_append(GTK_BOX(widget->root), GTK_WIDGET(widget->stack)); + g_object_set_data(G_OBJECT(widget->root), + "evidence-preview-widget", widget); + return widget; +} + +GtkWidget *evidence_preview_widget_get_widget( + const EvidencePreviewWidget *widget) +{ return widget != NULL ? widget->root : NULL; } + +void evidence_preview_widget_show(EvidencePreviewWidget *widget, + const EvidencePreviewRequest *request, const char *metadata) +{ + EvidencePreviewRequest *owned; + PreviewWidgetContext *context; + GError *error = NULL; + if (widget == NULL || request == NULL) return; + evidence_preview_widget_cancel(widget); + widget->identifier = g_strdup(request->evidence_identifier); + owned = evidence_preview_request_new(request->investigation_root_path, + request->evidence_identifier, request->relative_path, + request->expected_sha256, request->mime_type, widget->generation); + gtk_label_set_text(widget->metadata, metadata != NULL ? metadata : ""); + gtk_label_set_text(widget->status, + "Détection, vérification et chargement en cours…"); + gtk_stack_set_visible_child_name(widget->stack, "loading"); + context = g_new0(PreviewWidgetContext, 1); + g_weak_ref_init(&context->root, G_OBJECT(widget->root)); + context->identifier = g_strdup(widget->identifier); + context->generation = widget->generation; + if (owned != NULL) + widget->task = evidence_preview_task_start(widget->task_manager, + owned, preview_widget_completed, context, + preview_widget_context_free, &error); + evidence_preview_request_free(owned); + if (widget->task == NULL) { + preview_widget_context_free(context); + gtk_stack_set_visible_child_name(widget->stack, "error"); + gtk_label_set_text(widget->status, + error != NULL ? error->message : "Aperçu impossible."); + } + g_clear_error(&error); +} + +void evidence_preview_widget_free(EvidencePreviewWidget *widget) +{ + if (widget == NULL) return; + evidence_preview_widget_cancel(widget); + if (widget->root != NULL) { + g_object_set_data(G_OBJECT(widget->root), + "evidence-preview-widget", NULL); + g_object_weak_unref(G_OBJECT(widget->root), + preview_widget_root_finalized, widget); + } + if (widget->video == NULL) + evidence_video_preview_controller_abandon_owner( + widget->video_controller); + g_clear_pointer(&widget->video_controller, + evidence_video_preview_controller_free); + g_free(widget); +} + +const char *evidence_preview_widget_get_state( + const EvidencePreviewWidget *widget) +{ + return widget != NULL + ? gtk_stack_get_visible_child_name(widget->stack) : NULL; +} + +char *evidence_preview_widget_dup_text( + const EvidencePreviewWidget *widget) +{ + GtkTextBuffer *buffer; + GtkTextIter start; + GtkTextIter end; + if (widget == NULL) return NULL; + buffer = gtk_text_view_get_buffer(widget->email); + gtk_text_buffer_get_bounds(buffer, &start, &end); + return gtk_text_buffer_get_text(buffer, &start, &end, FALSE); +} diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index 5347363..bfdfd49 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -12,6 +12,7 @@ #include "models/relation_record.h" #include "widgets/entity_details_panel.h" #include "widgets/investigation_graph_view.h" +#include "widgets/evidence_preview_widget.h" #include #include @@ -97,14 +98,7 @@ struct Workspace GtkWidget *analyze_rib_button; GtkWidget *extract_metadata_button; GtkWidget *recover_pdf_password_button; - GtkWidget *evidence_preview_stack; - GtkWidget *evidence_preview_status; - GtkWidget *evidence_preview_text; - GtkWidget *evidence_preview_picture; - GtkWidget *evidence_preview_video; - GtkWidget *evidence_preview_expand_button; - char *evidence_preview_path; - gboolean evidence_preview_is_video; + EvidencePreviewWidget *evidence_preview; char *selected_evidence_identifier; @@ -639,111 +633,11 @@ static const char *workspace_get_integrity_status_text( } } -/** @brief Affiche une erreur asynchrone produite par le moteur vidéo. */ -static void workspace_on_video_error_changed(GObject *object, - GParamSpec *parameter, gpointer user_data) -{ - Workspace *workspace = user_data; - const GError *error = NULL; - (void) parameter; - if (workspace == NULL || !GTK_IS_MEDIA_STREAM(object)) return; - error = gtk_media_stream_get_error(GTK_MEDIA_STREAM(object)); - if (error == NULL) return; - gtk_label_set_text(GTK_LABEL(workspace->evidence_preview_status), - error->message != NULL ? error->message : - "Le moteur vidéo ne peut pas lire ce fichier."); - gtk_stack_set_visible_child_name(GTK_STACK( - workspace->evidence_preview_stack), "status"); - gtk_widget_set_sensitive(workspace->evidence_preview_expand_button, - FALSE); -} - /** @brief Arrête et détache le média vidéo actuellement affiché. */ static void workspace_clear_evidence_preview(Workspace *workspace) { - GtkMediaStream *stream = NULL; if (workspace == NULL) return; - if (workspace->evidence_preview_video != NULL) - { - stream = gtk_video_get_media_stream( - GTK_VIDEO(workspace->evidence_preview_video)); - if (stream != NULL) - { - g_signal_handlers_disconnect_by_data(stream, workspace); - gtk_media_stream_pause(stream); - } - gtk_video_set_media_stream(GTK_VIDEO( - workspace->evidence_preview_video), NULL); - } - if (workspace->evidence_preview_picture != NULL) - gtk_picture_set_paintable(GTK_PICTURE( - workspace->evidence_preview_picture), NULL); - g_clear_pointer(&workspace->evidence_preview_path, g_free); - workspace->evidence_preview_is_video = FALSE; - if (workspace->evidence_preview_expand_button != NULL) - gtk_widget_set_sensitive(workspace->evidence_preview_expand_button, - FALSE); -} - -/** @brief Ouvre le média courant dans une fenêtre d'aperçu agrandie. */ -static void workspace_on_expand_evidence_preview(GtkButton *button, - gpointer user_data) -{ - Workspace *workspace = user_data; - GtkWindow *window = NULL; - GtkWidget *content = NULL; - (void) button; - if (workspace == NULL || workspace->evidence_preview_path == NULL) return; - window = GTK_WINDOW(gtk_window_new()); - gtk_window_set_title(window, "Aperçu de la preuve"); - gtk_window_set_default_size(window, 960, 720); - if (workspace->evidence_preview_is_video) - { - content = gtk_video_new_for_filename(workspace->evidence_preview_path); - gtk_video_set_autoplay(GTK_VIDEO(content), FALSE); - } - else if (gtk_stack_get_visible_child(GTK_STACK( - workspace->evidence_preview_stack)) == - workspace->evidence_preview_status) - { - GFile *file = g_file_new_for_path(workspace->evidence_preview_path); - char *uri = g_file_get_uri(file); - GError *error = NULL; - if (uri == NULL || !g_app_info_launch_default_for_uri(uri, NULL, - &error)) - { - gtk_label_set_text(GTK_LABEL(workspace->evidence_preview_status), - error != NULL ? error->message : - "Impossible d’ouvrir ce document."); - g_clear_error(&error); - } - g_free(uri); - g_object_unref(file); - gtk_window_destroy(window); - return; - } - else - { - content = gtk_picture_new_for_filename(workspace->evidence_preview_path); - gtk_picture_set_can_shrink(GTK_PICTURE(content), TRUE); - gtk_picture_set_content_fit(GTK_PICTURE(content), GTK_CONTENT_FIT_CONTAIN); - } - gtk_window_set_child(window, content); - gtk_window_present(window); -} - -/** @brief Ouvre l’aperçu agrandi lorsqu’un utilisateur clique dessus. */ -static void workspace_on_evidence_preview_clicked(GtkGestureClick *gesture, - gint n_press, gdouble x, gdouble y, gpointer user_data) -{ - Workspace *workspace = user_data; - (void) gesture; - (void) x; - (void) y; - if (workspace == NULL || n_press != 1 || - workspace->evidence_preview_path == NULL) - return; - workspace_on_expand_evidence_preview(NULL, workspace); + evidence_preview_widget_clear(workspace->evidence_preview); } /** @brief Convertit le statut d'une relation en texte utilisateur. */ @@ -1212,7 +1106,7 @@ static void workspace_on_recover_pdf_password_clicked(GtkButton *button, workspace->recover_pdf_password_user_data); } -Workspace *workspace_new(void) +Workspace *workspace_new(TaskManager *task_manager) { GtkWidget *evidence_content = NULL; GtkWidget *evidence_separator = NULL; @@ -1220,6 +1114,7 @@ Workspace *workspace_new(void) Workspace *workspace = NULL; + if (task_manager == NULL) return NULL; workspace = g_new0(Workspace, 1); workspace->root_widget = gtk_box_new( @@ -1485,65 +1380,14 @@ Workspace *workspace_new(void) workspace->evidence_name_label ); - workspace->evidence_preview_stack = gtk_stack_new(); - workspace->evidence_preview_status = gtk_label_new( - "Aucun aperçu disponible pour cette preuve."); - workspace->evidence_preview_picture = gtk_picture_new(); - workspace->evidence_preview_video = gtk_video_new(); - workspace->evidence_preview_expand_button = - gtk_button_new_with_label("Agrandir l’aperçu"); - gtk_widget_set_size_request(workspace->evidence_preview_stack, -1, 180); - gtk_widget_set_vexpand(workspace->evidence_preview_stack, FALSE); - gtk_widget_set_hexpand(workspace->evidence_preview_stack, TRUE); - gtk_widget_set_size_request(workspace->evidence_preview_picture, -1, 180); - gtk_widget_set_size_request(workspace->evidence_preview_video, -1, 180); - gtk_widget_set_vexpand(workspace->evidence_preview_picture, FALSE); - gtk_widget_set_vexpand(workspace->evidence_preview_video, FALSE); - gtk_picture_set_can_shrink(GTK_PICTURE( - workspace->evidence_preview_picture), TRUE); - gtk_picture_set_content_fit(GTK_PICTURE( - workspace->evidence_preview_picture), GTK_CONTENT_FIT_CONTAIN); - gtk_video_set_autoplay(GTK_VIDEO(workspace->evidence_preview_video), FALSE); - gtk_stack_add_named(GTK_STACK(workspace->evidence_preview_stack), - workspace->evidence_preview_status, "status"); - workspace->evidence_preview_text = gtk_text_view_new(); - gtk_text_view_set_editable(GTK_TEXT_VIEW(workspace->evidence_preview_text), FALSE); - gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(workspace->evidence_preview_text), GTK_WRAP_WORD_CHAR); - gtk_widget_set_vexpand(workspace->evidence_preview_text, TRUE); - gtk_stack_add_named(GTK_STACK(workspace->evidence_preview_stack), - workspace->evidence_preview_text, "text"); - gtk_stack_add_named(GTK_STACK(workspace->evidence_preview_stack), - workspace->evidence_preview_picture, "image"); - gtk_stack_add_named(GTK_STACK(workspace->evidence_preview_stack), - workspace->evidence_preview_video, "video"); - { - GtkGesture *gesture = gtk_gesture_click_new(); - gtk_widget_add_controller(workspace->evidence_preview_picture, - GTK_EVENT_CONTROLLER(gesture)); - g_signal_connect(gesture, "released", - G_CALLBACK(workspace_on_evidence_preview_clicked), workspace); - gesture = gtk_gesture_click_new(); - gtk_widget_add_controller(workspace->evidence_preview_video, - GTK_EVENT_CONTROLLER(gesture)); - g_signal_connect(gesture, "released", - G_CALLBACK(workspace_on_evidence_preview_clicked), workspace); - gesture = gtk_gesture_click_new(); - gtk_widget_add_controller(workspace->evidence_preview_text, - GTK_EVENT_CONTROLLER(gesture)); - g_signal_connect(gesture, "released", - G_CALLBACK(workspace_on_evidence_preview_clicked), workspace); + workspace->evidence_preview = evidence_preview_widget_new( + task_manager, NULL, NULL); + if (workspace->evidence_preview == NULL) { + workspace_free(workspace); + return NULL; } - gtk_stack_set_visible_child_name(GTK_STACK( - workspace->evidence_preview_stack), "status"); - gtk_widget_set_halign(workspace->evidence_preview_expand_button, - GTK_ALIGN_START); - gtk_widget_set_sensitive(workspace->evidence_preview_expand_button, FALSE); - g_signal_connect(workspace->evidence_preview_expand_button, "clicked", - G_CALLBACK(workspace_on_expand_evidence_preview), workspace); gtk_box_append(GTK_BOX(evidence_content), - workspace->evidence_preview_stack); - gtk_box_append(GTK_BOX(evidence_content), - workspace->evidence_preview_expand_button); + evidence_preview_widget_get_widget(workspace->evidence_preview)); workspace->verify_evidence_button = gtk_button_new_with_label( @@ -2842,9 +2686,6 @@ void workspace_set_selected_evidence( } workspace_clear_evidence_preview(workspace); - if (workspace->evidence_preview_stack != NULL) - gtk_stack_set_visible_child_name(GTK_STACK( - workspace->evidence_preview_stack), "status"); g_clear_pointer( &workspace->selected_evidence_identifier, @@ -3051,99 +2892,65 @@ void workspace_set_eml_analysis_available( void workspace_set_evidence_preview(Workspace *workspace, const char *file_path, const char *display_name) { - GFile *file = NULL; - GFileInfo *info = NULL; - const char *content_type = NULL; - char *mime_type = NULL; - GtkMediaStream *stream = NULL; - GError *error = NULL; + char *contents = NULL; + gsize length = 0; + char *sha256 = NULL; + char *root = NULL; + char *name = NULL; + EvidencePreviewRequest *request = NULL; if (workspace == NULL) return; workspace_clear_evidence_preview(workspace); - if (file_path == NULL || file_path[0] == '\0') - { - gtk_label_set_text(GTK_LABEL(workspace->evidence_preview_status), - "Aucun aperçu disponible pour cette preuve."); - gtk_stack_set_visible_child_name(GTK_STACK( - workspace->evidence_preview_stack), "status"); + if (file_path == NULL || !g_file_get_contents( + file_path, &contents, &length, NULL)) return; + sha256 = g_compute_checksum_for_data(G_CHECKSUM_SHA256, + (const guchar *) contents, length); + root = g_path_get_dirname(file_path); + name = g_path_get_basename(file_path); + request = evidence_preview_request_new(root, + display_name != NULL ? display_name : name, name, sha256, NULL, 0); + evidence_preview_widget_show(workspace->evidence_preview, request, + display_name); + evidence_preview_request_free(request); + g_free(name); + g_free(root); + g_free(sha256); + g_free(contents); +} + +void workspace_show_evidence_preview(Workspace *workspace, + const char *investigation_root, const EvidenceRecord *record) +{ + EvidencePreviewRequest *request; + char *size; + char *metadata; + if (workspace == NULL) return; + if (investigation_root == NULL || record == NULL) { + workspace_clear_evidence_preview(workspace); return; } - file = g_file_new_for_path(file_path); - info = g_file_query_info(file, G_FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE, - G_FILE_QUERY_INFO_NONE, NULL, &error); - if (info != NULL) content_type = g_file_info_get_content_type(info); - if (content_type != NULL) mime_type = g_content_type_get_mime_type( - content_type); - if (mime_type != NULL && g_str_has_prefix(mime_type, "image/")) - { - gtk_picture_set_filename(GTK_PICTURE( - workspace->evidence_preview_picture), file_path); - gtk_stack_set_visible_child_name(GTK_STACK( - workspace->evidence_preview_stack), "image"); - workspace->evidence_preview_is_video = FALSE; - } - else if (mime_type != NULL && g_str_has_prefix(mime_type, "video/")) - { - stream = gtk_media_file_new_for_filename(file_path); - g_signal_connect(stream, "notify::error", - G_CALLBACK(workspace_on_video_error_changed), workspace); - gtk_video_set_media_stream(GTK_VIDEO( - workspace->evidence_preview_video), stream); - g_object_unref(stream); - gtk_stack_set_visible_child_name(GTK_STACK( - workspace->evidence_preview_stack), "video"); - workspace->evidence_preview_is_video = TRUE; - } - else - { - char *message = NULL; - if ((mime_type != NULL && g_strcmp0(mime_type, "text/plain") == 0) || - g_str_has_suffix(file_path, ".txt") || - g_str_has_suffix(file_path, ".log") || - g_str_has_suffix(file_path, ".json")) - { - char *contents = NULL; - gsize length = 0; - if (g_file_get_contents(file_path, &contents, &length, NULL)) - { - GtkTextBuffer *buffer = gtk_text_view_get_buffer( - GTK_TEXT_VIEW(workspace->evidence_preview_text)); - gtk_text_buffer_set_text(buffer, contents, (gint) MIN(length, 200000)); - gtk_stack_set_visible_child_name(GTK_STACK( - workspace->evidence_preview_stack), "text"); - workspace->evidence_preview_is_video = FALSE; - g_free(contents); - goto text_preview_ready; - } - g_free(contents); - } - message = g_strdup_printf( - "Aperçu intégré indisponible pour %s%s%s. Utilisez le bouton " - "pour ouvrir le document avec l’application associée.", - display_name != NULL ? display_name : "ce fichier", - error != NULL ? " : " : "", - error != NULL ? error->message : "format non pris en charge"); - gtk_label_set_text(GTK_LABEL(workspace->evidence_preview_status), - message); - gtk_stack_set_visible_child_name(GTK_STACK( - workspace->evidence_preview_stack), "status"); - if (g_file_test(file_path, G_FILE_TEST_IS_REGULAR)) - { - workspace->evidence_preview_path = g_strdup(file_path); - gtk_widget_set_sensitive(workspace->evidence_preview_expand_button, - workspace->evidence_preview_path != NULL); - } - g_free(message); - goto cleanup; - } -text_preview_ready: - workspace->evidence_preview_path = g_strdup(file_path); - gtk_widget_set_sensitive(workspace->evidence_preview_expand_button, - workspace->evidence_preview_path != NULL); -cleanup: - g_clear_error(&error); - g_free(mime_type); - g_clear_object(&info); - g_clear_object(&file); + size = g_format_size(evidence_record_get_size_bytes(record)); + metadata = g_strdup_printf( + "Nom original : %s\nUUID : %s\nType métier : %s\n" + "MIME enregistré : %s\nTaille : %s\nSHA-256 : %s\n" + "Intégrité enregistrée : %s", + evidence_record_get_original_name(record), + evidence_record_get_identifier(record), + evidence_record_get_type_identifier(record), + evidence_record_get_mime_type(record) != NULL + ? evidence_record_get_mime_type(record) : "Non renseigné", + size, evidence_record_get_sha256(record), + workspace_get_integrity_status_text( + evidence_record_get_integrity_status(record))); + request = evidence_preview_request_new(investigation_root, + evidence_record_get_identifier(record), + evidence_record_get_relative_path(record), + evidence_record_get_sha256(record), + evidence_record_get_mime_type(record), 0); + evidence_preview_widget_show(workspace->evidence_preview, + request, metadata); + evidence_preview_request_free(request); + g_free(metadata); + g_free(size); } gboolean workspace_select_graph_entity( @@ -3841,6 +3648,8 @@ void workspace_free(Workspace *workspace) } workspace_clear_evidence_preview(workspace); + g_clear_pointer(&workspace->evidence_preview, + evidence_preview_widget_free); g_clear_pointer( &workspace->osint_selection_context, diff --git a/tests/test_create_person_dialog_gtk.c b/tests/test_create_person_dialog_gtk.c index 467087b..a42bb47 100644 --- a/tests/test_create_person_dialog_gtk.c +++ b/tests/test_create_person_dialog_gtk.c @@ -9,8 +9,17 @@ typedef struct TaskManager *task_manager; guint completion_count; gboolean passed; + gboolean evidence_model_destroyed; + GListModel *evidence_model; } TestContext; +static void evidence_model_destroyed(gpointer data, GObject *object) +{ + TestContext *context = data; + (void) object; + context->evidence_model_destroyed = TRUE; +} + static GtkWidget *find_button(GtkWidget *widget, const char *label) { if (GTK_IS_BUTTON(widget) && @@ -38,6 +47,99 @@ static GtkWidget *find_entry(GtkWidget *widget, const char *placeholder) return NULL; } +static GtkDropDown *find_evidence_drop_down(GtkWidget *widget) +{ + if (GTK_IS_DROP_DOWN(widget)) { + GListModel *model = gtk_drop_down_get_model(GTK_DROP_DOWN(widget)); + GtkStringObject *first = model != NULL && + g_list_model_get_n_items(model) > 0 + ? g_list_model_get_item(model, 0) : NULL; + gboolean matches = first != NULL && g_strcmp0( + gtk_string_object_get_string(first), + "Aucune preuve associée") == 0; + g_clear_object(&first); + if (matches) return GTK_DROP_DOWN(widget); + } + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkDropDown *match = find_evidence_drop_down(child); + if (match != NULL) return match; + } + return NULL; +} + +static GtkDropDown *find_drop_down_starting_with(GtkWidget *widget, + const char *first_label) +{ + if (GTK_IS_DROP_DOWN(widget)) { + GListModel *model = gtk_drop_down_get_model(GTK_DROP_DOWN(widget)); + GtkStringObject *first = model != NULL && + g_list_model_get_n_items(model) > 0 + ? g_list_model_get_item(model, 0) : NULL; + gboolean matches = first != NULL && g_strcmp0( + gtk_string_object_get_string(first), first_label) == 0; + g_clear_object(&first); + if (matches) return GTK_DROP_DOWN(widget); + } + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkDropDown *match = find_drop_down_starting_with(child, first_label); + if (match != NULL) return match; + } + return NULL; +} + +static GtkWidget *find_first_button(GtkWidget *widget) +{ + if (GTK_IS_BUTTON(widget)) return widget; + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkWidget *match = find_first_button(child); + if (match != NULL) return match; + } + return NULL; +} + +static GtkListView *find_list_view(GtkWidget *widget, gboolean mapped_only) +{ + if (GTK_IS_LIST_VIEW(widget) && + (!mapped_only || gtk_widget_get_mapped(widget))) + return GTK_LIST_VIEW(widget); + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkListView *match = find_list_view(child, mapped_only); + if (match != NULL) return match; + } + return NULL; +} + +static GtkListView *find_popup_list_view(GtkDropDown *dropdown) +{ + GtkWidget *root = GTK_WIDGET(gtk_widget_get_root(GTK_WIDGET(dropdown))); + GtkListView *list_view; + GtkWidget *button = find_first_button(GTK_WIDGET(dropdown)); + g_assert_nonnull(button); + if (GTK_IS_TOGGLE_BUTTON(button)) + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), TRUE); + else + gtk_widget_activate(button); + while (g_main_context_iteration(NULL, FALSE)) {} + list_view = find_list_view(GTK_WIDGET(dropdown), TRUE); + if (list_view == NULL && root != NULL) + list_view = find_list_view(root, TRUE); + return list_view; +} + +static void activate_popup_item(GtkListView *list_view, guint position) +{ + GtkSelectionModel *selection = gtk_list_view_get_model(list_view); + g_assert_nonnull(selection); + g_assert_true(gtk_selection_model_select_item( + selection, position, TRUE)); + g_signal_emit_by_name(list_view, "activate", position); + while (g_main_context_iteration(NULL, FALSE)) {} +} + static GtkWidget *find_label_containing(GtkWidget *widget, const char *text) { if (GTK_IS_LABEL(widget) && @@ -63,6 +165,7 @@ static gboolean check_after_cancel(gpointer data) TestContext *context = data; GList *windows = gtk_application_get_windows(context->application); g_assert_cmpuint(context->completion_count, ==, 1); + g_assert_true(context->evidence_model_destroyed); g_assert_true(gtk_widget_get_visible(GTK_WIDGET(context->main_window))); g_assert_cmpuint(g_list_length(windows), ==, 1); g_assert_true(windows->data == context->main_window); @@ -78,6 +181,7 @@ static gboolean click_cancel(gpointer data) GtkWindow *dialog = windows != NULL && windows->data != context->main_window ? windows->data : windows != NULL ? windows->next->data : NULL; GtkWidget *button, *designation, *search; + GtkDropDown *evidence, *retained_type, *type_filter; g_assert_nonnull(dialog); designation = find_entry(GTK_WIDGET(dialog), "Personne présumée liée aux comptes"); @@ -90,13 +194,70 @@ static gboolean click_cancel(gpointer data) search = find_entry(GTK_WIDGET(dialog), "Rechercher par nom, description ou type"); g_assert_nonnull(search); - gtk_editable_set_text(GTK_EDITABLE(search), "SPECIMEN"); + evidence = find_evidence_drop_down(GTK_WIDGET(dialog)); + g_assert_nonnull(evidence); + context->evidence_model = gtk_drop_down_get_model(evidence); + g_assert_nonnull(context->evidence_model); + g_object_weak_ref(G_OBJECT(context->evidence_model), + evidence_model_destroyed, context); + GtkListView *popup_list = find_popup_list_view(evidence); + g_assert_nonnull(popup_list); + activate_popup_item(popup_list, 1); + popup_list = find_popup_list_view(evidence); + g_assert_nonnull(popup_list); + activate_popup_item(popup_list, 2); + popup_list = find_popup_list_view(evidence); + g_assert_nonnull(popup_list); + activate_popup_item(popup_list, 0); + popup_list = find_popup_list_view(evidence); + g_assert_nonnull(popup_list); + activate_popup_item(popup_list, 1); + gtk_editable_set_text(GTK_EDITABLE(search), "recherche"); + g_assert_true(gtk_drop_down_get_model(evidence) == + context->evidence_model); + g_assert_false(context->evidence_model_destroyed); + type_filter = find_drop_down_starting_with( + GTK_WIDGET(dialog), "Tous les types"); + g_assert_nonnull(type_filter); + popup_list = find_popup_list_view(type_filter); + g_assert_nonnull(popup_list); + activate_popup_item(popup_list, 2); + popup_list = find_popup_list_view(evidence); + g_assert_nonnull(popup_list); + activate_popup_item(popup_list, 1); + g_assert_cmpuint(gtk_drop_down_get_selected(evidence), ==, 1); + button = find_button(GTK_WIDGET(dialog), "Ajouter à la sélection"); + g_assert_nonnull(button); + g_signal_emit_by_name(button, "clicked"); + retained_type = find_drop_down_starting_with( + GTK_WIDGET(dialog), "Capture d’écran"); + g_assert_nonnull(retained_type); + gtk_drop_down_set_selected(retained_type, 4); + g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), + "Type : email (email)")); + g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), "— email")); + popup_list = find_popup_list_view(type_filter); + g_assert_nonnull(popup_list); + activate_popup_item(popup_list, 0); + gtk_editable_set_text(GTK_EDITABLE(search), "AUTRE"); + g_assert_true(gtk_drop_down_get_model(evidence) == + context->evidence_model); + popup_list = find_popup_list_view(evidence); + g_assert_nonnull(popup_list); + activate_popup_item(popup_list, 1); + button = find_button(GTK_WIDGET(dialog), "Ajouter à la sélection"); + g_assert_nonnull(button); + g_signal_emit_by_name(button, "clicked"); button = find_button(GTK_WIDGET(dialog), "Suivant"); g_signal_emit_by_name(button, "clicked"); g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), "Désignation : SPECIMEN")); g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), "SPECIMEN-recherche.png")); + g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), + "Type métier : email")); + g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), + "AUTRE-SPECIMEN.jpg")); g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), "Aucune écriture n’a encore été effectuée")); button = find_button(GTK_WIDGET(dialog), "Annuler"); @@ -124,6 +285,17 @@ static void activate(GtkApplication *application, gpointer data) evidence_record_set_display_metadata( record, "Capture d’écran", NULL); g_ptr_array_add(records, record); + record = evidence_record_new( + "10000000-0000-4000-8000-000000000002", + "AUTRE-SPECIMEN.jpg", "autre.jpg", + "01_Preuves_Originales/autre.jpg", "photo", 24, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "2026-07-28T10:00:00Z", NULL, NULL, + "Deuxième preuve synthétique", EVIDENCE_INTEGRITY_STATUS_UNKNOWN, + &error); + g_assert_no_error(error); + evidence_record_set_display_metadata(record, "Photo", "image/jpeg"); + g_ptr_array_add(records, record); context->main_window = GTK_WINDOW( gtk_application_window_new(application)); gtk_window_present(context->main_window); diff --git a/tests/test_evidence_preview.c b/tests/test_evidence_preview.c index c4ff8d7..403a2ff 100644 --- a/tests/test_evidence_preview.c +++ b/tests/test_evidence_preview.c @@ -1,8 +1,10 @@ #include "core/evidence_preview.h" #include "core/file_hash.h" #include +#include #include #include +#include #include "core/evidence_preview_task.h" #include @@ -31,6 +33,7 @@ static void test_preview_png_and_guards(void) result = evidence_preview_load(request, NULL, &error); g_assert_no_error(error); g_assert_nonnull(result); + g_assert_cmpstr(result->effective_mime_type, ==, "image/png"); g_assert_true(evidence_preview_result_matches(result, "10000000-0000-4000-8000-000000000109", 7)); g_assert_false(evidence_preview_result_matches(result, @@ -94,7 +97,7 @@ static void test_preview_jpeg_async(void) g_assert_true(file_hash_compute_sha256(path, NULL, &sha, &size, &error)); EvidencePreviewRequest *request = evidence_preview_request_new(root, "10000000-0000-4000-8000-000000000110", "SPECIMEN.jpg", - sha, "image/jpeg", 9); + sha, NULL, 9); BackgroundTask *task = evidence_preview_task_start(manager, request, preview_done, &state, NULL, &error); g_assert_nonnull(task); @@ -104,6 +107,7 @@ static void test_preview_jpeg_async(void) g_assert_nonnull(result); g_assert_cmpint(result->width, ==, 32); g_assert_cmpint(result->height, ==, 20); + g_assert_cmpstr(result->effective_mime_type, ==, "image/jpeg"); background_task_unref(task); evidence_preview_request_free(request); task_manager_free(manager); g_main_loop_unref(state.loop); @@ -111,6 +115,257 @@ static void test_preview_jpeg_async(void) g_free(sha); g_free(path); g_free(root); } +static void test_preview_rejects_false_jpeg(void) +{ + GError *error = NULL; + char *root = g_dir_make_tmp("labfy-preview-invalid-XXXXXX", &error); + char *path = g_build_filename(root, "SPECIMEN.jpg", NULL); + char *sha = NULL; + guint64 size = 0; + EvidencePreviewRequest *request; + g_assert_true(g_file_set_contents(path, "SPECIMEN non image", -1, + &error)); + g_assert_true(file_hash_compute_sha256(path, NULL, &sha, &size, &error)); + request = evidence_preview_request_new(root, + "10000000-0000-4000-8000-000000000111", "SPECIMEN.jpg", + sha, "image/jpeg", 10); + EvidencePreviewResult *result = + evidence_preview_load(request, NULL, &error); + g_assert_no_error(error); + g_assert_nonnull(result); + g_assert_cmpint(result->kind, ==, EVIDENCE_PREVIEW_KIND_UNSUPPORTED); + evidence_preview_result_free(result); + evidence_preview_request_free(request); + g_assert_cmpint(g_remove(path), ==, 0); + g_assert_cmpint(g_rmdir(root), ==, 0); + g_free(sha); + g_free(path); + g_free(root); +} + +static EvidencePreviewResult *load_fixture(const char *root, + const char *name, const guint8 *data, gsize length, const char *mime) +{ + GError *error = NULL; + char *path = g_build_filename(root, name, NULL); + char *sha = NULL; guint64 size = 0; + g_assert_true(g_file_set_contents(path, (const char *) data, + (gssize) length, &error)); + g_assert_true(file_hash_compute_sha256(path, NULL, &sha, &size, &error)); + EvidencePreviewRequest *request = evidence_preview_request_new(root, + "10000000-0000-4000-8000-000000000112", name, sha, mime, 11); + EvidencePreviewResult *result = evidence_preview_load(request, NULL, &error); + g_assert_no_error(error); g_assert_nonnull(result); + evidence_preview_request_free(request); g_free(sha); g_free(path); + return result; +} + +static void test_preview_dispatch_text_video_email(void) +{ + GError *error = NULL; + char *root = g_dir_make_tmp("labfy-preview-dispatch-XXXXXX", &error); + static const guint8 text[] = "SPECIMEN,évidence\n1,contrôlée\n"; + static const guint8 mp4[] = { + 0,0,0,24,'f','t','y','p','i','s','o','m',0,0,0,0, + 'i','s','o','m','m','p','4','2'}; + static const guint8 mov[] = { + 0,0,0,20,'f','t','y','p','q','t',' ',' ',0,0,0,0,'q','t',' ',' '}; + static const guint8 eml[] = + "From: Alice SPECIMEN \r\n" + "To: Bob SPECIMEN \r\n" + "Date: Tue, 28 Jul 2026 10:00:00 +0200\r\n" + "Subject: SPECIMEN\r\nMessage-ID: \r\n" + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + "Corps SPECIMEN sans ressource distante.\r\n"; + EvidencePreviewResult *result = load_fixture(root, "SPECIMEN.csv", + text, sizeof(text) - 1, NULL); + g_assert_cmpint(result->kind, ==, EVIDENCE_PREVIEW_KIND_TEXT); + g_assert_nonnull(strstr(result->text, "évidence")); + evidence_preview_result_free(result); + result = load_fixture(root, "SPECIMEN.bin", mp4, sizeof(mp4), "text/plain"); + g_assert_cmpint(result->kind, ==, EVIDENCE_PREVIEW_KIND_VIDEO); + g_assert_cmpstr(result->effective_mime_type, ==, "video/mp4"); + evidence_preview_result_free(result); + result = load_fixture(root, "SPECIMEN.data", mov, sizeof(mov), NULL); + g_assert_cmpint(result->kind, ==, EVIDENCE_PREVIEW_KIND_VIDEO); + g_assert_cmpstr(result->effective_mime_type, ==, "video/quicktime"); + evidence_preview_result_free(result); + result = load_fixture(root, "SPECIMEN.eml", eml, sizeof(eml) - 1, NULL); + g_assert_cmpint(result->kind, ==, EVIDENCE_PREVIEW_KIND_EMAIL); + g_assert_nonnull(strstr(result->text, "Alice SPECIMEN")); + evidence_preview_result_free(result); + g_remove(g_build_filename(root, "SPECIMEN.csv", NULL)); + g_remove(g_build_filename(root, "SPECIMEN.bin", NULL)); + g_remove(g_build_filename(root, "SPECIMEN.data", NULL)); + g_remove(g_build_filename(root, "SPECIMEN.eml", NULL)); + g_assert_cmpint(g_rmdir(root), ==, 0); g_free(root); +} + +static void test_preview_pdf_first_page(void) +{ + GError *error = NULL; + char *root = g_dir_make_tmp("labfy-preview-pdf-XXXXXX", &error); + char *path = g_build_filename(root, "SPECIMEN.pdf", NULL); + cairo_surface_t *surface = cairo_pdf_surface_create(path, 320, 200); + cairo_t *cr = cairo_create(surface); + cairo_set_source_rgb(cr, 0, 0, 0); cairo_move_to(cr, 20, 40); + cairo_show_text(cr, "SPECIMEN"); cairo_show_page(cr); + cairo_destroy(cr); cairo_surface_destroy(surface); + char *contents = NULL; gsize length = 0; + g_assert_true(g_file_get_contents(path, &contents, &length, &error)); + EvidencePreviewResult *result = load_fixture(root, "SPECIMEN.pdf", + (const guint8 *) contents, length, "application/octet-stream"); + g_assert_cmpint(result->kind, ==, EVIDENCE_PREVIEW_KIND_PDF); + g_assert_nonnull(result->png_bytes); g_assert_cmpuint(result->item_count, ==, 1); + evidence_preview_result_free(result); + g_free(contents); g_assert_cmpint(g_remove(path), ==, 0); + g_assert_cmpint(g_rmdir(root), ==, 0); g_free(path); g_free(root); +} + +static gboolean write_heic_specimen(const char *path, gint width, gint height) +{ + struct heif_context *context = heif_context_alloc(); + struct heif_encoder *encoder = NULL; + struct heif_image *image = NULL; + struct heif_image_handle *handle = NULL; + struct heif_error status = heif_context_get_encoder_for_format( + context, heif_compression_HEVC, &encoder); + if (status.code != heif_error_Ok) { + heif_context_free(context); return FALSE; + } + status = heif_image_create(width, height, heif_colorspace_RGB, + heif_chroma_interleaved_RGB, &image); + if (status.code == heif_error_Ok) + status = heif_image_add_plane(image, heif_channel_interleaved, + width, height, 8); + if (status.code == heif_error_Ok) { + gint stride = 0; + guint8 *pixels = heif_image_get_plane(image, + heif_channel_interleaved, &stride); + for (gint y = 0; y < height; y++) + for (gint x = 0; x < width; x++) { + pixels[y * stride + x * 3] = (guint8) (x % 255); + pixels[y * stride + x * 3 + 1] = (guint8) (y % 255); + pixels[y * stride + x * 3 + 2] = 96; + } + status = heif_context_encode_image(context, image, encoder, + NULL, &handle); + } + if (status.code == heif_error_Ok) + status = heif_context_write_to_file(context, path); + if (handle != NULL) heif_image_handle_release(handle); + if (image != NULL) heif_image_release(image); + heif_encoder_release(encoder); heif_context_free(context); + return status.code == heif_error_Ok; +} + +static void test_preview_real_heic(void) +{ + GError *error = NULL; + char *root = g_dir_make_tmp("labfy-preview-heic-XXXXXX", &error); + char *path = g_build_filename(root, "SPECIMEN", NULL); + if (!write_heic_specimen(path, 48, 32)) { + g_test_skip("Aucun encodeur HEVC libheif disponible."); + g_free(path); g_rmdir(root); g_free(root); return; + } + char *contents = NULL; gsize length = 0; + g_assert_true(g_file_get_contents(path, &contents, &length, &error)); + EvidencePreviewResult *result = load_fixture(root, "SPECIMEN", + (const guint8 *) contents, length, NULL); + g_assert_cmpint(result->kind, ==, EVIDENCE_PREVIEW_KIND_IMAGE); + g_assert_cmpstr(result->effective_mime_type, ==, "image/heic"); + g_assert_cmpint(result->width, ==, 48); + g_assert_cmpint(result->height, ==, 32); + g_assert_cmpuint(result->item_count, ==, 1); + evidence_preview_result_free(result); + for (gsize offset = 0; offset + 4 <= length; offset++) + if (memcmp(contents + offset, "heic", 4) == 0) + memcpy(contents + offset, "mif1", 4); + result = load_fixture(root, "SPECIMEN.heif", + (const guint8 *) contents, length, NULL); + g_assert_cmpint(result->kind, ==, EVIDENCE_PREVIEW_KIND_IMAGE); + g_assert_cmpstr(result->effective_mime_type, ==, "image/heif"); + evidence_preview_result_free(result); + char *heif_path = g_build_filename(root, "SPECIMEN.heif", NULL); + g_assert_cmpint(g_remove(heif_path), ==, 0); g_free(heif_path); + g_free(contents); g_assert_cmpint(g_remove(path), ==, 0); + g_assert_cmpint(g_rmdir(root), ==, 0); g_free(path); g_free(root); +} + +static void test_preview_heic_cancel_and_invalid(void) +{ + GError *error = NULL; + char *root = g_dir_make_tmp("labfy-preview-heic-invalid-XXXXXX", &error); + static const guint8 invalid[] = { + 0,0,0,20,'f','t','y','p','h','e','i','c',0,0,0,0,'m','i','f','1'}; + char *path = g_build_filename(root, "SPECIMEN.heic", NULL); + g_assert_true(g_file_set_contents(path, (const char *) invalid, + sizeof(invalid), &error)); + char *sha = NULL; guint64 size = 0; + g_assert_true(file_hash_compute_sha256(path, NULL, &sha, &size, &error)); + EvidencePreviewRequest *request = evidence_preview_request_new(root, + "10000000-0000-4000-8000-000000000113", "SPECIMEN.heic", + sha, NULL, 12); + g_assert_null(evidence_preview_load(request, NULL, &error)); + g_assert_nonnull(error); g_clear_error(&error); + GCancellable *cancellable = g_cancellable_new(); + g_cancellable_cancel(cancellable); + g_assert_null(evidence_preview_load(request, cancellable, &error)); + g_assert_nonnull(error); + g_clear_error(&error); g_object_unref(cancellable); + evidence_preview_request_free(request); g_free(sha); + g_assert_cmpint(g_remove(path), ==, 0); + g_assert_cmpint(g_rmdir(root), ==, 0); g_free(path); g_free(root); +} + +static void test_preview_dimension_limits_without_allocation(void) +{ + g_assert_true(evidence_preview_dimensions_are_safe(8000, 5000)); + g_assert_false(evidence_preview_dimensions_are_safe(8001, 5000)); + g_assert_false(evidence_preview_dimensions_are_safe(12001, 1)); + g_assert_false(evidence_preview_dimensions_are_safe(1, 12001)); + g_assert_false(evidence_preview_dimensions_are_safe(0, 100)); +} + +static void test_preview_eml_multipart_inventory(void) +{ + GError *error = NULL; + char *root = g_dir_make_tmp("labfy-preview-eml-XXXXXX", &error); + static const guint8 eml[] = + "From: Alice SPECIMEN \r\n" + "To: Bob , Eve \r\n" + "Subject: =?UTF-8?Q?Sujet_=C3=A9tude_SPECIMEN?=\r\n" + "Date: Tue, 28 Jul 2026 10:00:00 +0200\r\n" + "Message-ID: \r\nMIME-Version: 1.0\r\n" + "Content-Type: multipart/mixed; boundary=outer\r\n\r\n" + "--outer\r\nContent-Type: multipart/alternative; boundary=inner\r\n\r\n" + "--inner\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n" + "

HTML distant

\r\n" + "--inner\r\nContent-Type: text/plain; charset=UTF-8\r\n" + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" + "Corps=20pr=C3=A9f=C3=A9r=C3=A9=20SPECIMEN\r\n--inner--\r\n" + "--outer\r\nContent-Type: text/plain\r\n" + "Content-Disposition: attachment; filename*0*=UTF-8''rapport%20;" + " filename*1*=synth%C3%A9tique.txt\r\n" + "Content-Transfer-Encoding: base64\r\n\r\nU1BFQ0lNRU4=\r\n" + "--outer\r\nContent-Type: image/png; name=\"inline.png\"\r\n" + "Content-Disposition: inline\r\nContent-ID: \r\n" + "Content-Transfer-Encoding: base64\r\n\r\nUE5H\r\n--outer--\r\n"; + EvidencePreviewResult *result = load_fixture(root, "SPECIMEN.eml", + eml, sizeof(eml) - 1, "message/rfc822"); + g_assert_cmpint(result->kind, ==, EVIDENCE_PREVIEW_KIND_EMAIL); + g_assert_cmpuint(result->item_count, ==, 2); + g_assert_nonnull(strstr(result->text, "Corps préféré SPECIMEN")); + g_assert_nonnull(strstr(result->text, "rapport synthétique.txt")); + g_assert_nonnull(strstr(result->text, "inline.png")); + g_assert_nonnull(strstr(result->text, "Content-ID: specimen-inline@example.com")); + g_assert_null(strstr(result->text, "--outer")); + evidence_preview_result_free(result); + char *path = g_build_filename(root, "SPECIMEN.eml", NULL); + g_assert_cmpint(g_remove(path), ==, 0); + g_assert_cmpint(g_rmdir(root), ==, 0); g_free(path); g_free(root); +} + int main(int argc, char **argv) { g_test_init(&argc, &argv, NULL); @@ -118,5 +373,19 @@ int main(int argc, char **argv) test_preview_png_and_guards); g_test_add_func("/evidence-preview/jpeg-async", test_preview_jpeg_async); + g_test_add_func("/evidence-preview/rejects-false-jpeg", + test_preview_rejects_false_jpeg); + g_test_add_func("/evidence-preview/dispatch-text-video-email", + test_preview_dispatch_text_video_email); + g_test_add_func("/evidence-preview/pdf-first-page", + test_preview_pdf_first_page); + g_test_add_func("/evidence-preview/real-heic", + test_preview_real_heic); + g_test_add_func("/evidence-preview/heic-cancel-invalid", + test_preview_heic_cancel_and_invalid); + g_test_add_func("/evidence-preview/eml-multipart-inventory", + test_preview_eml_multipart_inventory); + g_test_add_func("/evidence-preview/dimension-limits", + test_preview_dimension_limits_without_allocation); return g_test_run(); } diff --git a/tests/test_evidence_preview_widget_gtk.c b/tests/test_evidence_preview_widget_gtk.c new file mode 100644 index 0000000..5656be1 --- /dev/null +++ b/tests/test_evidence_preview_widget_gtk.c @@ -0,0 +1,84 @@ +#include "widgets/evidence_preview_widget.h" +#include + +static void drain_until(EvidencePreviewWidget *widget, const char *state) +{ + gint64 deadline = g_get_monotonic_time() + 5 * G_TIME_SPAN_SECOND; + while (g_strcmp0(evidence_preview_widget_get_state(widget), state) != 0 && + g_get_monotonic_time() < deadline) + g_main_context_iteration(NULL, TRUE); +} + +static void test_widget_lifecycle_and_eml(void) +{ + static const char eml[] = + "From: alice@example.test\r\n" + "To: bob@example.test\r\n" + "Cc: copie@example.test\r\n" + "Date: Tue, 28 Jul 2026 10:00:00 +0200\r\n" + "Subject: SPECIMEN partagé\r\n" + "Message-ID: \r\n" + "Content-Type: multipart/mixed; boundary=x\r\n\r\n" + "--x\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n" + "Corps synthétique.\r\n" + "--x\r\nContent-Type: text/plain; name=piece.txt\r\n" + "Content-Disposition: attachment; filename=piece.txt\r\n\r\n" + "contenu\r\n--x--\r\n"; + GError *error = NULL; + char *directory = g_dir_make_tmp("labfy-preview-widget-XXXXXX", &error); + char *path; + char *sha; + EvidencePreviewRequest *request; + TaskManager *manager; + EvidencePreviewWidget *widget; + char *text; + if (directory == NULL) { + g_test_skip(error->message); + g_clear_error(&error); + return; + } + path = g_build_filename(directory, "specimen.eml", NULL); + g_assert_true(g_file_set_contents(path, eml, -1, &error)); + g_assert_no_error(error); + sha = g_compute_checksum_for_string(G_CHECKSUM_SHA256, eml, -1); + manager = task_manager_new(); + widget = evidence_preview_widget_new(manager, NULL, NULL); + g_assert_nonnull(widget); + g_assert_cmpstr(evidence_preview_widget_get_state(widget), ==, "empty"); + request = evidence_preview_request_new(directory, + "11111111-1111-4111-8111-111111111111", + "specimen.eml", sha, NULL, 1); + evidence_preview_widget_show(widget, request, "SPECIMEN"); + g_assert_cmpstr(evidence_preview_widget_get_state(widget), ==, "loading"); + drain_until(widget, "email"); + g_assert_cmpstr(evidence_preview_widget_get_state(widget), ==, "email"); + text = evidence_preview_widget_dup_text(widget); + g_assert_nonnull(g_strstr_len(text, -1, "alice@example.test")); + g_assert_nonnull(g_strstr_len(text, -1, "Corps synthétique.")); + g_assert_nonnull(g_strstr_len(text, -1, "piece.txt")); + g_free(text); + evidence_preview_widget_clear(widget); + g_assert_cmpstr(evidence_preview_widget_get_state(widget), ==, "empty"); + evidence_preview_widget_cancel(widget); + evidence_preview_widget_cancel(widget); + evidence_preview_widget_free(widget); + task_manager_free(manager); + evidence_preview_request_free(request); + g_remove(path); + g_rmdir(directory); + g_free(sha); + g_free(path); + g_free(directory); +} + +int main(int argc, char **argv) +{ + if (!gtk_init_check()) { + g_print("SKIP: aucun affichage GTK disponible.\n"); + return 0; + } + g_test_init(&argc, &argv, NULL); + g_test_add_func("/evidence-preview-widget/eml-lifecycle", + test_widget_lifecycle_and_eml); + return g_test_run(); +} diff --git a/tests/test_evidence_staging.c b/tests/test_evidence_staging.c new file mode 100644 index 0000000..e1b0e8a --- /dev/null +++ b/tests/test_evidence_staging.c @@ -0,0 +1,70 @@ +#include "core/evidence_staging.h" +#include +#include + +static void test_prepare_cleanup(void) +{ + char *directory = g_dir_make_tmp("labfy-staging-test-XXXXXX", NULL); + char *source = g_build_filename(directory, "SPECIMEN été.png", NULL); + const char content[] = "\x89PNG\r\n\x1a\nSPECIMEN"; + EvidenceStaging *staging; + EvidenceStagingResult *result; + GError *error = NULL; + g_assert_true(g_file_set_contents(source, content, + sizeof content - 1, &error)); + staging = evidence_staging_new(&error); + result = evidence_staging_prepare(staging, source, NULL, &error); + g_assert_no_error(error); + g_assert_nonnull(result); + g_assert_cmpstr(result->mime_type, ==, "image/png"); + g_assert_cmpstr(result->suggested_type, ==, "screenshot"); + g_assert_true(g_file_test(result->staging_path, G_FILE_TEST_EXISTS)); + { + char *after = NULL; + gsize length = 0; + g_assert_true(g_file_get_contents(source, &after, &length, NULL)); + g_assert_cmpmem(after, length, content, sizeof content - 1); + g_free(after); + } + g_assert_true(evidence_staging_remove( + staging, result->staging_path, &error)); + g_assert_false(g_file_test(result->staging_path, G_FILE_TEST_EXISTS)); + evidence_staging_result_free(result); + evidence_staging_free(staging); + g_unlink(source); g_rmdir(directory); + g_free(source); g_free(directory); +} +static void test_invalid_sources(void) +{ + char *directory = g_dir_make_tmp("labfy-staging-invalid-XXXXXX", NULL); + char *link_path = g_build_filename(directory, "SPECIMEN-link", NULL); + EvidenceStaging *staging = evidence_staging_new(NULL); + GError *error = NULL; + g_assert_null(evidence_staging_prepare( + staging, directory, NULL, &error)); + g_clear_error(&error); + { + GFile *link_file = g_file_new_for_path(link_path); + g_assert_true(g_file_make_symbolic_link( + link_file, directory, NULL, NULL)); + g_object_unref(link_file); + } + g_assert_null(evidence_staging_prepare( + staging, link_path, NULL, &error)); + g_clear_error(&error); + g_assert_null(evidence_staging_prepare( + staging, "/tmp/SPECIMEN-absent", NULL, &error)); + g_clear_error(&error); + evidence_staging_free(staging); + g_unlink(link_path); g_rmdir(directory); + g_free(link_path); g_free(directory); +} +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/evidence-staging/prepare-cleanup", + test_prepare_cleanup); + g_test_add_func("/evidence-staging/invalid-sources", + test_invalid_sources); + return g_test_run(); +} diff --git a/tests/test_evidence_video_preview_controller.c b/tests/test_evidence_video_preview_controller.c new file mode 100644 index 0000000..fef73d9 --- /dev/null +++ b/tests/test_evidence_video_preview_controller.c @@ -0,0 +1,41 @@ +#include "core/evidence_video_preview_controller.h" +#include +typedef struct { guint pause, seek, detach, release; } Effects; +typedef struct { Effects *effects; } FakeMedia; +static void pause_media(gpointer data) +{ ((FakeMedia *) data)->effects->pause++; } +static void seek_media(gpointer data) +{ ((FakeMedia *) data)->effects->seek++; } +static void detach_media(gpointer data) +{ ((Effects *) data)->detach++; } +static void release_media(gpointer data) +{ ((FakeMedia *) data)->effects->release++; g_free(data); } +static FakeMedia *media_new(Effects *effects) +{ FakeMedia *media = g_new0(FakeMedia, 1); media->effects = effects; return media; } +static void test_replace_close(void) +{ + Effects effects = {0}; + EvidenceVideoPreviewActions actions = { + pause_media, seek_media, detach_media, release_media}; + EvidenceVideoPreviewController *controller = + evidence_video_preview_controller_new(&effects, &actions); + g_assert_false(evidence_video_preview_controller_has_media(controller)); + evidence_video_preview_controller_replace(controller, media_new(&effects)); + g_assert_true(evidence_video_preview_controller_has_media(controller)); + evidence_video_preview_controller_replace(controller, media_new(&effects)); + g_assert_cmpuint(effects.pause, ==, 1); + g_assert_cmpuint(effects.seek, ==, 1); + g_assert_cmpuint(effects.detach, ==, 1); + g_assert_cmpuint(effects.release, ==, 1); + evidence_video_preview_controller_free(controller); + g_assert_cmpuint(effects.pause, ==, 2); + g_assert_cmpuint(effects.seek, ==, 2); + g_assert_cmpuint(effects.detach, ==, 2); + g_assert_cmpuint(effects.release, ==, 2); +} +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/evidence-video/replace-close", test_replace_close); + return g_test_run(); +} diff --git a/tests/test_person_confirmation_summary.c b/tests/test_person_confirmation_summary.c index 675074d..464eb67 100644 --- a/tests/test_person_confirmation_summary.c +++ b/tests/test_person_confirmation_summary.c @@ -50,11 +50,39 @@ static void test_optional_summary(void) "Aucune preuve principale associée")); g_free(summary); } +static void test_multiple_summary(void) +{ + EvidenceRecord *evidence = record(); + PersonEvidenceSelection *selection = person_evidence_selection_new(); + GPtrArray *roles = g_ptr_array_new(); + char *summary; + g_ptr_array_add(roles, "Témoin"); + g_assert_true(person_evidence_selection_add_existing( + selection, evidence, NULL)); + g_assert_true(person_evidence_selection_add_staged(selection, + "/tmp/SPECIMEN-source.pdf", "/tmp/SPECIMEN-stage.pdf", + "SPECIMEN-document.pdf", "application/pdf", "document", 100, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + NULL, "2026-07-28T10:00:00Z", NULL)); + summary = person_confirmation_summary_build_multiple( + "Personne SPECIMEN", NULL, NULL, "Présumé", 40, NULL, + roles, selection); + g_assert_nonnull(strstr(summary, "SPECIMEN-confirmation.png")); + g_assert_nonnull(strstr(summary, "SPECIMEN-document.pdf")); + g_assert_nonnull(strstr(summary, "1 nouvelle(s) preuve(s)")); + g_assert_nonnull(strstr(summary, "1 preuve(s) existante(s)")); + g_assert_nonnull(strstr(summary, "Aucun OCR")); + g_free(summary); + g_ptr_array_unref(roles); + person_evidence_selection_free(selection); + evidence_record_free(evidence); +} int main(int argc, char **argv) { g_test_init(&argc, &argv, NULL); g_test_add_func("/person-confirmation/full", test_full_summary); g_test_add_func("/person-confirmation/optional", test_optional_summary); + g_test_add_func("/person-confirmation/multiple", test_multiple_summary); return g_test_run(); } diff --git a/tests/test_person_creation_coordinator.c b/tests/test_person_creation_coordinator.c new file mode 100644 index 0000000..9046dfe --- /dev/null +++ b/tests/test_person_creation_coordinator.c @@ -0,0 +1,114 @@ +#include "core/evidence_staging.h" +#include "core/person_creation_coordinator.h" +#include "database/database.h" +#include "database/statement.h" +#include +#include + +static guint64 count(Database *database, const char *table) +{ + char *sql = g_strdup_printf("SELECT COUNT(*) FROM %s;", table); + DatabaseStatement *statement = database_statement_prepare(database, sql); + int64_t value = 0; + g_assert_nonnull(statement); + g_assert_cmpint(database_statement_step(statement), ==, + DATABASE_STATEMENT_STEP_ROW); + g_assert_true(database_statement_column_int64(statement, 0, &value)); + database_statement_finalize(statement); + g_free(sql); + return (guint64) value; +} +static void remove_tree(const char *root) +{ + GDir *directory = g_dir_open(root, 0, NULL); + const char *name; + if (directory == NULL) return; + while ((name = g_dir_read_name(directory)) != NULL) { + char *path = g_build_filename(root, name, NULL); + if (g_file_test(path, G_FILE_TEST_IS_DIR)) remove_tree(path); + else g_unlink(path); + g_free(path); + } + g_dir_close(directory); + g_rmdir(root); +} +static void test_success_and_rollback(void) +{ + char *root = g_dir_make_tmp("labfy-coordinator-XXXXXX", NULL); + char *database_directory = + g_build_filename(root, "00_BaseDeDonnees", NULL); + char *database_path = + g_build_filename(database_directory, "Enquete.sqlite", NULL); + char *source_one = g_build_filename(root, "SPECIMEN-one.txt", NULL); + char *source_two = g_build_filename(root, "SPECIMEN-two.txt", NULL); + EvidenceStaging *staging = NULL; + EvidenceStagingResult *prepared_one = NULL, *prepared_two = NULL; + PersonEvidenceSelection *selection = NULL; + PersonCreationCoordinatorResult *result = NULL; + Database *database; + PersonEntityInput person = { + .designation = "Personne SPECIMEN", + .identification_status = "suspected", + .confidence = 20 + }; + GError *error = NULL; + g_assert_cmpint(g_mkdir_with_parents(database_directory, 0700), ==, 0); + g_assert_true(database_initialize( + database_path, "Enquête SPECIMEN", root)); + g_assert_true(g_file_set_contents( + source_one, "SPECIMEN-ONE", -1, NULL)); + g_assert_true(g_file_set_contents( + source_two, "SPECIMEN-TWO", -1, NULL)); + staging = evidence_staging_new(&error); + prepared_one = evidence_staging_prepare( + staging, source_one, NULL, &error); + prepared_two = evidence_staging_prepare( + staging, source_two, NULL, &error); + g_assert_no_error(error); + selection = person_evidence_selection_new(); + g_assert_true(person_evidence_selection_add_staged(selection, + prepared_one->source_path, prepared_one->staging_path, + prepared_one->original_name, prepared_one->mime_type, "text", + prepared_one->size_bytes, prepared_one->sha256, NULL, + prepared_one->prepared_at, &error)); + database = database_open(database_path); + result = person_creation_coordinator_execute(database, root, &person, + selection, NULL, &error); + g_assert_no_error(error); + g_assert_nonnull(result); + g_assert_cmpuint(result->evidence_identifiers->len, ==, 1); + g_assert_cmpuint(count(database, "entites"), ==, 1); + g_assert_cmpuint(count(database, "preuves"), ==, 1); + g_assert_cmpuint(count(database, "preuve_entites"), ==, 1); + g_assert_cmpuint(count(database, "preuve_entite_sources"), ==, 1); + person_creation_coordinator_result_free(result); + person_evidence_selection_free(selection); + selection = person_evidence_selection_new(); + g_assert_true(person_evidence_selection_add_staged(selection, + prepared_two->source_path, prepared_two->staging_path, + prepared_two->original_name, prepared_two->mime_type, "missing_type", + prepared_two->size_bytes, prepared_two->sha256, NULL, + prepared_two->prepared_at, &error)); + result = person_creation_coordinator_execute(database, root, &person, + selection, NULL, &error); + g_assert_null(result); + g_assert_nonnull(error); + g_clear_error(&error); + g_assert_cmpuint(count(database, "entites"), ==, 1); + g_assert_cmpuint(count(database, "preuves"), ==, 1); + database_close(database); + person_evidence_selection_free(selection); + evidence_staging_result_free(prepared_one); + evidence_staging_result_free(prepared_two); + evidence_staging_free(staging); + g_free(database_directory); g_free(database_path); + g_free(source_one); g_free(source_two); + remove_tree(root); g_free(root); +} +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/person-coordinator/success-rollback", + test_success_and_rollback); + return g_test_run(); +} diff --git a/tests/test_person_evidence_selection.c b/tests/test_person_evidence_selection.c new file mode 100644 index 0000000..fc0b783 --- /dev/null +++ b/tests/test_person_evidence_selection.c @@ -0,0 +1,117 @@ +#include "models/person_evidence_selection.h" +#include +#include + +static EvidenceRecord *record(const char *identifier, const char *name, + const char *sha) +{ + EvidenceRecord *record = evidence_record_new(identifier, name, name, + "01_Preuves_Originales/specimen.png", "screenshot", 12, sha, + "2026-07-28T10:00:00Z", NULL, NULL, "SPECIMEN", + EVIDENCE_INTEGRITY_STATUS_VALID, NULL); + evidence_record_set_display_metadata(record, "Capture", "image/png"); + return record; +} +static void test_collection(void) +{ + PersonEvidenceSelection *selection = person_evidence_selection_new(); + EvidenceRecord *first = record( + "10000000-0000-4000-8000-000000000001", "SPECIMEN-1.png", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + EvidenceRecord *second = record( + "10000000-0000-4000-8000-000000000002", "SPECIMEN-2.png", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + GError *error = NULL; + g_assert_cmpuint(person_evidence_selection_get_count(selection), ==, 0); + g_assert_true(person_evidence_selection_add_existing( + selection, first, &error)); + g_assert_true(person_evidence_selection_add_existing( + selection, second, &error)); + g_assert_true(person_evidence_selection_add_staged(selection, + "/tmp/SPECIMEN-source.jpg", "/tmp/SPECIMEN-stage.jpg", + "SPECIMEN.jpg", "image/jpeg", "photo", 24, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "description", "2026-07-28T10:00:00Z", &error)); + g_assert_cmpuint(person_evidence_selection_get_count(selection), ==, 3); + g_assert_cmpstr(person_evidence_selection_item_get_original_name( + person_evidence_selection_get(selection, 0)), ==, "SPECIMEN-1.png"); + g_assert_true(person_evidence_selection_set_active(selection, + person_evidence_selection_item_get_identifier( + person_evidence_selection_get(selection, 2)))); + g_assert_cmpstr(person_evidence_selection_item_get_original_name( + person_evidence_selection_get_active(selection)), ==, "SPECIMEN.jpg"); + g_assert_true(person_evidence_selection_set_type(selection, + person_evidence_selection_item_get_identifier( + person_evidence_selection_get(selection, 2)), "document")); + g_assert_true(person_evidence_selection_remove(selection, + person_evidence_selection_item_get_identifier( + person_evidence_selection_get(selection, 1)))); + g_assert_cmpuint(person_evidence_selection_get_count(selection), ==, 2); + g_assert_true(person_evidence_selection_is_confirmable(selection)); + evidence_record_free(first); evidence_record_free(second); + person_evidence_selection_free(selection); +} +static void test_duplicates(void) +{ + PersonEvidenceSelection *selection = person_evidence_selection_new(); + EvidenceRecord *first = record( + "10000000-0000-4000-8000-000000000001", "SPECIMEN.png", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + GError *error = NULL; + g_assert_true(person_evidence_selection_add_existing( + selection, first, &error)); + g_assert_false(person_evidence_selection_add_existing( + selection, first, &error)); + g_assert_error(error, + g_quark_from_static_string("person-evidence-selection-error"), 2); + g_clear_error(&error); + g_assert_false(person_evidence_selection_add_staged(selection, + "/tmp/SPECIMEN-copy", "/tmp/SPECIMEN-stage", "copy", + "image/png", "photo", 12, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + NULL, "2026-07-28T10:00:00Z", &error)); + g_assert_nonnull(strstr(error->message, "déjà")); + g_clear_error(&error); + g_assert_true(person_evidence_selection_remove(selection, + person_evidence_selection_item_get_identifier( + person_evidence_selection_get(selection, 0)))); + g_assert_true(person_evidence_selection_add_existing( + selection, first, &error)); + evidence_record_free(first); + person_evidence_selection_free(selection); +} +static void test_deep_copy_survives_sources(void) +{ + PersonEvidenceSelection *source = person_evidence_selection_new(); + EvidenceRecord *incomplete = record( + "10000000-0000-4000-8000-000000000003", "SPECIMEN-NULL.jpg", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"); + evidence_record_set_display_metadata(incomplete, NULL, NULL); + g_assert_true(person_evidence_selection_add_existing( + source, incomplete, NULL)); + g_assert_true(person_evidence_selection_set_type(source, + person_evidence_selection_item_get_identifier( + person_evidence_selection_get(source, 0)), "email")); + PersonEvidenceSelection *copy = person_evidence_selection_copy(source); + evidence_record_free(incomplete); + person_evidence_selection_free(source); + g_assert_cmpuint(person_evidence_selection_get_count(copy), ==, 1); + const PersonEvidenceSelectionItem *item = + person_evidence_selection_get(copy, 0); + g_assert_cmpstr(person_evidence_selection_item_get_original_name(item), + ==, "SPECIMEN-NULL.jpg"); + g_assert_cmpstr(person_evidence_selection_item_get_type_identifier(item), + ==, "email"); + g_assert_true(person_evidence_selection_remove(copy, + person_evidence_selection_item_get_identifier(item))); + person_evidence_selection_free(copy); +} +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/person-evidence/collection", test_collection); + g_test_add_func("/person-evidence/duplicates", test_duplicates); + g_test_add_func("/person-evidence/deep-copy", + test_deep_copy_survives_sources); + return g_test_run(); +}