diff --git a/Makefile b/Makefile index 4f8b125..b4c02e6 100644 --- a/Makefile +++ b/Makefile @@ -120,6 +120,7 @@ TEST_OSINT_DNS_PROPOSAL := tests/test_osint_dns_proposal TEST_OSINT_DNS_INTEGRATION := tests/test_osint_dns_integration TEST_OSINT_EXECUTION_DAO := tests/test_osint_execution_dao TEST_OSINT_EXECUTION_INTEGRITY := tests/test_osint_execution_integrity +TEST_EVIDENCE_RECLASSIFICATION := tests/test_evidence_reclassification all: $(TARGET) @@ -595,6 +596,19 @@ $(TEST_OSINT_EXECUTION_INTEGRITY): \ src/core/osint_execution_integrity.c $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) +$(TEST_EVIDENCE_RECLASSIFICATION): \ + tests/test_evidence_reclassification.c \ + src/core/evidence_reclassification.c \ + src/core/file_hash.c \ + src/dao/evidence_dao.c \ + src/models/evidence_record.c \ + src/database/database.c \ + src/database/schema.c \ + src/database/statement.c \ + src/database/transaction.c \ + src/database/error.c + $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) -lsqlite3 + $(TEST_INVESTIGATION_GRAPH_LOAD_TASK): \ tests/test_investigation_graph_load_task.c \ src/core/investigation_graph_load_task.c \ @@ -669,7 +683,8 @@ test: \ $(TEST_OSINT_DNS_PROPOSAL) \ $(TEST_OSINT_DNS_INTEGRATION) \ $(TEST_OSINT_EXECUTION_DAO) \ - $(TEST_OSINT_EXECUTION_INTEGRITY) + $(TEST_OSINT_EXECUTION_INTEGRITY) \ + $(TEST_EVIDENCE_RECLASSIFICATION) @echo "Exécution des tests..." @./$(TEST_NODE) @./$(TEST_TREE_MODEL) @@ -724,6 +739,7 @@ test: \ @$(TEST_OSINT_DNS_INTEGRATION) @$(TEST_OSINT_EXECUTION_DAO) @$(TEST_OSINT_EXECUTION_INTEGRITY) + @$(TEST_EVIDENCE_RECLASSIFICATION) @echo "Tous les tests sont valides." %.o: %.c @@ -784,7 +800,8 @@ clean: $(TEST_OSINT_DNS_PROPOSAL) \ $(TEST_OSINT_DNS_INTEGRATION) \ $(TEST_OSINT_EXECUTION_DAO) \ - $(TEST_OSINT_EXECUTION_INTEGRITY) + $(TEST_OSINT_EXECUTION_INTEGRITY) \ + $(TEST_EVIDENCE_RECLASSIFICATION) -include $(DEP) diff --git a/README.md b/README.md index aa80d9f..8dd2c00 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ Le socle actuel comprend notamment : - panneau d’activité GTK ; - import groupé de preuves avec confirmation globale, révision individuelle, copie contrôlée, empreinte SHA-256 et bilan détaillé ; +- correction du type, de la source et de la description d'une preuve avec + contrôle d'intégrité et déplacement cohérent de sa copie interne ; - registre d’outils externes ; - exécution sécurisée par `GSubprocess` ; - exécution d’outils en tâche de fond ; diff --git a/docs/database/DATABASE_ARCHITECTURE.md b/docs/database/DATABASE_ARCHITECTURE.md index ef6164a..93e7529 100644 --- a/docs/database/DATABASE_ARCHITECTURE.md +++ b/docs/database/DATABASE_ARCHITECTURE.md @@ -766,6 +766,13 @@ importé séparément. Les imports sont exécutés séquentiellement afin d'évi les écritures SQLite concurrentes. L'échec d'un fichier n'annule pas les preuves déjà importées et apparaît dans le bilan final. +Le classement, la source et la description peuvent être corrigés après +l'import. Un changement de type déplace la copie interne vers le dossier +adapté dans la même opération logique que la mise à jour SQLite. L'empreinte +et la taille sont vérifiées avant et après ; un échec annule la transaction et +restaure le chemin initial. L'UUID, le nom interne et la date d'import restent +inchangés. + --- ## 4.6 entites diff --git a/include/core/evidence_reclassification.h b/include/core/evidence_reclassification.h new file mode 100644 index 0000000..ff2133e --- /dev/null +++ b/include/core/evidence_reclassification.h @@ -0,0 +1,44 @@ +/****************************************************************************** + * @file evidence_reclassification.h + * @brief Reclassement cohérent d'une preuve sans modifier son contenu. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_EVIDENCE_RECLASSIFICATION_H +#define LABFY_INVESTIGATION_EVIDENCE_RECLASSIFICATION_H + +#include "database/database.h" + +#include + +G_BEGIN_DECLS + +/** + * @brief Déplace une preuve et met à jour ses métadonnées avec retour arrière. + * + * L'intégrité est contrôlée avant et après le déplacement. Le contenu, + * l'UUID, le nom interne, la taille, l'empreinte et la date d'import ne sont + * jamais modifiés. + * + * @param database Connexion SQLite ouverte. + * @param investigation_root Racine de l'enquête. + * @param evidence_identifier UUID de la preuve. + * @param type_identifier Nouveau type métier. + * @param relative_directory Dossier relatif de destination. + * @param source Nouvelle source, ou NULL. + * @param description Nouvelle description, ou NULL. + * @param error Adresse recevant une éventuelle erreur. + * @return TRUE si le reclassement complet réussit. + */ +gboolean evidence_reclassification_apply( + Database *database, + const char *investigation_root, + const char *evidence_identifier, + const char *type_identifier, + const char *relative_directory, + const char *source, + const char *description, + GError **error +); + +G_END_DECLS +#endif diff --git a/include/dao/evidence_dao.h b/include/dao/evidence_dao.h index 9788e60..9d0c38e 100644 --- a/include/dao/evidence_dao.h +++ b/include/dao/evidence_dao.h @@ -144,6 +144,34 @@ gboolean evidence_dao_update_integrity_status( GError **error ); +/** + * @brief Met à jour le classement et les métadonnées éditables d'une preuve. + * + * L'UUID, le nom interne, la taille, l'empreinte et la date d'import restent + * inchangés. La transaction éventuelle reste sous la responsabilité de + * l'appelant. + * + * @param evidence_dao DAO valide. + * @param identifier UUID de la preuve. + * @param type_identifier Nouveau code de type. + * @param relative_path Nouveau chemin relatif du fichier. + * @param source Nouvelle source, ou NULL. + * @param description Nouvelle description, ou NULL. + * @param updated_at Date UTC de modification. + * @param error Adresse recevant une éventuelle erreur. + * @return TRUE si la mise à jour réussit. + */ +gboolean evidence_dao_update_metadata( + EvidenceDao *evidence_dao, + const char *identifier, + const char *type_identifier, + const char *relative_path, + const char *source, + const char *description, + const char *updated_at, + GError **error +); + /** * @brief Compte les preuves persistées. * diff --git a/include/views/evidence_metadata_dialog.h b/include/views/evidence_metadata_dialog.h new file mode 100644 index 0000000..1b7b814 --- /dev/null +++ b/include/views/evidence_metadata_dialog.h @@ -0,0 +1,53 @@ +/****************************************************************************** + * @file evidence_metadata_dialog.h + * @brief Dialogue de modification des métadonnées éditables d'une preuve. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_EVIDENCE_METADATA_DIALOG_H +#define LABFY_INVESTIGATION_EVIDENCE_METADATA_DIALOG_H + +#include "models/evidence_record.h" + +#include + +G_BEGIN_DECLS + +/** @brief Résultat opaque possédé du dialogue. */ +typedef struct EvidenceMetadataDialogResult EvidenceMetadataDialogResult; + +/** @brief Callback recevant le résultat possédé, ou NULL après annulation. */ +typedef void (*EvidenceMetadataDialogCallback)( + EvidenceMetadataDialogResult *result, gpointer user_data +); + +/** + * @brief Présente le dialogue prérempli de modification des métadonnées. + * @param parent Fenêtre parente. + * @param record Preuve empruntée. + * @param evidence_types Tableau emprunté de EvidenceType. + * @param callback Callback appelé une fois à la fermeture. + * @param user_data Données empruntées du callback. + * @return TRUE si la fenêtre a été créée. + */ +gboolean evidence_metadata_dialog_present( + GtkWindow *parent, + const EvidenceRecord *record, + const GPtrArray *evidence_types, + EvidenceMetadataDialogCallback callback, + gpointer user_data +); + +/** @brief Libère un résultat, ou accepte NULL. */ +void evidence_metadata_dialog_result_free(EvidenceMetadataDialogResult *result); +/** @brief Retourne le code de type emprunté. */ +const char *evidence_metadata_dialog_result_get_type_identifier( + const EvidenceMetadataDialogResult *result); +/** @brief Retourne la source empruntée, ou NULL. */ +const char *evidence_metadata_dialog_result_get_source( + const EvidenceMetadataDialogResult *result); +/** @brief Retourne la description empruntée, ou NULL. */ +const char *evidence_metadata_dialog_result_get_description( + const EvidenceMetadataDialogResult *result); + +G_END_DECLS +#endif diff --git a/include/views/main_window.h b/include/views/main_window.h index cd992cc..feefb4d 100644 --- a/include/views/main_window.h +++ b/include/views/main_window.h @@ -86,6 +86,12 @@ typedef void (*MainWindowVerifyEvidenceCallback)( gpointer user_data ); +/** @brief Callback appelé pour modifier les métadonnées d'une preuve. */ +typedef void (*MainWindowEditEvidenceCallback)( + const char *evidence_identifier, + gpointer user_data +); + /** * @brief Callback appelé après le déplacement effectif d'un nœud. * @@ -151,6 +157,18 @@ void main_window_set_verify_evidence_callback( gpointer user_data ); +/** + * @brief Définit le callback de modification de la preuve sélectionnée. + * @param main_window Fenêtre principale. + * @param callback Callback facultatif. + * @param user_data Données privées transmises au callback. + */ +void main_window_set_edit_evidence_callback( + MainWindow *main_window, + MainWindowEditEvidenceCallback callback, + gpointer user_data +); + /** * @brief Définit le callback de fin de déplacement d'un nœud. * diff --git a/include/widgets/workspace.h b/include/widgets/workspace.h index 5fb7b29..433c9b6 100644 --- a/include/widgets/workspace.h +++ b/include/widgets/workspace.h @@ -95,6 +95,12 @@ typedef void (*WorkspaceOsintActionCallback)( gpointer user_data ); +/** @brief Callback appelé pour modifier les métadonnées d'une preuve. */ +typedef void (*WorkspaceEditEvidenceCallback)( + const char *evidence_identifier, + gpointer user_data +); + /** * @brief Crée une nouvelle zone de travail. * @@ -204,6 +210,18 @@ void workspace_set_osint_action_callback( gpointer user_data ); +/** + * @brief Définit le callback de modification de la preuve sélectionnée. + * @param workspace Zone de travail. + * @param callback Callback facultatif. + * @param user_data Données empruntées transmises au callback. + */ +void workspace_set_edit_evidence_callback( + Workspace *workspace, + WorkspaceEditEvidenceCallback callback, + gpointer user_data +); + /** * @brief Définit le callback de vérification de la preuve affichée. * diff --git a/src/core/application.c b/src/core/application.c index 1d61966..4e5c944 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -27,12 +27,14 @@ #include "core/tool_task.h" #include "core/osint_dns_integration.h" #include "core/osint_execution_integrity.h" +#include "core/evidence_reclassification.h" #include "views/file_dialog.h" #include "core/evidence_import_task.h" #include "models/evidence_record.h" #include "dao/evidence_type_dao.h" #include "dao/graph_node_position_dao.h" #include "views/evidence_import_dialog.h" +#include "views/evidence_metadata_dialog.h" #include "views/create_relation_dialog.h" #include "views/osint_dns_review_dialog.h" #include "views/osint_execution_history_dialog.h" @@ -159,6 +161,20 @@ typedef struct char *file_path; } ApplicationEvidenceDialogContext; +/** @brief Contexte possédé pendant l'édition d'une preuve. */ +typedef struct +{ + Application *application; + char *evidence_identifier; +} ApplicationEvidenceMetadataContext; + +/** @brief Données d'actualisation différée après reclassement. */ +typedef struct +{ + Application *application; + char *root_path; +} ApplicationEvidenceRefreshContext; + /** * @brief Contexte conservé jusqu'à la fin d'une vérification. */ @@ -3138,6 +3154,131 @@ static const char *application_get_evidence_subdirectory( return "Documents"; } +/** @brief Libère le contexte d'édition des métadonnées. */ +static void application_evidence_metadata_context_free( + ApplicationEvidenceMetadataContext *context +) +{ + if (context == NULL) return; + g_free(context->evidence_identifier); g_free(context); +} + +/** @brief Actualise les vues après la destruction du dialogue d'édition. */ +static gboolean application_refresh_after_evidence_reclassification( + gpointer user_data +) +{ + ApplicationEvidenceRefreshContext *context = user_data; + GError *error = NULL; + if (context != NULL && context->application != NULL && + context->application->main_window != NULL) + { + application_refresh_investigation_tree( + context->application, context->root_path); + application_refresh_evidence_models(context->application, &error); + main_window_set_selected_evidence( + context->application->main_window, NULL); + application_message_dialog_present( + main_window_get_window(context->application->main_window), + APPLICATION_MESSAGE_DIALOG_INFORMATION, "Preuve reclassée", + "Les métadonnées et le dossier ont été mis à jour. Le SHA-256 est inchangé."); + } + g_clear_error(&error); + if (context != NULL) { g_free(context->root_path); g_free(context); } + return G_SOURCE_REMOVE; +} + +/** @brief Applique le reclassement confirmé puis actualise les vues. */ +static void application_on_evidence_metadata_edited( + EvidenceMetadataDialogResult *result, gpointer user_data +) +{ + ApplicationEvidenceMetadataContext *context = user_data; + Application *application = context != NULL ? context->application : NULL; + const InvestigationProject *project = NULL; + const char *root_path = NULL; const char *subdirectory = NULL; + char *relative_directory = NULL; GError *error = NULL; + if (result == NULL) + { + application_evidence_metadata_context_free(context); + return; + } + if (application == NULL || application->session == NULL) goto cleanup; + project = investigation_session_get_project(application->session); + root_path = project != NULL ? investigation_project_get_root_path(project) : NULL; + subdirectory = application_get_evidence_subdirectory( + evidence_metadata_dialog_result_get_type_identifier(result)); + relative_directory = g_build_filename( + APPLICATION_EVIDENCE_ROOT_DIRECTORY, subdirectory, NULL); + if (!evidence_reclassification_apply( + investigation_session_get_database(application->session), root_path, + context->evidence_identifier, + evidence_metadata_dialog_result_get_type_identifier(result), + relative_directory, + evidence_metadata_dialog_result_get_source(result), + evidence_metadata_dialog_result_get_description(result), &error)) + { + application_present_error(application, "Modification impossible", + error != NULL ? error->message : "Le reclassement a échoué."); + g_clear_error(&error); goto cleanup; + } + { + ApplicationEvidenceRefreshContext *refresh_context = + g_new0(ApplicationEvidenceRefreshContext, 1); + refresh_context->application = application; + refresh_context->root_path = g_strdup(root_path); + g_timeout_add(50U, + application_refresh_after_evidence_reclassification, + refresh_context); + } +cleanup: + g_free(relative_directory); + evidence_metadata_dialog_result_free(result); + application_evidence_metadata_context_free(context); +} + +/** @brief Charge et présente les métadonnées éditables d'une preuve. */ +static void application_on_edit_evidence_requested( + const char *evidence_identifier, gpointer user_data +) +{ + Application *application = user_data; Database *database = NULL; + EvidenceDao *evidence_dao = NULL; EvidenceTypeDao *type_dao = NULL; + EvidenceRecord *record = NULL; GPtrArray *types = NULL; + ApplicationEvidenceMetadataContext *context = NULL; GError *error = NULL; + if (application == NULL || application->session == NULL || + evidence_identifier == NULL) return; + database = investigation_session_get_database(application->session); + evidence_dao = evidence_dao_new(database, &error); + type_dao = evidence_type_dao_new(database, &error); + if (evidence_dao != NULL) + record = evidence_dao_find_by_identifier( + evidence_dao, evidence_identifier, &error); + if (type_dao != NULL) types = evidence_type_dao_list_all(type_dao, &error); + if (record == NULL || types == NULL || types->len == 0U) + { + application_present_error(application, "Modification impossible", + error != NULL ? error->message : "Les métadonnées sont indisponibles."); + goto cleanup; + } + context = g_new0(ApplicationEvidenceMetadataContext, 1); + context->application = application; + context->evidence_identifier = g_strdup(evidence_identifier); + if (!evidence_metadata_dialog_present( + main_window_get_window(application->main_window), record, types, + application_on_evidence_metadata_edited, context)) + { + application_evidence_metadata_context_free(context); + context = NULL; + application_present_error(application, "Modification impossible", + "Le dialogue de métadonnées n'a pas pu être créé."); + } +cleanup: + g_clear_error(&error); g_clear_pointer(&types, g_ptr_array_unref); + evidence_record_free(record); evidence_type_dao_free(type_dao); + evidence_dao_free(evidence_dao); +} + /** * @brief Prépare et démarre l’import asynchrone d’un fichier. */ @@ -5952,6 +6093,12 @@ static void application_on_activate( application ); + main_window_set_edit_evidence_callback( + application->main_window, + application_on_edit_evidence_requested, + application + ); + main_window_set_graph_node_moved_callback( application->main_window, application_on_graph_node_moved, diff --git a/src/core/evidence_reclassification.c b/src/core/evidence_reclassification.c new file mode 100644 index 0000000..098502d --- /dev/null +++ b/src/core/evidence_reclassification.c @@ -0,0 +1,120 @@ +/****************************************************************************** + * @file evidence_reclassification.c + * @brief Reclassement cohérent d'une preuve sans modifier son contenu. + ******************************************************************************/ + +#include "core/evidence_reclassification.h" + +#include "core/file_hash.h" +#include "dao/evidence_dao.h" +#include "database/transaction.h" +#include "models/evidence_record.h" + +#include +#include + +/** @brief Enregistre une erreur de reclassement si aucune n'existe encore. */ +static void evidence_reclassification_set_error( + GError **error, const char *message +) +{ + if (error != NULL && *error == NULL) + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED, message); +} + +gboolean evidence_reclassification_apply( + Database *database, const char *investigation_root, + const char *evidence_identifier, const char *type_identifier, + const char *relative_directory, const char *source, + const char *description, GError **error +) +{ + EvidenceDao *dao = NULL; EvidenceRecord *record = NULL; + char *old_path = NULL; char *new_path = NULL; char *new_relative_path = NULL; + char *new_parent = NULL; char *sha256 = NULL; char *updated_at = NULL; + guint64 size_bytes = 0U; GDateTime *now = NULL; + gboolean moved = FALSE; gboolean transaction_started = FALSE; + gboolean success = FALSE; + if (database == NULL || investigation_root == NULL || + evidence_identifier == NULL || type_identifier == NULL || + relative_directory == NULL || g_path_is_absolute(relative_directory) || + strstr(relative_directory, "..") != NULL) + { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT, + "Les paramètres de reclassement sont invalides."); + return FALSE; + } + dao = evidence_dao_new(database, error); + if (dao == NULL) goto cleanup; + record = evidence_dao_find_by_identifier(dao, evidence_identifier, error); + if (record == NULL) goto cleanup; + old_path = g_build_filename(investigation_root, + evidence_record_get_relative_path(record), NULL); + new_relative_path = g_build_filename(relative_directory, + evidence_record_get_internal_name(record), NULL); + new_path = g_build_filename(investigation_root, new_relative_path, NULL); + new_parent = g_path_get_dirname(new_path); + if (!file_hash_compute_sha256(old_path, NULL, &sha256, &size_bytes, error) || + g_strcmp0(sha256, evidence_record_get_sha256(record)) != 0 || + size_bytes != evidence_record_get_size_bytes(record)) + { + evidence_reclassification_set_error(error, + "L'intégrité de la preuve source n'est pas conforme."); + goto cleanup; + } + g_clear_pointer(&sha256, g_free); + if (g_mkdir_with_parents(new_parent, 0700) != 0) + { + g_set_error(error, G_IO_ERROR, g_io_error_from_errno(errno), + "Impossible de préparer le dossier de destination : %s", + g_strerror(errno)); + goto cleanup; + } + if (!database_transaction_begin(database)) + { + evidence_reclassification_set_error(error, + "Impossible de démarrer la transaction de reclassement."); + goto cleanup; + } + transaction_started = TRUE; + if (g_strcmp0(old_path, new_path) != 0) + { + if (g_file_test(new_path, G_FILE_TEST_EXISTS) || + g_rename(old_path, new_path) != 0) + { + g_set_error(error, G_IO_ERROR, g_io_error_from_errno(errno), + "Impossible de déplacer la preuve : %s", g_strerror(errno)); + goto cleanup; + } + moved = TRUE; + } + now = g_date_time_new_now_utc(); + updated_at = now != NULL + ? g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ") : NULL; + if (!evidence_dao_update_metadata(dao, evidence_identifier, + type_identifier, new_relative_path, source, description, + updated_at, error) || + !file_hash_compute_sha256(new_path, NULL, &sha256, &size_bytes, error) || + g_strcmp0(sha256, evidence_record_get_sha256(record)) != 0 || + size_bytes != evidence_record_get_size_bytes(record)) + { + evidence_reclassification_set_error(error, + "L'intégrité après reclassement n'est pas conforme."); + goto cleanup; + } + if (!database_transaction_commit(database)) + { + evidence_reclassification_set_error(error, + "Impossible de valider le reclassement."); + goto cleanup; + } + transaction_started = FALSE; success = TRUE; +cleanup: + if (!success && transaction_started) database_transaction_rollback(database); + if (!success && moved && g_rename(new_path, old_path) != 0) + g_warning("Impossible de restaurer la preuve '%s'.", old_path); + g_clear_pointer(&now, g_date_time_unref); g_free(updated_at); g_free(sha256); + g_free(new_parent); g_free(new_path); g_free(new_relative_path); g_free(old_path); + evidence_record_free(record); evidence_dao_free(dao); + return success; +} diff --git a/src/dao/evidence_dao.c b/src/dao/evidence_dao.c index a813313..d6abd44 100644 --- a/src/dao/evidence_dao.c +++ b/src/dao/evidence_dao.c @@ -156,6 +156,11 @@ static const char *const evidence_dao_update_integrity_status_sql = "SET integrity_status = ? " "WHERE id = ?;"; +/** @brief Requête de mise à jour des métadonnées éditables. */ +static const char *const evidence_dao_update_metadata_sql = + "UPDATE preuves SET type_id=(SELECT id FROM types_preuve WHERE code=?)," + "relative_path=?,source=?,description=?,updated_at=? WHERE id=?;"; + /** * @brief Enregistre une erreur littérale. */ @@ -1344,6 +1349,49 @@ cleanup: return success; } +gboolean evidence_dao_update_metadata( + EvidenceDao *evidence_dao, const char *identifier, + const char *type_identifier, const char *relative_path, + const char *source, const char *description, const char *updated_at, + GError **error +) +{ + DatabaseStatement *statement = NULL; + gboolean success = FALSE; + g_return_val_if_fail(error == NULL || *error == NULL, FALSE); + if (evidence_dao == NULL || evidence_dao->database == NULL || + identifier == NULL || !g_uuid_string_is_valid(identifier) || + type_identifier == NULL || type_identifier[0] == '\0' || + relative_path == NULL || relative_path[0] == '\0' || + g_path_is_absolute(relative_path) || updated_at == NULL || + strlen(updated_at) != 20U) + { + evidence_dao_set_error_literal(error, + EVIDENCE_DAO_ERROR_INVALID_ARGUMENT, + "Les métadonnées de reclassement sont invalides."); + return FALSE; + } + statement = database_statement_prepare( + evidence_dao->database, evidence_dao_update_metadata_sql); + if (statement == NULL) goto cleanup; + success = database_statement_bind_text(statement, 1, type_identifier) && + database_statement_bind_text(statement, 2, relative_path) && + (source != NULL ? database_statement_bind_text(statement, 3, source) + : database_statement_bind_null(statement, 3)) && + (description != NULL + ? database_statement_bind_text(statement, 4, description) + : database_statement_bind_null(statement, 4)) && + database_statement_bind_text(statement, 5, updated_at) && + database_statement_bind_text(statement, 6, identifier) && + database_statement_step(statement) == DATABASE_STATEMENT_STEP_DONE; +cleanup: + if (!success) evidence_dao_set_database_error(evidence_dao, error, + EVIDENCE_DAO_ERROR_EXECUTE, + "Impossible de mettre à jour les métadonnées de la preuve"); + database_statement_finalize(statement); + return success; +} + gboolean evidence_dao_count( EvidenceDao *evidence_dao, guint64 *out_count, diff --git a/src/views/evidence_metadata_dialog.c b/src/views/evidence_metadata_dialog.c new file mode 100644 index 0000000..4106ffb --- /dev/null +++ b/src/views/evidence_metadata_dialog.c @@ -0,0 +1,212 @@ +/****************************************************************************** + * @file evidence_metadata_dialog.c + * @brief Dialogue de modification des métadonnées éditables d'une preuve. + ******************************************************************************/ + +#include "views/evidence_metadata_dialog.h" + +#include "models/evidence_type.h" + +struct EvidenceMetadataDialogResult +{ + char *type_identifier; + char *source; + char *description; +}; + +typedef struct +{ + GtkWindow *window; + GtkDropDown *type_dropdown; + GtkEditable *source_entry; + GtkTextBuffer *description_buffer; + GtkStringList *type_labels; + GPtrArray *type_identifiers; + EvidenceMetadataDialogCallback callback; + gpointer user_data; + gboolean completed; +} EvidenceMetadataDialogContext; + +/** @brief Copie un texte facultatif et transforme une chaîne vide en NULL. */ +static char *evidence_metadata_dialog_copy_optional(const char *text) +{ + char *copy = text != NULL ? g_strdup(text) : NULL; + if (copy != NULL) g_strstrip(copy); + if (copy != NULL && copy[0] == '\0') g_clear_pointer(©, g_free); + return copy; +} + +/** @brief Libère le contexte détenu par la fenêtre. */ +static void evidence_metadata_dialog_context_free(gpointer data) +{ + EvidenceMetadataDialogContext *context = data; + if (context == NULL) return; + g_clear_pointer(&context->type_identifiers, g_ptr_array_unref); + g_clear_object(&context->type_labels); + g_free(context); +} + +/** @brief Détruit la fenêtre hors de la pile du signal GTK courant. */ +static gboolean evidence_metadata_dialog_destroy_idle(gpointer data) +{ + GtkWindow *window = GTK_WINDOW(data); + gtk_window_destroy(window); + g_object_unref(window); + return G_SOURCE_REMOVE; +} + +/** @brief Termine le dialogue et transmet éventuellement un résultat. */ +static void evidence_metadata_dialog_complete( + EvidenceMetadataDialogContext *context, + EvidenceMetadataDialogResult *result +) +{ + if (context == NULL || context->completed) return; + context->completed = TRUE; + if (context->callback != NULL) context->callback(result, context->user_data); + else evidence_metadata_dialog_result_free(result); + gtk_widget_set_visible(GTK_WIDGET(context->window), FALSE); + g_idle_add( + evidence_metadata_dialog_destroy_idle, + g_object_ref(context->window) + ); +} + +/** @brief Annule la modification. */ +static void evidence_metadata_dialog_on_cancel( + GtkButton *button, gpointer user_data +) +{ + (void) button; + evidence_metadata_dialog_complete(user_data, NULL); +} + +/** @brief Traite la fermeture native comme une annulation explicite. */ +static gboolean evidence_metadata_dialog_on_close_request( + GtkWindow *window, gpointer user_data +) +{ + (void) window; + evidence_metadata_dialog_complete(user_data, NULL); + return TRUE; +} + +/** @brief Construit et transmet les valeurs modifiées. */ +static void evidence_metadata_dialog_on_save( + GtkButton *button, gpointer user_data +) +{ + EvidenceMetadataDialogContext *context = user_data; + EvidenceMetadataDialogResult *result = NULL; + GtkTextIter start; GtkTextIter end; char *description = NULL; + guint selected = 0U; + (void) button; + if (context == NULL) return; + selected = gtk_drop_down_get_selected(context->type_dropdown); + if (selected >= context->type_identifiers->len) return; + gtk_text_buffer_get_bounds(context->description_buffer, &start, &end); + description = gtk_text_buffer_get_text( + context->description_buffer, &start, &end, FALSE); + result = g_new0(EvidenceMetadataDialogResult, 1); + result->type_identifier = g_strdup( + g_ptr_array_index(context->type_identifiers, selected)); + result->source = evidence_metadata_dialog_copy_optional( + gtk_editable_get_text(context->source_entry)); + result->description = evidence_metadata_dialog_copy_optional(description); + g_free(description); + evidence_metadata_dialog_complete(context, result); +} + +gboolean evidence_metadata_dialog_present( + GtkWindow *parent, const EvidenceRecord *record, + const GPtrArray *evidence_types, EvidenceMetadataDialogCallback callback, + gpointer user_data +) +{ + EvidenceMetadataDialogContext *context = NULL; + GtkStringList *labels = NULL; GtkWidget *main_box = NULL; + GtkWidget *description_view = NULL; GtkWidget *description_scroll = NULL; + GtkWidget *buttons = NULL; GtkWidget *cancel = NULL; GtkWidget *save = NULL; + guint selected = 0U; + if (record == NULL || evidence_types == NULL || evidence_types->len == 0U || + callback == NULL) return FALSE; + context = g_new0(EvidenceMetadataDialogContext, 1); + context->window = GTK_WINDOW(gtk_window_new()); + context->callback = callback; context->user_data = user_data; + context->type_identifiers = g_ptr_array_new_with_free_func(g_free); + labels = gtk_string_list_new(NULL); + context->type_labels = labels; + for (guint index = 0U; index < evidence_types->len; index++) + { + const EvidenceType *type = g_ptr_array_index((GPtrArray *) evidence_types, index); + gtk_string_list_append(labels, evidence_type_get_label(type)); + g_ptr_array_add(context->type_identifiers, + g_strdup(evidence_type_get_code(type))); + if (g_strcmp0(evidence_type_get_code(type), + evidence_record_get_type_identifier(record)) == 0) selected = index; + } + gtk_window_set_title(context->window, "Modifier les métadonnées"); + gtk_window_set_default_size(context->window, 560, 420); + gtk_window_set_modal(context->window, TRUE); + gtk_window_set_destroy_with_parent(context->window, TRUE); + if (parent != NULL) gtk_window_set_transient_for(context->window, parent); + g_object_set_data_full(G_OBJECT(context->window), "evidence-metadata-context", + context, evidence_metadata_dialog_context_free); + main_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); + gtk_widget_set_margin_start(main_box, 18); gtk_widget_set_margin_end(main_box, 18); + gtk_widget_set_margin_top(main_box, 18); gtk_widget_set_margin_bottom(main_box, 18); + gtk_box_append(GTK_BOX(main_box), gtk_label_new( + "Le fichier, son UUID, sa taille, sa date d'import et son SHA-256 resteront inchangés.")); + context->type_dropdown = GTK_DROP_DOWN(gtk_drop_down_new( + G_LIST_MODEL(labels), NULL)); + gtk_drop_down_set_selected(context->type_dropdown, selected); + gtk_box_append(GTK_BOX(main_box), gtk_label_new("Type de preuve")); + gtk_box_append(GTK_BOX(main_box), GTK_WIDGET(context->type_dropdown)); + context->source_entry = GTK_EDITABLE(gtk_entry_new()); + gtk_editable_set_text(context->source_entry, + evidence_record_get_source(record) != NULL + ? evidence_record_get_source(record) : ""); + gtk_box_append(GTK_BOX(main_box), gtk_label_new("Source")); + gtk_box_append(GTK_BOX(main_box), GTK_WIDGET(context->source_entry)); + description_view = gtk_text_view_new(); + context->description_buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(description_view)); + gtk_text_buffer_set_text(context->description_buffer, + evidence_record_get_description(record) != NULL + ? evidence_record_get_description(record) : "", -1); + gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(description_view), GTK_WRAP_WORD_CHAR); + description_scroll = gtk_scrolled_window_new(); + gtk_widget_set_vexpand(description_scroll, TRUE); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(description_scroll), description_view); + gtk_box_append(GTK_BOX(main_box), gtk_label_new("Description")); + gtk_box_append(GTK_BOX(main_box), description_scroll); + buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_set_halign(buttons, GTK_ALIGN_END); + cancel = gtk_button_new_with_label("Annuler"); + save = gtk_button_new_with_label("Enregistrer"); + gtk_widget_add_css_class(save, "suggested-action"); + g_signal_connect(cancel, "clicked", G_CALLBACK(evidence_metadata_dialog_on_cancel), context); + g_signal_connect(save, "clicked", G_CALLBACK(evidence_metadata_dialog_on_save), context); + g_signal_connect(context->window, "close-request", + G_CALLBACK(evidence_metadata_dialog_on_close_request), context); + gtk_box_append(GTK_BOX(buttons), cancel); gtk_box_append(GTK_BOX(buttons), save); + gtk_box_append(GTK_BOX(main_box), buttons); + gtk_window_set_child(context->window, main_box); + gtk_window_present(context->window); + return TRUE; +} + +void evidence_metadata_dialog_result_free(EvidenceMetadataDialogResult *result) +{ + if (result == NULL) return; + g_free(result->type_identifier); g_free(result->source); + g_free(result->description); g_free(result); +} +const char *evidence_metadata_dialog_result_get_type_identifier( + const EvidenceMetadataDialogResult *result) +{ return result != NULL ? result->type_identifier : NULL; } +const char *evidence_metadata_dialog_result_get_source( + const EvidenceMetadataDialogResult *result) +{ return result != NULL ? result->source : NULL; } +const char *evidence_metadata_dialog_result_get_description( + const EvidenceMetadataDialogResult *result) +{ return result != NULL ? result->description : NULL; } diff --git a/src/views/main_window.c b/src/views/main_window.c index ab01c13..9fbd5ab 100644 --- a/src/views/main_window.c +++ b/src/views/main_window.c @@ -24,7 +24,7 @@ /** * @brief Position initiale de la séparation horizontale. */ -#define MAIN_WINDOW_SIDEBAR_POSITION 320 +#define MAIN_WINDOW_SIDEBAR_POSITION 640 /** * @brief Position initiale de la séparation verticale. @@ -99,6 +99,9 @@ struct MainWindow gpointer verify_evidence_user_data; + MainWindowEditEvidenceCallback edit_evidence_callback; + gpointer edit_evidence_user_data; + MainWindowGraphNodeMovedCallback graph_node_moved_callback; @@ -156,6 +159,17 @@ static void main_window_on_osint_action_requested( ); } +/** @brief Relaie la modification d'une preuve vers l'application. */ +static void main_window_on_edit_evidence_requested( + const char *evidence_identifier, gpointer user_data +) +{ + MainWindow *main_window = user_data; + if (main_window == NULL || main_window->edit_evidence_callback == NULL) return; + main_window->edit_evidence_callback( + evidence_identifier, main_window->edit_evidence_user_data); +} + /** * @brief Ouvre dans le workspace l'entité choisie dans la sidebar. */ @@ -782,6 +796,12 @@ MainWindow *main_window_new( main_window ); + workspace_set_edit_evidence_callback( + main_window->workspace, + main_window_on_edit_evidence_requested, + main_window + ); + workspace_widget = workspace_get_widget( main_window->workspace ); @@ -1297,6 +1317,16 @@ void main_window_set_osint_action_callback( main_window->osint_action_user_data = user_data; } +void main_window_set_edit_evidence_callback( + MainWindow *main_window, MainWindowEditEvidenceCallback callback, + gpointer user_data +) +{ + if (main_window == NULL) return; + main_window->edit_evidence_callback = callback; + main_window->edit_evidence_user_data = user_data; +} + void main_window_set_tree_selection_callback( MainWindow *main_window, InvestigationTreeViewSelectionCallback callback, diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index a7a96d8..a57859d 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -85,6 +85,7 @@ struct Workspace GtkWidget *evidence_identifier_label; GtkWidget *evidence_sha256_label; GtkWidget *verify_evidence_button; + GtkWidget *edit_evidence_button; char *selected_evidence_identifier; @@ -94,6 +95,9 @@ struct Workspace gpointer verify_evidence_user_data; + WorkspaceEditEvidenceCallback edit_evidence_callback; + gpointer edit_evidence_user_data; + WorkspaceGraphNodeMovedCallback graph_node_moved_callback; @@ -834,6 +838,20 @@ static void workspace_on_verify_evidence_clicked( ); } +/** @brief Transmet la demande de modification de la preuve affichée. */ +static void workspace_on_edit_evidence_clicked( + GtkButton *button, gpointer user_data +) +{ + Workspace *workspace = user_data; + (void) button; + if (workspace == NULL || workspace->edit_evidence_callback == NULL || + workspace->selected_evidence_identifier == NULL) return; + workspace->edit_evidence_callback( + workspace->selected_evidence_identifier, + workspace->edit_evidence_user_data); +} + Workspace *workspace_new(void) { GtkWidget *evidence_content = NULL; @@ -1153,6 +1171,16 @@ Workspace *workspace_new(void) workspace->verify_evidence_button ); + workspace->edit_evidence_button = gtk_button_new_with_label( + "Modifier les métadonnées"); + gtk_widget_set_halign(workspace->edit_evidence_button, GTK_ALIGN_START); + gtk_widget_set_sensitive(workspace->edit_evidence_button, FALSE); + gtk_widget_set_tooltip_text(workspace->edit_evidence_button, + "Corriger le classement sans modifier le fichier original"); + g_signal_connect(workspace->edit_evidence_button, "clicked", + G_CALLBACK(workspace_on_edit_evidence_clicked), workspace); + gtk_box_append(GTK_BOX(evidence_content), workspace->edit_evidence_button); + evidence_separator = gtk_separator_new( GTK_ORIENTATION_HORIZONTAL @@ -2079,6 +2107,8 @@ void workspace_set_selected_node( FALSE ); } + if (workspace->edit_evidence_button != NULL) + gtk_widget_set_sensitive(workspace->edit_evidence_button, FALSE); if (node == NULL) { @@ -2233,6 +2263,7 @@ void workspace_set_selected_evidence( workspace->verify_evidence_button, TRUE ); + gtk_widget_set_sensitive(workspace->edit_evidence_button, TRUE); size_bytes = evidence_record_get_size_bytes( @@ -2774,6 +2805,16 @@ void workspace_set_verify_evidence_callback( user_data; } +void workspace_set_edit_evidence_callback( + Workspace *workspace, WorkspaceEditEvidenceCallback callback, + gpointer user_data +) +{ + if (workspace == NULL) return; + workspace->edit_evidence_callback = callback; + workspace->edit_evidence_user_data = user_data; +} + void workspace_set_graph_node_moved_callback( Workspace *workspace, WorkspaceGraphNodeMovedCallback callback, diff --git a/tests/test_evidence_reclassification.c b/tests/test_evidence_reclassification.c new file mode 100644 index 0000000..783706e --- /dev/null +++ b/tests/test_evidence_reclassification.c @@ -0,0 +1,71 @@ +/****************************************************************************** + * @file test_evidence_reclassification.c + * @brief Tests du reclassement cohérent des preuves. + ******************************************************************************/ + +#include "core/evidence_reclassification.h" +#include "core/file_hash.h" +#include "dao/evidence_dao.h" +#include "database/database.h" +#include "models/evidence_record.h" + +#include +#include + +static void test_reclassification_preserves_identity_and_hash(void) +{ + GError *error = NULL; guint64 size = 0U; char *sha256 = NULL; + char *root = g_dir_make_tmp("labfy-reclassify-XXXXXX", &error); + char *database_dir = g_build_filename(root, "00_BaseDeDonnees", NULL); + char *database_path = g_build_filename(database_dir, "Enquete.sqlite", NULL); + char *old_dir = g_build_filename(root, "01_Preuves_Originales", "Documents", NULL); + char *old_path = g_build_filename(old_dir, "internal.bin", NULL); + char *new_path = g_build_filename(root, "01_Preuves_Originales", "Photos", + "internal.bin", NULL); + Database *database = NULL; EvidenceDao *dao = NULL; + EvidenceRecord *record = NULL; EvidenceRecord *loaded = NULL; + g_assert_no_error(error); + g_assert_cmpint(g_mkdir_with_parents(database_dir, 0700), ==, 0); + g_assert_cmpint(g_mkdir_with_parents(old_dir, 0700), ==, 0); + g_assert_true(g_file_set_contents(old_path, "binary\0content", 14, &error)); + g_assert_true(file_hash_compute_sha256(old_path, NULL, &sha256, &size, &error)); + g_assert_true(database_initialize(database_path, "Test", root)); + database = database_open(database_path); dao = evidence_dao_new(database, &error); + record = evidence_record_new( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "original.bin", "internal.bin", + "01_Preuves_Originales/Documents/internal.bin", "document", size, sha256, + "2026-07-22T10:00:00Z", "2026-07-18T10:00:00Z", "Victime", "Avant", + EVIDENCE_INTEGRITY_STATUS_UNKNOWN, &error); + g_assert_nonnull(record); g_assert_true(evidence_dao_insert(dao, record, &error)); + g_assert_true(evidence_reclassification_apply( + database, root, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "photo", + "01_Preuves_Originales/Photos", "Victime", "Après", &error)); + g_assert_false(g_file_test(old_path, G_FILE_TEST_EXISTS)); + g_assert_true(g_file_test(new_path, G_FILE_TEST_IS_REGULAR)); + loaded = evidence_dao_find_by_identifier( + dao, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &error); + g_assert_cmpstr(evidence_record_get_type_identifier(loaded), ==, "photo"); + g_assert_cmpstr(evidence_record_get_sha256(loaded), ==, sha256); + g_assert_cmpstr(evidence_record_get_imported_at(loaded), ==, + "2026-07-22T10:00:00Z"); + g_assert_cmpstr(evidence_record_get_description(loaded), ==, "Après"); + evidence_record_free(loaded); evidence_record_free(record); + evidence_dao_free(dao); database_close(database); + g_assert_cmpint(g_remove(new_path), ==, 0); + g_assert_cmpint(g_remove(database_path), ==, 0); + char *photos = g_path_get_dirname(new_path); + char *originals = g_path_get_dirname(photos); + g_assert_cmpint(g_rmdir(old_dir), ==, 0); g_assert_cmpint(g_rmdir(photos), ==, 0); + g_assert_cmpint(g_rmdir(originals), ==, 0); g_assert_cmpint(g_rmdir(database_dir), ==, 0); + g_assert_cmpint(g_rmdir(root), ==, 0); + g_free(photos); g_free(originals); g_free(sha256); g_free(new_path); + g_free(old_path); g_free(old_dir); g_free(database_path); g_free(database_dir); g_free(root); +} + +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/evidence-reclassification/preserve", + test_reclassification_preserves_identity_and_hash); + return g_test_run(); +}