From a3c4b4c51ad94fa544acc3e3be81d510cb37208a Mon Sep 17 00:00:00 2001 From: grayTerminal-sh Date: Tue, 28 Jul 2026 07:39:53 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20raccorder=20le=20pivot=20EML=20=C3=A0?= =?UTF-8?q?=20l'interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/testing/EML_PIVOT_MANUAL_TEST.md | 47 +++++ include/core/eml_pipeline_task.h | 10 + include/views/eml_analysis_dialog.h | 10 + include/views/main_window.h | 4 + include/widgets/workspace.h | 4 + src/core/application.c | 235 +++++++++++++++++++++-- src/core/eml_pipeline_task.c | 54 +++++- src/views/eml_analysis_dialog.c | 230 ++++++++++++++++++++++ src/views/main_window.c | 9 + src/widgets/workspace.c | 13 +- tests/fixtures/eml/manual_smoke_test.eml | 38 ++++ tests/test_eml_pipeline_task.c | 92 +++++++++ 12 files changed, 723 insertions(+), 23 deletions(-) create mode 100644 docs/testing/EML_PIVOT_MANUAL_TEST.md create mode 100644 tests/fixtures/eml/manual_smoke_test.eml diff --git a/docs/testing/EML_PIVOT_MANUAL_TEST.md b/docs/testing/EML_PIVOT_MANUAL_TEST.md new file mode 100644 index 0000000..9883e0e --- /dev/null +++ b/docs/testing/EML_PIVOT_MANUAL_TEST.md @@ -0,0 +1,47 @@ +# Test manuel du parcours EML + +Ce test emploie uniquement la fixture synthétique +`tests/fixtures/eml/manual_smoke_test.eml`. Il ne nécessite aucune donnée +réelle. + +## Parcours synthétique + +1. Compiler avec `make -j8`. +2. Créer une enquête synthétique neuve dans un répertoire de test. +3. Importer `tests/fixtures/eml/manual_smoke_test.eml` comme preuve. +4. Sélectionner cette preuve dans la barre latérale. +5. Vérifier que l’action compacte « Analyser l’e-mail » devient active. +6. Lancer l’action et observer « Analyse complète de l’e-mail » dans le + panneau d’activité. +7. Pendant un premier essai, annuler la tâche depuis ce panneau et vérifier + qu’aucun dialogue de succès ni aucune nouvelle entité n’apparaît. +8. Relancer l’analyse et vérifier dans le dialogue les en-têtes, les deux + pièces jointes, les textes, les métadonnées et les avertissements relatifs + aux outils optionnels éventuellement absents. +9. Vérifier que l’IBAN commençant par `FR00` est indiqué comme donnée de + démonstration invalide et n’est pas proposé à l’intégration. +10. Vérifier que les valeurs sont sélectionnables et copiables. +11. Fermer avec « Rejeter et fermer » et confirmer l’absence de nouvelle + entité, relation ou rattachement. +12. Relancer, sélectionner quelques adresses ou domaines synthétiques, puis + choisir « Intégrer les éléments sélectionnés ». +13. Vérifier le message de bilan, le rafraîchissement du graphe, de la barre + latérale et des détails de preuve. +14. Fermer puis rouvrir l’enquête synthétique et vérifier que les objets + confirmés sont toujours présents. +15. Recalculer l’intégrité de la preuve et vérifier que son SHA-256 est + inchangé. + +Noter séparément le comportement lorsque ExifTool, Tesseract ou les outils +Poppler ne sont pas installés : les en-têtes et l’extraction MIME doivent +rester consultables. + +## Test sur une copie d’enquête réelle + +Ne jamais commencer sur l’unique exemplaire d’une enquête. Copier le dossier +complet, vérifier que la copie s’ouvre, conserver une sauvegarde distincte, +puis lancer Labfy Investigation uniquement sur cette copie. + +Ne jamais transmettre à Codex une preuve, une base SQLite, une pièce jointe +ou une donnée sensible. En cas d’anomalie, relever uniquement les étapes, les +messages techniques expurgés et le comportement observé. diff --git a/include/core/eml_pipeline_task.h b/include/core/eml_pipeline_task.h index e28b3f3..54d1c06 100644 --- a/include/core/eml_pipeline_task.h +++ b/include/core/eml_pipeline_task.h @@ -54,6 +54,16 @@ BackgroundTask *eml_pipeline_task_new_with_tools_and_limit( guint document_analysis_limit ); +BackgroundTask *eml_pipeline_task_start( + const char *eml_path, + const char *staging_directory, + const char *evidence_id, + const DocumentAnalysisTools *tools, + BackgroundTaskCompletionCallback completion_callback, + gpointer completion_data, + GDestroyNotify completion_data_destroy +); + G_END_DECLS #endif /* LABFY_INVESTIGATION_EML_PIPELINE_TASK_H */ diff --git a/include/views/eml_analysis_dialog.h b/include/views/eml_analysis_dialog.h index 1aaafe7..1a56434 100644 --- a/include/views/eml_analysis_dialog.h +++ b/include/views/eml_analysis_dialog.h @@ -6,6 +6,7 @@ #define LABFY_INVESTIGATION_EML_ANALYSIS_DIALOG_H #include "core/eml_processing.h" #include "core/eml_integration.h" +#include "core/eml_pipeline_task.h" #include G_BEGIN_DECLS /** @brief Callback recevant les propositions sélectionnées, ou NULL. */ @@ -19,5 +20,14 @@ typedef void (*EmlAnalysisDialogCallback)(GPtrArray *proposals, void eml_analysis_dialog_present(GtkWindow *parent, const EmlProcessingResult *result, EmlAnalysisDialogCallback callback, gpointer user_data); +void eml_analysis_dialog_present_pipeline( + GtkWindow *parent, + const EmlPipelineResult *result, + const char *evidence_name, + const char *relative_path, + const char *source_sha256, + EmlAnalysisDialogCallback callback, + gpointer user_data +); G_END_DECLS #endif diff --git a/include/views/main_window.h b/include/views/main_window.h index 7aac1ee..1084a3a 100644 --- a/include/views/main_window.h +++ b/include/views/main_window.h @@ -497,6 +497,10 @@ void main_window_set_selected_evidence( MainWindow *main_window, const EvidenceRecord *evidence_record ); +void main_window_set_eml_analysis_available( + MainWindow *main_window, + gboolean available +); /** @brief Transmet au workspace un chemin absolu validé pour l'aperçu. */ void main_window_set_evidence_preview(MainWindow *main_window, diff --git a/include/widgets/workspace.h b/include/widgets/workspace.h index c4721cc..aeed891 100644 --- a/include/widgets/workspace.h +++ b/include/widgets/workspace.h @@ -203,6 +203,10 @@ void workspace_set_selected_evidence( Workspace *workspace, const EvidenceRecord *evidence_record ); +void workspace_set_eml_analysis_available( + Workspace *workspace, + gboolean available +); /** * @brief Affiche l'aperçu local d'un fichier image ou vidéo. diff --git a/src/core/application.c b/src/core/application.c index e5e2602..3250e85 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -58,6 +58,8 @@ #include "core/person_entity_service.h" #include "core/eml_processing.h" #include "core/eml_integration.h" +#include "core/eml_pipeline_task.h" +#include "core/file_hash.h" #include "database/database.h" #include "database/transaction.h" #include "views/create_social_account_dialog.h" @@ -249,7 +251,15 @@ typedef struct static char *application_osint_output_to_utf8(GBytes *bytes); static char *application_osint_create_timestamp(void); /** @brief Contexte possédé pendant la révision d'une analyse EML. */ -typedef struct { Application *application; char *evidence_identifier; } +typedef struct { + Application *application; + InvestigationSession *expected_session; + char *evidence_identifier; + char *evidence_name; + char *relative_path; + char *source_sha256; + char *staging_directory; +} ApplicationEmlReviewContext; static void application_start_graph_loading( @@ -3738,7 +3748,48 @@ cleanup: /** @brief Libère le contexte de révision EML. */ static void application_eml_review_context_free(ApplicationEmlReviewContext *context) -{ if (context == NULL) return; g_free(context->evidence_identifier); g_free(context); } +{ + if (context == NULL) return; + g_free(context->evidence_identifier); + g_free(context->evidence_name); + g_free(context->relative_path); + g_free(context->source_sha256); + g_free(context->staging_directory); + g_free(context); +} + +/** @brief Supprime récursivement une zone de staging créée par l'application. */ +static void application_remove_staging_directory(const char *path) +{ + if (path == NULL) return; + GFile *directory = g_file_new_for_path(path); + GError *error = NULL; + GFileEnumerator *enumerator = g_file_enumerate_children(directory, + G_FILE_ATTRIBUTE_STANDARD_NAME "," G_FILE_ATTRIBUTE_STANDARD_TYPE, + G_FILE_QUERY_INFO_NOFOLLOW_SYMLINKS, NULL, &error); + if (enumerator != NULL) + { + GFileInfo *info = NULL; + while ((info = g_file_enumerator_next_file( + enumerator, NULL, &error)) != NULL) + { + GFile *child = g_file_get_child(directory, + g_file_info_get_name(info)); + char *child_path = g_file_get_path(child); + if (g_file_info_get_file_type(info) == G_FILE_TYPE_DIRECTORY) + application_remove_staging_directory(child_path); + else + (void) g_file_delete(child, NULL, NULL); + g_free(child_path); + g_object_unref(child); + g_object_unref(info); + } + g_object_unref(enumerator); + } + g_clear_error(&error); + (void) g_file_delete(directory, NULL, NULL); + g_object_unref(directory); +} /** @brief Intègre la sélection EML puis actualise le graphe. */ static void application_on_eml_selection_completed( @@ -3749,7 +3800,12 @@ static void application_on_eml_selection_completed( const InvestigationProject *project = NULL; GError *error = NULL; guint created = 0, reused = 0; char *message = NULL; if (proposals == NULL) - { application_eml_review_context_free(context); return; } + { + application_remove_staging_directory(context != NULL + ? context->staging_directory : NULL); + application_eml_review_context_free(context); + return; + } if (application == NULL || application->session == NULL || !eml_integration_apply(investigation_session_get_database(application->session), context->evidence_identifier, proposals, &created, &reused, &error)) @@ -3764,12 +3820,66 @@ static void application_on_eml_selection_completed( project = investigation_session_get_project(application->session); application_start_graph_loading(application, investigation_project_get_database_path(project)); + (void) application_refresh_evidence_models(application, NULL); } g_free(message); g_clear_error(&error); g_ptr_array_unref(proposals); + application_remove_staging_directory(context != NULL + ? context->staging_directory : NULL); application_eml_review_context_free(context); } -/** @brief Prépare une copie vérifiée puis affiche l'analyse EML locale. */ +/** @brief Termine le pipeline EML sur le contexte GTK principal. */ +static void application_on_eml_pipeline_completed( + BackgroundTask *task, + gpointer user_data) +{ + ApplicationEmlReviewContext *context = user_data; + Application *application = context != NULL ? context->application : NULL; + BackgroundTaskState state = background_task_get_state(task); + if (application == NULL || application->main_window == NULL || + application->session == NULL || + application->session != context->expected_session) + { + application_remove_staging_directory(context != NULL + ? context->staging_directory : NULL); + application_eml_review_context_free(context); + return; + } + if (state == BACKGROUND_TASK_STATE_CANCELLED) + { + main_window_set_status(application->main_window, + "Analyse de l’e-mail annulée."); + application_remove_staging_directory(context->staging_directory); + application_eml_review_context_free(context); + return; + } + if (state != BACKGROUND_TASK_STATE_COMPLETED) + { + GError *error = background_task_dup_error(task); + application_present_error(application, "Analyse EML impossible", + error != NULL ? error->message : "Le pipeline EML a échoué."); + g_clear_error(&error); + application_remove_staging_directory(context->staging_directory); + application_eml_review_context_free(context); + return; + } + EmlPipelineResult *result = background_task_get_result(task); + if (result == NULL) + { + application_present_error(application, "Analyse EML impossible", + "Le pipeline n’a produit aucun résultat."); + application_remove_staging_directory(context->staging_directory); + application_eml_review_context_free(context); + return; + } + eml_analysis_dialog_present_pipeline( + main_window_get_window(application->main_window), + result, context->evidence_name, context->relative_path, + context->source_sha256, application_on_eml_selection_completed, + context); +} + +/** @brief Vérifie la preuve puis lance le pipeline EML en arrière-plan. */ static void application_on_analyze_eml_requested( const char *evidence_identifier, gpointer user_data) { @@ -3777,8 +3887,14 @@ static void application_on_analyze_eml_requested( const InvestigationProject *project = NULL; EvidenceDao *dao = NULL; EvidenceRecord *record = NULL; - EmlProcessingResult *result = NULL; ApplicationEmlReviewContext *context = NULL; + BackgroundTask *task = NULL; + char *root = NULL; + char *candidate = NULL; + char *source_path = NULL; + char *current_sha256 = NULL; + guint64 current_size = 0; + char *staging = NULL; GError *error = NULL; if (application == NULL || application->session == NULL || evidence_identifier == NULL) return; @@ -3787,22 +3903,93 @@ static void application_on_analyze_eml_requested( if (dao != NULL) record = evidence_dao_find_by_identifier( dao, evidence_identifier, &error); project = investigation_session_get_project(application->session); - if (record != NULL && project != NULL) - result = eml_processing_prepare( - investigation_project_get_root_path(project), record, &error); - if (result == NULL) - application_present_error(application, "Analyse EML impossible", - error != NULL ? error->message : "La copie de travail n'a pas pu être analysée."); - else + if (record == NULL || project == NULL) goto failure; + root = g_canonicalize_filename( + investigation_project_get_root_path(project), NULL); + candidate = g_build_filename(root, + evidence_record_get_relative_path(record), NULL); + source_path = g_canonicalize_filename(candidate, NULL); + const char *name = evidence_record_get_original_name(record); + char *lower = name != NULL ? g_ascii_strdown(name, -1) : NULL; + gboolean is_eml = lower != NULL && g_str_has_suffix(lower, ".eml"); + g_free(lower); + if (!is_eml || source_path == NULL || + !g_str_has_prefix(source_path, root) || + (source_path[strlen(root)] != G_DIR_SEPARATOR && + source_path[strlen(root)] != '\0') || + !g_file_test(source_path, G_FILE_TEST_IS_REGULAR)) { - context = g_new0(ApplicationEmlReviewContext, 1); - context->application = application; - context->evidence_identifier = g_strdup(evidence_identifier); - eml_analysis_dialog_present(main_window_get_window(application->main_window), - result, application_on_eml_selection_completed, context); + g_set_error_literal(&error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT, + "La preuve sélectionnée n’est pas un fichier EML exploitable."); + goto failure; } - eml_processing_result_free(result); evidence_record_free(record); - evidence_dao_free(dao); g_clear_error(&error); + if (!file_hash_compute_sha256(source_path, NULL, ¤t_sha256, + ¤t_size, &error) || + g_strcmp0(current_sha256, evidence_record_get_sha256(record)) != 0) + { + if (error == NULL) + g_set_error_literal(&error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA, + "L’empreinte SHA-256 de la preuve diverge de " + "l’empreinte enregistrée. L’analyse est bloquée."); + goto failure; + } + staging = g_dir_make_tmp("labfy-eml-staging-XXXXXX", &error); + if (staging == NULL) goto failure; + context = g_new0(ApplicationEmlReviewContext, 1); + context->application = application; + context->expected_session = application->session; + context->evidence_identifier = g_strdup(evidence_identifier); + context->evidence_name = g_strdup(name); + context->relative_path = g_strdup( + evidence_record_get_relative_path(record)); + context->source_sha256 = g_strdup(current_sha256); + context->staging_directory = staging; + staging = NULL; + DocumentAnalysisTools tools = { + .exiftool = "exiftool", .tesseract = "tesseract", + .pdfinfo = "pdfinfo", .pdftotext = "pdftotext", + .pdftoppm = "pdftoppm" + }; + task = eml_pipeline_task_start(source_path, context->staging_directory, + evidence_identifier, &tools, application_on_eml_pipeline_completed, + context, NULL); + if (task == NULL) + goto failure; + context = NULL; + if (!task_manager_add(application->task_manager, task, &error)) + { + background_task_cancel(task); + goto cleanup; + } + main_window_set_status(application->main_window, + "Analyse de l’e-mail lancée en arrière-plan."); + background_task_unref(task); + task = NULL; + goto cleanup; + +failure: + if (task != NULL) + background_task_cancel(task); + if (context != NULL) + application_remove_staging_directory(context->staging_directory); + else + application_remove_staging_directory(staging); + if (context != NULL) + application_eml_review_context_free(context); + if (application != NULL) + application_present_error(application, "Analyse EML impossible", + error != NULL ? error->message : + "La preuve n’a pas pu être analysée."); +cleanup: + background_task_unref(task); + g_free(staging); + g_free(current_sha256); + g_free(source_path); + g_free(candidate); + g_free(root); + evidence_record_free(record); + evidence_dao_free(dao); + g_clear_error(&error); } /** @brief Contexte possédé pendant la révision OCR d'un RIB. */ @@ -5495,6 +5682,7 @@ static void application_on_evidence_selected( 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); @@ -5506,11 +5694,20 @@ static void application_on_evidence_selected( 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)); } } + main_window_set_eml_analysis_available( + application->main_window, eml_analysis_available); original_name = evidence_record_get_original_name( diff --git a/src/core/eml_pipeline_task.c b/src/core/eml_pipeline_task.c index 8d74357..165c603 100644 --- a/src/core/eml_pipeline_task.c +++ b/src/core/eml_pipeline_task.c @@ -113,7 +113,8 @@ static gboolean eml_pipeline_task_worker(BackgroundTask *task, g_ptr_array_add(mime_res->warnings, g_strdup("L'extraction MIME a échoué ou ne contient aucune pièce jointe.")); } - background_task_report_progress(task, 0.75, "Analyse OCR et détection bancaire..."); + background_task_report_progress(task, 0.65, + "Analyse des pièces jointes avec ExifTool…"); GPtrArray *bank_proposals = g_ptr_array_new_with_free_func((GDestroyNotify) bank_proposal_free); GPtrArray *document_analyses = g_ptr_array_new_with_free_func( (GDestroyNotify) document_file_analysis_free); @@ -162,6 +163,10 @@ static gboolean eml_pipeline_task_worker(BackgroundTask *task, g_strcmp0(att->detected_mime, "application/pdf") == 0 || g_strcmp0(att->content_type, "application/pdf") == 0) { + background_task_report_progress(task, 0.78, + g_strcmp0(att->detected_mime, "application/pdf") == 0 + ? "Extraction du texte PDF ou OCR…" + : "Reconnaissance OCR de la pièce jointe…"); if (document_analyses->len >= data->document_analysis_limit) { skipped_document_analyses++; @@ -211,7 +216,10 @@ static gboolean eml_pipeline_task_worker(BackgroundTask *task, } } - background_task_report_progress(task, 1.0, "Analyse EML terminée avec succès."); + background_task_report_progress(task, 0.92, + "Détection des données bancaires terminée."); + background_task_report_progress(task, 0.98, + "Préparation du dialogue de révision…"); EmlPipelineResult *res = g_new0(EmlPipelineResult, 1); res->analysis = analysis; @@ -232,6 +240,8 @@ static gboolean eml_pipeline_task_worker(BackgroundTask *task, if (out_result != NULL) *out_result = res; + background_task_report_progress(task, 1.0, + "Analyse EML terminée avec succès."); return TRUE; } @@ -300,3 +310,43 @@ BackgroundTask *eml_pipeline_task_new_with_tools_and_limit( } return task; } + +BackgroundTask *eml_pipeline_task_start( + const char *eml_path, + const char *staging_directory, + const char *evidence_id, + const DocumentAnalysisTools *tools, + BackgroundTaskCompletionCallback completion_callback, + gpointer completion_data, + GDestroyNotify completion_data_destroy) +{ + if (eml_path == NULL || staging_directory == NULL || tools == NULL) + return NULL; + EmlPipelineTaskData *data = g_new0(EmlPipelineTaskData, 1); + data->eml_path = g_strdup(eml_path); + data->processed_evidence_dir = g_strdup(staging_directory); + data->evidence_id = g_strdup(evidence_id); + data->exiftool = g_strdup(tools->exiftool); + data->tesseract = g_strdup(tools->tesseract); + data->pdfinfo = g_strdup(tools->pdfinfo); + data->pdftotext = g_strdup(tools->pdftotext); + data->pdftoppm = g_strdup(tools->pdftoppm); + data->document_analysis_limit = DOCUMENT_ANALYSIS_MAX_PIPELINE_ITEMS; + BackgroundTask *task = background_task_new( + "Analyse complète de l’e-mail"); + GError *error = NULL; + if (task == NULL || !background_task_start(task, eml_pipeline_task_worker, + data, eml_pipeline_task_data_free, + (GDestroyNotify) eml_pipeline_result_free, + completion_callback, completion_data, completion_data_destroy, + &error)) + { + if (task != NULL) + background_task_unref(task); + else + eml_pipeline_task_data_free(data); + g_clear_error(&error); + return NULL; + } + return task; +} diff --git a/src/views/eml_analysis_dialog.c b/src/views/eml_analysis_dialog.c index f3a0260..c502bc6 100644 --- a/src/views/eml_analysis_dialog.c +++ b/src/views/eml_analysis_dialog.c @@ -206,3 +206,233 @@ void eml_analysis_dialog_present(GtkWindow *parent, g_free(received); g_free(emails); g_free(domains); g_free(sender_ips); g_free(destination_ips); } + +static void eml_analysis_dialog_append_attachment_summary( + GString *text, + const EmlPipelineResult *result) +{ + for (guint index = 0; result->mime_result != NULL && + result->mime_result->attachments != NULL && + index < result->mime_result->attachments->len; index++) + { + EmlAttachment *attachment = g_ptr_array_index( + result->mime_result->attachments, index); + g_string_append_printf(text, + "%s → %s | MIME %s / %s | partie %s | %" + G_GSIZE_FORMAT " octets | SHA-256 %s%s\n", + attachment->declared_filename != NULL + ? attachment->declared_filename : "(sans nom)", + attachment->sanitized_filename != NULL + ? attachment->sanitized_filename : "(sans nom)", + attachment->content_type != NULL + ? attachment->content_type : "inconnu", + attachment->detected_mime != NULL + ? attachment->detected_mime : "inconnu", + attachment->part_index != NULL ? attachment->part_index : "?", + attachment->decoded_size, + attachment->sha256 != NULL ? attachment->sha256 : "indisponible", + attachment->is_truncated ? " | TRONQUÉ" : ""); + } +} + +static void eml_analysis_dialog_append_document_summary( + GString *text, + GString *metadata, + GString *warnings, + const EmlPipelineResult *result) +{ + for (guint index = 0; result->document_analyses != NULL && + index < result->document_analyses->len; index++) + { + DocumentFileAnalysis *document = g_ptr_array_index( + result->document_analyses, index); + if (document->ocr != NULL && document->ocr->text != NULL) + g_string_append_printf(text, "\nOCR (%s)%s\n%s\n", + document->ocr->requested_languages, + document->ocr->execution->stdout_truncated + ? " — texte tronqué/partiel" : "", + document->ocr->text); + if (document->pdf != NULL) + for (guint page_index = 0; + page_index < document->pdf->pages->len; page_index++) + { + PdfPageAnalysis *page = g_ptr_array_index( + document->pdf->pages, page_index); + g_string_append_printf(text, + "\nPDF page %u — méthode %s — état %s\n%s\n", + page->page_number, + page->method == PDF_PAGE_METHOD_NATIVE + ? "texte natif" : "OCR", + document_analysis_state_code(page->state), + page->text != NULL ? page->text : "(aucun texte)"); + } + if (document->metadata != NULL) + { + DocumentToolExecution *execution = + document->metadata->execution; + if (execution != NULL && + execution->state == DOCUMENT_ANALYSIS_STATE_UNAVAILABLE) + g_string_append(warnings, "ExifTool indisponible.\n"); + for (guint metadata_index = 0; + metadata_index < document->metadata->metadata->len; + metadata_index++) + { + DocumentMetadataEntry *entry = g_ptr_array_index( + document->metadata->metadata, metadata_index); + g_string_append_printf(metadata, + "%s | %s:%s | %s | %s%s\n", + entry->code, entry->original_group, + entry->original_tag, entry->raw_value, + execution != NULL && execution->version != NULL + ? execution->version : "version inconnue", + entry->sensitive + ? " | SENSIBLE — confirmation explicite requise" : ""); + } + } + } +} + +void eml_analysis_dialog_present_pipeline( + GtkWindow *parent, + const EmlPipelineResult *result, + const char *evidence_name, + const char *relative_path, + const char *source_sha256, + EmlAnalysisDialogCallback callback, + gpointer user_data) +{ + if (parent == NULL || result == NULL || result->analysis == NULL) return; + EmlAnalysisDialogState *state = g_new0(EmlAnalysisDialogState, 1); + state->callback = callback; state->user_data = user_data; + state->window = GTK_WINDOW(gtk_window_new()); + gtk_window_set_title(state->window, "Révision de l’analyse EML"); + gtk_window_set_transient_for(state->window, parent); + gtk_window_set_modal(state->window, TRUE); + gtk_window_set_default_size(state->window, 900, 700); + GtkWidget *outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); + gtk_widget_set_margin_start(outer, 14); + gtk_widget_set_margin_end(outer, 14); + gtk_widget_set_margin_top(outer, 14); + gtk_widget_set_margin_bottom(outer, 14); + GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); + GtkWidget *grid = gtk_grid_new(); + gtk_grid_set_row_spacing(GTK_GRID(grid), 6); + gtk_grid_set_column_spacing(GTK_GRID(grid), 12); + eml_analysis_dialog_add_field(GTK_GRID(grid), 0, "Preuve", evidence_name); + eml_analysis_dialog_add_field(GTK_GRID(grid), 1, "Chemin relatif", relative_path); + eml_analysis_dialog_add_field(GTK_GRID(grid), 2, "SHA-256", source_sha256); + eml_analysis_dialog_add_field(GTK_GRID(grid), 3, "État global", + document_analysis_state_code(result->state)); + eml_analysis_dialog_add_field(GTK_GRID(grid), 4, "From", + eml_analysis_get_first_header(result->analysis, "from")); + eml_analysis_dialog_add_field(GTK_GRID(grid), 5, "Sender", + eml_analysis_get_first_header(result->analysis, "sender")); + eml_analysis_dialog_add_field(GTK_GRID(grid), 6, "Reply-To", + eml_analysis_get_first_header(result->analysis, "reply-to")); + eml_analysis_dialog_add_field(GTK_GRID(grid), 7, "Return-Path", + eml_analysis_get_first_header(result->analysis, "return-path")); + eml_analysis_dialog_add_field(GTK_GRID(grid), 8, "To", + eml_analysis_get_first_header(result->analysis, "to")); + eml_analysis_dialog_add_field(GTK_GRID(grid), 9, "Cc", + eml_analysis_get_first_header(result->analysis, "cc")); + eml_analysis_dialog_add_field(GTK_GRID(grid), 10, "Subject", + eml_analysis_get_first_header(result->analysis, "subject")); + eml_analysis_dialog_add_field(GTK_GRID(grid), 11, "Date brute", + eml_analysis_get_first_header(result->analysis, "date")); + eml_analysis_dialog_add_field(GTK_GRID(grid), 12, "Message-ID", + eml_analysis_get_first_header(result->analysis, "message-id")); + char *received = eml_analysis_dialog_join( + eml_analysis_get_header_values(result->analysis, "received")); + eml_analysis_dialog_add_field(GTK_GRID(grid), 13, + "Received (ordre original)", received); + gtk_box_append(GTK_BOX(content), grid); + + GString *attachments = g_string_new(NULL); + GString *texts = g_string_new(NULL); + GString *metadata = g_string_new(NULL); + GString *warnings = g_string_new(NULL); + eml_analysis_dialog_append_attachment_summary(attachments, result); + eml_analysis_dialog_append_document_summary( + texts, metadata, warnings, result); + for (guint index = 0; result->warnings != NULL && + index < result->warnings->len; index++) + g_string_append_printf(warnings, "%s\n", + (char *) g_ptr_array_index(result->warnings, index)); + for (guint index = 0; result->bank_proposals != NULL && + index < result->bank_proposals->len; index++) + { + BankProposal *proposal = g_ptr_array_index( + result->bank_proposals, index); + if (!proposal->is_iban_valid || + proposal->suggested_ocr_fix != NULL) + g_string_append_printf(warnings, + "Proposition bancaire invalide ou corrigée, " + "désélectionnée : %s\n", + proposal->raw_iban != NULL ? proposal->raw_iban : "(vide)"); + } + GtkWidget *details = gtk_grid_new(); + gtk_grid_set_row_spacing(GTK_GRID(details), 6); + gtk_grid_set_column_spacing(GTK_GRID(details), 12); + eml_analysis_dialog_add_field(GTK_GRID(details), 0, + "Pièces jointes", attachments->str); + eml_analysis_dialog_add_field(GTK_GRID(details), 1, + "Texte PDF et OCR", texts->str); + eml_analysis_dialog_add_field(GTK_GRID(details), 2, + "Métadonnées", metadata->str); + eml_analysis_dialog_add_field(GTK_GRID(details), 3, + "Avertissements", warnings->str); + gtk_box_append(GTK_BOX(content), details); + + state->proposals_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + eml_analysis_dialog_add_proposals(state->proposals_box, "email_address", + "E-mail", eml_analysis_get_email_addresses(result->analysis)); + eml_analysis_dialog_add_proposals(state->proposals_box, "domain_name", + "Domaine", eml_analysis_get_domains(result->analysis)); + eml_analysis_dialog_add_proposals(state->proposals_box, "ip_address", + "IP expéditeur", eml_analysis_get_sender_ip_addresses(result->analysis)); + GPtrArray *valid_ibans = g_ptr_array_new(); + for (guint index = 0; result->bank_proposals != NULL && + index < result->bank_proposals->len; index++) + { + BankProposal *proposal = g_ptr_array_index( + result->bank_proposals, index); + if (proposal->is_iban_valid && proposal->normalized_iban != NULL && + proposal->suggested_ocr_fix == NULL) + g_ptr_array_add(valid_ibans, proposal->normalized_iban); + } + eml_analysis_dialog_add_proposals(state->proposals_box, "iban", + "IBAN valide", valid_ibans); + g_ptr_array_unref(valid_ibans); + gtk_box_append(GTK_BOX(content), gtk_label_new( + "Propositions à intégrer explicitement")); + gtk_box_append(GTK_BOX(content), state->proposals_box); + + GtkWidget *scroll = gtk_scrolled_window_new(); + gtk_widget_set_vexpand(scroll, TRUE); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), content); + gtk_box_append(GTK_BOX(outer), scroll); + GtkWidget *actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_set_halign(actions, GTK_ALIGN_END); + GtkWidget *close = gtk_button_new_with_label("Rejeter et fermer"); + GtkWidget *integrate = gtk_button_new_with_label( + "Intégrer les éléments sélectionnés"); + gtk_widget_add_css_class(integrate, "suggested-action"); + g_signal_connect(close, "clicked", + G_CALLBACK(eml_analysis_dialog_on_close), state); + g_signal_connect(integrate, "clicked", + G_CALLBACK(eml_analysis_dialog_on_integrate), state); + gtk_box_append(GTK_BOX(actions), close); + gtk_box_append(GTK_BOX(actions), integrate); + gtk_box_append(GTK_BOX(outer), actions); + gtk_window_set_child(state->window, outer); + g_signal_connect(state->window, "close-request", + G_CALLBACK(eml_analysis_dialog_on_window_close), state); + g_object_set_data_full(G_OBJECT(state->window), "eml-dialog-state", + state, eml_analysis_dialog_state_free); + gtk_window_present(state->window); + g_free(received); + g_string_free(attachments, TRUE); + g_string_free(texts, TRUE); + g_string_free(metadata, TRUE); + g_string_free(warnings, TRUE); +} diff --git a/src/views/main_window.c b/src/views/main_window.c index 0ad80d5..26e9248 100644 --- a/src/views/main_window.c +++ b/src/views/main_window.c @@ -1995,6 +1995,15 @@ void main_window_set_selected_evidence( ); } +void main_window_set_eml_analysis_available( + MainWindow *main_window, + gboolean available) +{ + if (main_window != NULL) + workspace_set_eml_analysis_available( + main_window->workspace, available); +} + void main_window_set_evidence_preview(MainWindow *main_window, const char *file_path, const char *display_name) { diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index 85f0714..ab049c4 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -1598,11 +1598,11 @@ Workspace *workspace_new(void) G_CALLBACK(workspace_on_edit_evidence_clicked), workspace); gtk_box_append(GTK_BOX(evidence_content), workspace->edit_evidence_button); workspace->analyze_eml_button = gtk_button_new_with_label( - "Analyser les en-têtes EML"); + "Analyser l’e-mail"); gtk_widget_set_halign(workspace->analyze_eml_button, GTK_ALIGN_START); gtk_widget_set_sensitive(workspace->analyze_eml_button, FALSE); gtk_widget_set_tooltip_text(workspace->analyze_eml_button, - "Créer une copie vérifiée puis analyser localement ses en-têtes"); + "Vérifier la preuve puis lancer l’analyse complète en arrière-plan"); g_signal_connect(workspace->analyze_eml_button, "clicked", G_CALLBACK(workspace_on_analyze_eml_clicked), workspace); gtk_box_append(GTK_BOX(evidence_content), workspace->analyze_eml_button); @@ -2950,6 +2950,15 @@ void workspace_set_selected_evidence( ); } +void workspace_set_eml_analysis_available( + Workspace *workspace, + gboolean available) +{ + if (workspace != NULL && workspace->analyze_eml_button != NULL) + gtk_widget_set_sensitive( + workspace->analyze_eml_button, available); +} + void workspace_set_evidence_preview(Workspace *workspace, const char *file_path, const char *display_name) { diff --git a/tests/fixtures/eml/manual_smoke_test.eml b/tests/fixtures/eml/manual_smoke_test.eml new file mode 100644 index 0000000..72d8db2 --- /dev/null +++ b/tests/fixtures/eml/manual_smoke_test.eml @@ -0,0 +1,38 @@ +From: Expediteur Demo +Sender: Service Demo +Reply-To: Reponse Differente +Return-Path: +To: Destinataire Demo +Cc: Controle Demo +Subject: Démonstration synthétique du parcours EML +Date: Tue, 28 Jul 2026 10:30:00 +0200 +Message-ID: +Received: from relay2.example.net (relay2.example.net [198.51.100.20]) by mx.example.org with ESMTP id DEMO2 +Received: from relay1.example.com (relay1.example.com [192.0.2.10]) by relay2.example.net with ESMTP id DEMO1 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="LABFY-DEMO-107" + +--LABFY-DEMO-107 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Ceci est un message entièrement synthétique destiné au test manuel. +IBAN DE DÉMONSTRATION VOLONTAIREMENT INVALIDE : FR00 0000 0000 0000 0000 0000 000 +BIC SYNTHÉTIQUE DE FORMAT CONTRÔLABLE : DEMOFRPP + +--LABFY-DEMO-107 +Content-Type: text/plain; name="demonstration.txt" +Content-Disposition: attachment; filename="demonstration.txt" +Content-Transfer-Encoding: base64 + +RmljaGllciBzeW50aMOpdGlxdWUgZGUgZMOpbW9uc3RyYXRpb24uIElCQU4gREVNTyBJTlZBTElERTogRlIwMCAwMDAwIDAwMDAgMDAwMCAwMDAwIDAwMDAgMDAwLiBCSUMgREVNT0ZSUFAu + +--LABFY-DEMO-107 +Content-Type: image/png; name="pixel_synthetique.png" +Content-Disposition: inline; filename="pixel_synthetique.png" +Content-ID: +Content-Transfer-Encoding: base64 + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII= + +--LABFY-DEMO-107-- diff --git a/tests/test_eml_pipeline_task.c b/tests/test_eml_pipeline_task.c index 5e2d1f3..4902e9b 100644 --- a/tests/test_eml_pipeline_task.c +++ b/tests/test_eml_pipeline_task.c @@ -145,6 +145,35 @@ static void wait_for_task(BackgroundTask *task) g_main_context_iteration(NULL, TRUE); } +static void remove_tree(const char *path) +{ + GFile *directory = g_file_new_for_path(path); + GFileEnumerator *enumerator = g_file_enumerate_children(directory, + G_FILE_ATTRIBUTE_STANDARD_NAME "," G_FILE_ATTRIBUTE_STANDARD_TYPE, + G_FILE_QUERY_INFO_NOFOLLOW_SYMLINKS, NULL, NULL); + if (enumerator != NULL) + { + GFileInfo *info = NULL; + while ((info = g_file_enumerator_next_file( + enumerator, NULL, NULL)) != NULL) + { + GFile *child = g_file_get_child(directory, + g_file_info_get_name(info)); + char *child_path = g_file_get_path(child); + if (g_file_info_get_file_type(info) == G_FILE_TYPE_DIRECTORY) + remove_tree(child_path); + else + g_assert_true(g_file_delete(child, NULL, NULL)); + g_free(child_path); + g_object_unref(child); + g_object_unref(info); + } + g_object_unref(enumerator); + } + g_assert_true(g_file_delete(directory, NULL, NULL)); + g_object_unref(directory); +} + static DocumentAnalysisTools synthetic_tools(void) { DocumentAnalysisTools tools = { @@ -157,6 +186,67 @@ static DocumentAnalysisTools synthetic_tools(void) return tools; } +typedef struct +{ + gboolean called; + BackgroundTaskState state; +} PipelineCompletion; + +static void pipeline_completed(BackgroundTask *task, gpointer user_data) +{ + PipelineCompletion *completion = user_data; + completion->called = TRUE; + completion->state = background_task_get_state(task); +} + +static void test_manual_fixture_async_start(void) +{ + GError *error = NULL; + char *source_before = NULL; + gsize source_length = 0; + g_assert_true(g_file_get_contents( + "tests/fixtures/eml/manual_smoke_test.eml", + &source_before, &source_length, &error)); + g_assert_no_error(error); + char *staging = g_dir_make_tmp("labfy-eml-manual-XXXXXX", &error); + g_assert_no_error(error); + DocumentAnalysisTools tools = synthetic_tools(); + PipelineCompletion completion = { 0 }; + BackgroundTask *task = eml_pipeline_task_start( + "tests/fixtures/eml/manual_smoke_test.eml", staging, + "synthetic-evidence", &tools, pipeline_completed, + &completion, NULL); + g_assert_nonnull(task); + wait_for_task(task); + while (!completion.called) + g_main_context_iteration(NULL, TRUE); + g_assert_cmpint(completion.state, ==, + BACKGROUND_TASK_STATE_COMPLETED); + EmlPipelineResult *result = background_task_get_result(task); + g_assert_nonnull(result); + g_assert_cmpuint(result->mime_result->attachments->len, ==, 2); + g_assert_cmpuint(result->bank_proposals->len, ==, 1); + BankProposal *bank = g_ptr_array_index(result->bank_proposals, 0); + g_assert_false(bank->is_iban_valid); + g_assert_cmpstr(bank->normalized_iban, ==, + "FR0000000000000000000000000"); + g_assert_cmpuint( + eml_analysis_get_header_values(result->analysis, "received")->len, + ==, 2); + char *source_after = NULL; + gsize after_length = 0; + g_assert_true(g_file_get_contents( + "tests/fixtures/eml/manual_smoke_test.eml", + &source_after, &after_length, &error)); + g_assert_no_error(error); + g_assert_cmpmem(source_after, after_length, source_before, source_length); + g_free(source_after); + g_free(source_before); + background_task_unref(task); + remove_tree(staging); + g_free(staging); +} + static void test_eml_pipeline_pdf_end_to_end_and_limit(void) { GError *error = NULL; @@ -238,5 +328,7 @@ int main(int argc, char **argv) test_eml_pipeline_document_analysis); g_test_add_func("/eml-pipeline-task/pdf-end-to-end-limit", test_eml_pipeline_pdf_end_to_end_and_limit); + g_test_add_func("/eml-pipeline-task/manual-fixture-async", + test_manual_fixture_async_start); return g_test_run(); }