From 613d2096bc5eeb2c1c4f60ae701292e19a2abe66 Mon Sep 17 00:00:00 2001 From: grayTerminal-sh Date: Fri, 24 Jul 2026 12:53:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(relations):=20centraliser=20les=20types=20?= =?UTF-8?q?et=20am=C3=A9liorer=20les=20aper=C3=A7us?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + CHANGELOG.md | 3 + Makefile | 42 ++- README.md | 4 + database/schema_current.sql | 39 ++ database/schema_v10.sql | 45 +++ docs/database/DATABASE_ARCHITECTURE.md | 13 + include/core/bank_proposal.h | 73 ++++ include/core/controlled_vocab.h | 67 ++++ include/core/eml_integration.h | 10 +- include/core/eml_mime_extractor.h | 68 ++++ include/core/eml_pipeline_task.h | 40 ++ include/database/schema.h | 1 + include/widgets/controlled_vocab_dropdown.h | 40 ++ src/core/bank_proposal.c | 202 ++++++++++ src/core/controlled_vocab.c | 182 +++++++++ src/core/eml_integration.c | 43 ++- src/core/eml_mime_extractor.c | 388 ++++++++++++++++++++ src/core/eml_pipeline_task.c | 177 +++++++++ src/database/database.c | 26 +- src/database/schema.c | 6 + src/views/eml_analysis_dialog.c | 38 +- src/widgets/controlled_vocab_dropdown.c | 83 +++++ tests/test_bank_proposal.c | 58 +++ tests/test_controlled_vocab.c | 91 +++++ tests/test_database.c | 5 +- tests/test_eml_pipeline_task.c | 94 +++++ watch_20260723-211101 | 5 + 28 files changed, 1826 insertions(+), 18 deletions(-) create mode 100644 database/schema_v10.sql create mode 100644 include/core/bank_proposal.h create mode 100644 include/core/controlled_vocab.h create mode 100644 include/core/eml_mime_extractor.h create mode 100644 include/core/eml_pipeline_task.h create mode 100644 include/widgets/controlled_vocab_dropdown.h create mode 100644 src/core/bank_proposal.c create mode 100644 src/core/controlled_vocab.c create mode 100644 src/core/eml_mime_extractor.c create mode 100644 src/core/eml_pipeline_task.c create mode 100644 src/widgets/controlled_vocab_dropdown.c create mode 100644 tests/test_bank_proposal.c create mode 100644 tests/test_controlled_vocab.c create mode 100644 tests/test_eml_pipeline_task.c create mode 100644 watch_20260723-211101 diff --git a/.gitignore b/.gitignore index 3bb9baa..5b7abdb 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ labfy-investigation # Neovim / LSP .cache/ compile_commands.json +/AGENTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6547d8b..52d15d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ### Added +- Préparation du pivot e-mail : pipeline EML asynchrone, extraction MIME + sécurisée, propositions bancaires IBAN/BIC et vocabulaire contrôlé. + - Référentiel persistant et normalisé des types de relations, avec codes système stables, types personnalisés, renommage et fusion transactionnelle. - Sélecteur canonique dans les formulaires de création et de modification des diff --git a/Makefile b/Makefile index 21114b8..babe0c5 100644 --- a/Makefile +++ b/Makefile @@ -132,9 +132,36 @@ TEST_PDF_PASSWORD_RECOVERY := tests/test_pdf_password_recovery TEST_EXTRACTION_DROP_SERVICE := tests/test_extraction_drop_service TEST_RELATION_TYPE_NORMALIZER := tests/test_relation_type_normalizer TEST_RELATION_TYPE_SERVICE := tests/test_relation_type_service +TEST_CONTROLLED_VOCAB := tests/test_controlled_vocab +TEST_BANK_PROPOSAL := tests/test_bank_proposal +TEST_EML_PIPELINE_TASK := tests/test_eml_pipeline_task all: $(TARGET) +$(TEST_BANK_PROPOSAL): \ + tests/test_bank_proposal.c \ + src/core/bank_proposal.c \ + src/core/controlled_vocab.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ \ + $(shell $(PKG_CONFIG) --libs glib-2.0) + +$(TEST_CONTROLLED_VOCAB): \ + tests/test_controlled_vocab.c \ + src/core/controlled_vocab.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ \ + $(shell $(PKG_CONFIG) --libs glib-2.0) + +$(TEST_EML_PIPELINE_TASK): \ + tests/test_eml_pipeline_task.c \ + src/core/eml_pipeline_task.c src/core/eml_mime_extractor.c \ + src/core/eml_analyzer.c src/core/bank_proposal.c \ + src/core/controlled_vocab.c src/core/iban_analyzer.c \ + src/core/rib_ocr.c src/core/file_hash.c src/core/background_task.c + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ \ + $(TEST_LDFLAGS) -lsqlite3 + + + $(TEST_RELATION_TYPE_NORMALIZER): \ tests/test_relation_type_normalizer.c \ src/core/relation_type_normalizer.c @@ -799,7 +826,10 @@ test: \ $(TEST_PDF_PASSWORD_RECOVERY) \ $(TEST_EXTRACTION_DROP_SERVICE) \ $(TEST_RELATION_TYPE_NORMALIZER) \ - $(TEST_RELATION_TYPE_SERVICE) + $(TEST_RELATION_TYPE_SERVICE) \ + $(TEST_CONTROLLED_VOCAB) \ + $(TEST_BANK_PROPOSAL) \ + $(TEST_EML_PIPELINE_TASK) @echo "Exécution des tests..." @./$(TEST_NODE) @./$(TEST_TREE_MODEL) @@ -865,6 +895,9 @@ test: \ @$(TEST_EXTRACTION_DROP_SERVICE) @$(TEST_RELATION_TYPE_NORMALIZER) @$(TEST_RELATION_TYPE_SERVICE) + @$(TEST_CONTROLLED_VOCAB) + @$(TEST_BANK_PROPOSAL) + @$(TEST_EML_PIPELINE_TASK) @echo "Tous les tests sont valides." %.o: %.c @@ -933,7 +966,12 @@ clean: $(TEST_EML_ANALYZER) \ $(TEST_EXTRACTION_DROP_SERVICE) \ $(TEST_RELATION_TYPE_NORMALIZER) \ - $(TEST_RELATION_TYPE_SERVICE) + $(TEST_RELATION_TYPE_SERVICE) \ + $(TEST_CONTROLLED_VOCAB) \ + $(TEST_BANK_PROPOSAL) \ + $(TEST_EML_PIPELINE_TASK) + + -include $(DEP) diff --git a/README.md b/README.md index 9fdfb74..5a16e3e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ Labfy Investigation doit permettre de : - exécuter des traitements longs en arrière-plan ; - intégrer progressivement des outils OSINT externes ; - conserver les sorties brutes, les versions et les paramètres d’exécution ; +- analyser localement des messages EML et inventorier leurs pièces jointes + sans modifier la preuve originale ; +- conserver les propositions IBAN/BIC avec leur provenance et leur statut de + vérification ; - distinguer les faits observés, les résultats d’outils, les corrélations et les hypothèses ; - produire des rapports compréhensibles et traçables. diff --git a/database/schema_current.sql b/database/schema_current.sql index dd936fe..d009422 100644 --- a/database/schema_current.sql +++ b/database/schema_current.sql @@ -138,3 +138,42 @@ FOR EACH ROW BEGIN DELETE FROM graph_layout_positions WHERE node_id = OLD.id; END; + +CREATE TABLE IF NOT EXISTS bank_account_entities +( + id TEXT PRIMARY KEY, + iban TEXT NOT NULL, + bic TEXT, + holder_name TEXT, + bank_name TEXT, + bank_address TEXT, + country_code TEXT, + bank_code TEXT, + branch_code TEXT, + account_number TEXT, + rib_key TEXT, + verification_status TEXT NOT NULL DEFAULT 'proposed' CHECK (verification_status IN ('proposed', 'confirmed', 'rejected', 'conflicted', 'invalid')), + provenance_kind TEXT NOT NULL DEFAULT 'ocr' CHECK (provenance_kind IN ('observed', 'ocr', 'header', 'metadata', 'derived', 'manual')), + evidence_id TEXT, + extraction_id TEXT, + created_at TEXT NOT NULL CHECK (length(created_at) = 20), + updated_at TEXT NOT NULL CHECK (length(updated_at) = 20), + FOREIGN KEY (evidence_id) REFERENCES preuves(id) ON DELETE SET NULL, + FOREIGN KEY (extraction_id) REFERENCES extractions(id) ON DELETE SET NULL, + CHECK (length(trim(id)) > 0), + CHECK (length(trim(iban)) > 0) +); + +CREATE INDEX IF NOT EXISTS idx_bank_account_entities_iban ON bank_account_entities(iban); +CREATE INDEX IF NOT EXISTS idx_bank_account_entities_evidence ON bank_account_entities(evidence_id); + +INSERT OR IGNORE INTO relation_types(code, label, normalized_key, description, is_system) VALUES +('sent_from', 'Envoyé depuis', 'envoyé depuis', 'Message e-mail envoyé depuis une adresse ou serveur.', 1), +('sent_to', 'Envoyé à', 'envoyé à', 'Message e-mail envoyé à une adresse.', 1), +('reply_to', 'Répondre à', 'répondre à', 'Adresse de réponse configurée.', 1), +('has_attachment', 'Possède la pièce jointe', 'possède la pièce jointe', 'Preuve ou fichier joint à un e-mail.', 1), +('relayed_by', 'Relayé par', 'relayé par', 'Relais SMTP ayant acheminé le message.', 1), +('uses_domain', 'Utilise le domaine', 'utilise le domaine', 'Adresse e-mail rattachée à un domaine.', 1), +('held_at', 'Tenu auprès de', 'tenu auprès de', 'Compte bancaire ouvert dans une banque.', 1), +('named_as_holder_of', 'Nommé titulaire de', 'nommé titulaire de', 'Personne ou entité observée comme titulaire du RIB.', 1), +('supports', 'Soutient', 'soutient', 'Preuve soutenant une entité ou relation.', 1); diff --git a/database/schema_v10.sql b/database/schema_v10.sql new file mode 100644 index 0000000..5789fd7 --- /dev/null +++ b/database/schema_v10.sql @@ -0,0 +1,45 @@ +/****************************************************************************** + * Labfy Investigation + * + * Schéma SQLite officiel V10 + * Extension pour le pivot e-mail, les entités bancaires et la traçabilité. + ******************************************************************************/ + +CREATE TABLE IF NOT EXISTS bank_account_entities +( + id TEXT PRIMARY KEY, + iban TEXT NOT NULL, + bic TEXT, + holder_name TEXT, + bank_name TEXT, + bank_address TEXT, + country_code TEXT, + bank_code TEXT, + branch_code TEXT, + account_number TEXT, + rib_key TEXT, + verification_status TEXT NOT NULL DEFAULT 'proposed' CHECK (verification_status IN ('proposed', 'confirmed', 'rejected', 'conflicted', 'invalid')), + provenance_kind TEXT NOT NULL DEFAULT 'ocr' CHECK (provenance_kind IN ('observed', 'ocr', 'header', 'metadata', 'derived', 'manual')), + evidence_id TEXT, + extraction_id TEXT, + created_at TEXT NOT NULL CHECK (length(created_at) = 20), + updated_at TEXT NOT NULL CHECK (length(updated_at) = 20), + FOREIGN KEY (evidence_id) REFERENCES preuves(id) ON DELETE SET NULL, + FOREIGN KEY (extraction_id) REFERENCES extractions(id) ON DELETE SET NULL, + CHECK (length(trim(id)) > 0), + CHECK (length(trim(iban)) > 0) +); + +CREATE INDEX IF NOT EXISTS idx_bank_account_entities_iban ON bank_account_entities(iban); +CREATE INDEX IF NOT EXISTS idx_bank_account_entities_evidence ON bank_account_entities(evidence_id); + +INSERT OR IGNORE INTO relation_types(code, label, normalized_key, description, is_system) VALUES +('sent_from', 'Envoyé depuis', 'envoyé depuis', 'Message e-mail envoyé depuis une adresse ou serveur.', 1), +('sent_to', 'Envoyé à', 'envoyé à', 'Message e-mail envoyé à une adresse.', 1), +('reply_to', 'Répondre à', 'répondre à', 'Adresse de réponse configurée.', 1), +('has_attachment', 'Possède la pièce jointe', 'possède la pièce jointe', 'Preuve ou fichier joint à un e-mail.', 1), +('relayed_by', 'Relayé par', 'relayé par', 'Relais SMTP ayant acheminé le message.', 1), +('uses_domain', 'Utilise le domaine', 'utilise le domaine', 'Adresse e-mail rattachée à un domaine.', 1), +('held_at', 'Tenu auprès de', 'tenu auprès de', 'Compte bancaire ouvert dans une banque.', 1), +('named_as_holder_of', 'Nommé titulaire de', 'nommé titulaire de', 'Personne ou entité observée comme titulaire du RIB.', 1), +('supports', 'Soutient', 'soutient', 'Preuve soutenant une entité ou relation.', 1); diff --git a/docs/database/DATABASE_ARCHITECTURE.md b/docs/database/DATABASE_ARCHITECTURE.md index 6666796..3d24dec 100644 --- a/docs/database/DATABASE_ARCHITECTURE.md +++ b/docs/database/DATABASE_ARCHITECTURE.md @@ -2392,6 +2392,19 @@ maintenable et pérenne. --- +## Pivot e-mail et comptes bancaires + +La version V10 ajoute `bank_account_entities`, qui conserve les composants +IBAN/RIB ainsi que leur statut de vérification et leur provenance. Une valeur +extraite par OCR reste une proposition (`proposed`) ; une dérivation locale +est marquée `derived` et ne devient jamais automatiquement une donnée +confirmée. Les fichiers EML dérivés restent reliés à leur preuve source par +les tables d’association existantes. + +Les codes de statuts, de provenance, de rôles d’adresse et de type de valeur +sont fournis par `controlled_vocab` : les codes techniques sont stables et +les libellés affichés sont séparés. + ## Références Documents associés : diff --git a/include/core/bank_proposal.h b/include/core/bank_proposal.h new file mode 100644 index 0000000..ee1965d --- /dev/null +++ b/include/core/bank_proposal.h @@ -0,0 +1,73 @@ +/****************************************************************************** + * @file bank_proposal.h + * @brief Détection, normalisation et modèle de proposition bancaire (IBAN, RIB, BIC). + ******************************************************************************/ +#ifndef LABFY_INVESTIGATION_BANK_PROPOSAL_H +#define LABFY_INVESTIGATION_BANK_PROPOSAL_H + +#include "core/controlled_vocab.h" +#include + +G_BEGIN_DECLS + +/** @brief Objet métier représentant une proposition bancaire complète. */ +typedef struct BankProposal +{ + char *id; /**< UUID de la proposition */ + char *raw_iban; /**< Graphie IBAN brute lue/OCR */ + char *normalized_iban; /**< IBAN nettoyé et majuscule */ + char *bic; /**< BIC / SWIFT (8 ou 11 car) */ + char *holder_name; /**< Titulaire du compte */ + char *bank_name; /**< Nom de la banque */ + char *bank_address; /**< Adresse de la banque */ + char *country_code; /**< Code pays (ex: "FR") */ + char *bank_code; /**< Code banque (5 ch pour RIB FR) */ + char *branch_code; /**< Code guichet (5 ch pour RIB FR) */ + char *account_number; /**< Numéro de compte (11 car) */ + char *rib_key; /**< Clé RIB (2 ch) */ + gboolean is_iban_valid; /**< VRAI si MOD-97 et format valides */ + gboolean is_derived_bban; /**< VRAI si composants dérivés de l'IBAN */ + char *suggested_ocr_fix; /**< Proposition de correction OCR (ex: "O->0") */ + char *verification_status; /**< Code contrôlé: proposed, confirmed, rejected, etc. */ + char *provenance_kind; /**< Code contrôlé: ocr, observed, derived, etc. */ + char *evidence_id; /**< UUID de la preuve source */ + char *extraction_id; /**< UUID de l'extraction source */ + char *created_at; /**< Horodatage ISO 8601 UTC */ + char *updated_at; /**< Horodatage ISO 8601 UTC */ +} BankProposal; + +/** @brief Libère une structure BankProposal. */ +void bank_proposal_free(BankProposal *proposal); + +/** + * @brief Crée une proposition bancaire depuis une chaîne IBAN ou un texte OCR. + * @param raw_text Texte brut observé / OCR. + * @param evidence_id UUID de la preuve source (facultatif). + * @return Nouvelle BankProposal, ou NULL. + */ +BankProposal *bank_proposal_analyze_text(const char *raw_text, const char *evidence_id); + +/** + * @brief Valide la structure MOD-97 d'un IBAN. + * @param iban IBAN nettoyé (sans espaces). + * @return VRAI si valide, FALSE sinon. + */ +gboolean bank_proposal_validate_iban(const char *iban); + +/** + * @brief Tente de dériver les composants BBAN (RIB) pour un IBAN français valide. + * @param proposal Proposition bancaire à enrichir. + * @return VRAI si dérivation réussie, FALSE sinon. + */ +gboolean bank_proposal_derive_french_rib(BankProposal *proposal); + +/** + * @brief Valide la structure d'un code BIC/SWIFT (8 ou 11 caractères). + * @param bic Code BIC à vérifier. + * @return VRAI si valide, FALSE sinon. + */ +gboolean bank_proposal_validate_bic(const char *bic); + +G_END_DECLS + +#endif /* LABFY_INVESTIGATION_BANK_PROPOSAL_H */ diff --git a/include/core/controlled_vocab.h b/include/core/controlled_vocab.h new file mode 100644 index 0000000..bdf9168 --- /dev/null +++ b/include/core/controlled_vocab.h @@ -0,0 +1,67 @@ +/****************************************************************************** + * @file controlled_vocab.h + * @brief Gestion du vocabulaire contrôlé pour les domaines fermés. + ******************************************************************************/ +#ifndef LABFY_INVESTIGATION_CONTROLLED_VOCAB_H +#define LABFY_INVESTIGATION_CONTROLLED_VOCAB_H + +#include +#include + +G_BEGIN_DECLS + +/** @brief Élément d'un vocabulaire contrôlé. */ +typedef struct ControlledVocabItem +{ + const char *code; /**< Code technique stable en anglais */ + const char *label; /**< Libellé affichable en français */ +} ControlledVocabItem; + +/** @brief Catégories de vocabulaire contrôlé. */ +typedef enum ControlledVocabCategory +{ + CONTROLLED_VOCAB_VERIFICATION_STATUS = 0, + CONTROLLED_VOCAB_PROVENANCE_KIND, + CONTROLLED_VOCAB_EMAIL_ROLE, + CONTROLLED_VOCAB_SMTP_ROLE, + CONTROLLED_VOCAB_VALUE_TYPE, + CONTROLLED_VOCAB_COUNT +} ControlledVocabCategory; + +/** + * @brief Retourne la liste ordonnée d'un vocabulaire contrôlé. + * @param category Catégorie demandée. + * @param out_count Pointeur recevant le nombre d'éléments. + * @return Tableau d'éléments statiques, ou NULL si catégorie invalide. + */ +const ControlledVocabItem *controlled_vocab_get_items(ControlledVocabCategory category, + size_t *out_count); + +/** + * @brief Vérifie si un code appartient à une catégorie de vocabulaire. + */ +gboolean controlled_vocab_is_valid_code(ControlledVocabCategory category, + const char *code); + +/** + * @brief Obtient le libellé utilisateur (français) à partir d'un code technique. + */ +const char *controlled_vocab_get_label(ControlledVocabCategory category, + const char *code); + +/** + * @brief Obtient le code technique à partir d'un libellé utilisateur. + */ +const char *controlled_vocab_get_code_from_label(ControlledVocabCategory category, + const char *label); + +/* Helpers spécifiques pour les catégories principales */ +gboolean controlled_vocab_is_valid_verification_status(const char *code); +gboolean controlled_vocab_is_valid_provenance_kind(const char *code); +gboolean controlled_vocab_is_valid_email_role(const char *code); +gboolean controlled_vocab_is_valid_smtp_role(const char *code); +gboolean controlled_vocab_is_valid_value_type(const char *code); + +G_END_DECLS + +#endif /* LABFY_INVESTIGATION_CONTROLLED_VOCAB_H */ diff --git a/include/core/eml_integration.h b/include/core/eml_integration.h index 1a58b9d..389cd0b 100644 --- a/include/core/eml_integration.h +++ b/include/core/eml_integration.h @@ -8,10 +8,18 @@ #include G_BEGIN_DECLS /** @brief Proposition d'entité explicitement sélectionnable. */ -typedef struct { char *type_identifier; char *value; } EmlEntityProposal; +typedef struct { + char *type_identifier; + char *value; + char *verification_status; + char *provenance_kind; +} EmlEntityProposal; /** @brief Crée une proposition possédée. */ EmlEntityProposal *eml_entity_proposal_new(const char *type_identifier, const char *value); +EmlEntityProposal *eml_entity_proposal_new_with_metadata( + const char *type_identifier, const char *value, + const char *verification_status, const char *provenance_kind); /** @brief Libère une proposition. */ void eml_entity_proposal_free(EmlEntityProposal *proposal); /** diff --git a/include/core/eml_mime_extractor.h b/include/core/eml_mime_extractor.h new file mode 100644 index 0000000..bc653dc --- /dev/null +++ b/include/core/eml_mime_extractor.h @@ -0,0 +1,68 @@ +/****************************************************************************** + * @file eml_mime_extractor.h + * @brief Extraction MIME sécurisée et inventaire des pièces jointes d'un EML. + ******************************************************************************/ +#ifndef LABFY_INVESTIGATION_EML_MIME_EXTRACTOR_H +#define LABFY_INVESTIGATION_EML_MIME_EXTRACTOR_H + +#include + +G_BEGIN_DECLS + +/** @brief Représentation d'une pièce jointe extraite d'un message EML. */ +typedef struct EmlAttachment +{ + char *part_index; /**< Chemin/index MIME (ex: "1.2") */ + char *declared_filename; /**< Nom de fichier d'origine */ + char *sanitized_filename; /**< Nom assaini (anti path-traversal) */ + char *extracted_path; /**< Chemin absolu dans 02_Preuves_Traitees */ + char *relative_path; /**< Chemin relatif par rapport à la racine d'enquête */ + char *content_type; /**< Type MIME déclaré */ + char *detected_mime; /**< Type MIME détecté */ + char *content_id; /**< Content-ID pour les images/pièces inline */ + char *transfer_encoding; /**< Content-Transfer-Encoding */ + gboolean is_inline; /**< VRAI si disposition inline */ + gsize encoded_size; /**< Taille encodée */ + gsize decoded_size; /**< Taille décodée */ + char *sha256; /**< Empreinte SHA-256 du fichier extrait */ + gboolean has_inconsistency; /**< VRAI si incohérence extension/MIME */ +} EmlAttachment; + +/** @brief Résultat de l'extraction MIME d'un fichier EML. */ +typedef struct EmlMimeResult +{ + GPtrArray *attachments; /**< Tableau de EmlAttachment* */ + GPtrArray *warnings; /**< Avertissements d'extraction */ +} EmlMimeResult; + +/** + * @brief Libère une structure EmlAttachment. + */ +void eml_attachment_free(EmlAttachment *attachment); + +/** + * @brief Libère une structure EmlMimeResult. + */ +void eml_mime_result_free(EmlMimeResult *result); + +/** + * @brief Assainit un nom de fichier pour éviter les attaques par traversée de chemin. + * @param raw_filename Nom d'origine. + * @return Nom assaini libéré avec g_free. + */ +char *eml_mime_sanitize_filename(const char *raw_filename); + +/** + * @brief Extrait et enregistre de manière sécurisée les pièces jointes d'un fichier EML. + * @param eml_path Chemin du fichier EML source. + * @param target_dir Dossier de destination dans 02_Preuves_Traitees (ex: .../02_Preuves_Traitees/eml_attachments/). + * @param error Destination d'erreur facultative. + * @return Résultat MIME, ou NULL en cas d'erreur. + */ +EmlMimeResult *eml_mime_extract_attachments(const char *eml_path, + const char *target_dir, + GError **error); + +G_END_DECLS + +#endif /* LABFY_INVESTIGATION_EML_MIME_EXTRACTOR_H */ diff --git a/include/core/eml_pipeline_task.h b/include/core/eml_pipeline_task.h new file mode 100644 index 0000000..5bb4ae9 --- /dev/null +++ b/include/core/eml_pipeline_task.h @@ -0,0 +1,40 @@ +/****************************************************************************** + * @file eml_pipeline_task.h + * @brief Pipeline d'analyse asynchrone complète pour fichier EML (En-têtes, MIME, OCR, Banque). + ******************************************************************************/ +#ifndef LABFY_INVESTIGATION_EML_PIPELINE_TASK_H +#define LABFY_INVESTIGATION_EML_PIPELINE_TASK_H + +#include "core/background_task.h" +#include "core/bank_proposal.h" +#include "core/eml_analyzer.h" +#include "core/eml_mime_extractor.h" + +G_BEGIN_DECLS + +/** @brief Résultat global d'un pipeline d'analyse EML. */ +typedef struct EmlPipelineResult +{ + EmlAnalysis *analysis; /**< Analyse des en-têtes EML */ + EmlMimeResult *mime_result; /**< Pièces jointes extraites */ + GPtrArray *bank_proposals; /**< Tableau de BankProposal* */ + GPtrArray *warnings; /**< Avertissements globaux */ +} EmlPipelineResult; + +/** @brief Libère un résultat EmlPipelineResult. */ +void eml_pipeline_result_free(EmlPipelineResult *result); + +/** + * @brief Crée une nouvelle tâche asynchrone d'analyse EML complète. + * @param eml_path Chemin du fichier .eml source. + * @param processed_evidence_dir Dossier racine 02_Preuves_Traitees. + * @param evidence_id UUID de la preuve EML. + * @return Nouvelle BackgroundTask, ou NULL. + */ +BackgroundTask *eml_pipeline_task_new(const char *eml_path, + const char *processed_evidence_dir, + const char *evidence_id); + +G_END_DECLS + +#endif /* LABFY_INVESTIGATION_EML_PIPELINE_TASK_H */ diff --git a/include/database/schema.h b/include/database/schema.h index bf17842..580e9d3 100644 --- a/include/database/schema.h +++ b/include/database/schema.h @@ -90,6 +90,7 @@ bool schema_install_v6(Database *database); bool schema_install_v7(Database *database); bool schema_install_v8(Database *database); bool schema_install_v9(Database *database); +bool schema_install_v10(Database *database); /** * @brief Garantit la présence des extensions du schéma courant V2. diff --git a/include/widgets/controlled_vocab_dropdown.h b/include/widgets/controlled_vocab_dropdown.h new file mode 100644 index 0000000..efeeae3 --- /dev/null +++ b/include/widgets/controlled_vocab_dropdown.h @@ -0,0 +1,40 @@ +/****************************************************************************** + * @file controlled_vocab_dropdown.h + * @brief Composant GTK4 (GtkDropDown) basé sur le vocabulaire contrôlé. + ******************************************************************************/ +#ifndef LABFY_INVESTIGATION_CONTROLLED_VOCAB_DROPDOWN_H +#define LABFY_INVESTIGATION_CONTROLLED_VOCAB_DROPDOWN_H + +#include "core/controlled_vocab.h" +#include + +G_BEGIN_DECLS + +/** + * @brief Crée un widget GtkDropDown initialisé avec une catégorie de vocabulaire contrôlé. + * @param category Catégorie de vocabulaire. + * @param default_code Code sélectionné par défaut (facultatif). + * @return Nouveau GtkWidget (GtkDropDown). + */ +GtkWidget *controlled_vocab_dropdown_new(ControlledVocabCategory category, + const char *default_code); + +/** + * @brief Obtient le code technique sélectionné dans le dropdown. + * @param dropdown Widget GtkDropDown créé avec controlled_vocab_dropdown_new. + * @return Code technique emprunté, ou NULL si rien n'est sélectionné. + */ +const char *controlled_vocab_dropdown_get_selected_code(GtkWidget *dropdown); + +/** + * @brief Sélectionne un code technique dans le dropdown. + * @param dropdown Widget GtkDropDown. + * @param code Code à sélectionner. + * @return TRUE si le code a été trouvé et sélectionné, FALSE sinon. + */ +gboolean controlled_vocab_dropdown_set_selected_code(GtkWidget *dropdown, + const char *code); + +G_END_DECLS + +#endif /* LABFY_INVESTIGATION_CONTROLLED_VOCAB_DROPDOWN_H */ diff --git a/src/core/bank_proposal.c b/src/core/bank_proposal.c new file mode 100644 index 0000000..64de07a --- /dev/null +++ b/src/core/bank_proposal.c @@ -0,0 +1,202 @@ +/****************************************************************************** + * @file bank_proposal.c + * @brief Détection, normalisation et modèle de proposition bancaire (IBAN, RIB, BIC). + ******************************************************************************/ +#include "core/bank_proposal.h" +#include +#include +#include +#include + +void bank_proposal_free(BankProposal *p) +{ + if (p == NULL) + return; + g_free(p->id); + g_free(p->raw_iban); + g_free(p->normalized_iban); + g_free(p->bic); + g_free(p->holder_name); + g_free(p->bank_name); + g_free(p->bank_address); + g_free(p->country_code); + g_free(p->bank_code); + g_free(p->branch_code); + g_free(p->account_number); + g_free(p->rib_key); + g_free(p->suggested_ocr_fix); + g_free(p->verification_status); + g_free(p->provenance_kind); + g_free(p->evidence_id); + g_free(p->extraction_id); + g_free(p->created_at); + g_free(p->updated_at); + g_free(p); +} + +gboolean bank_proposal_validate_iban(const char *iban) +{ + if (iban == NULL) + return FALSE; + + gsize len = strlen(iban); + if (len < 15 || len > 34) + return FALSE; + + /* Vérification des 2 premières lettres (Code pays) */ + if (!g_ascii_isalpha(iban[0]) || !g_ascii_isalpha(iban[1])) + return FALSE; + + /* Repositionnement des 4 premiers caractères à la fin */ + GString *rearranged = g_string_new(iban + 4); + g_string_append_len(rearranged, iban, 4); + + /* Conversion des lettres en chiffres (A=10, Z=35) */ + GString *numeric = g_string_new(NULL); + for (gsize i = 0; i < rearranged->len; i++) + { + char c = rearranged->str[i]; + if (g_ascii_isalpha(c)) + { + int val = g_ascii_toupper(c) - 'A' + 10; + g_string_append_printf(numeric, "%d", val); + } + else if (g_ascii_isdigit(c)) + { + g_string_append_c(numeric, c); + } + else + { + g_string_free(numeric, TRUE); + g_string_free(rearranged, TRUE); + return FALSE; + } + } + + g_string_free(rearranged, TRUE); + + /* Calcul du modulo 97 par blocs */ + guint remainder = 0; + for (gsize i = 0; i < numeric->len; i++) + { + int digit = numeric->str[i] - '0'; + remainder = (remainder * 10 + (guint)digit) % 97; + } + + g_string_free(numeric, TRUE); + + return (remainder == 1); + + +} + +gboolean bank_proposal_validate_bic(const char *bic) +{ + if (bic == NULL) + return FALSE; + + gsize len = strlen(bic); + if (len != 8 && len != 11) + return FALSE; + + for (gsize i = 0; i < 4; i++) + { + if (!g_ascii_isalpha(bic[i])) + return FALSE; + } + for (gsize i = 4; i < 6; i++) + { + if (!g_ascii_isalpha(bic[i])) + return FALSE; + } + for (gsize i = 6; i < len; i++) + { + if (!g_ascii_isalnum(bic[i])) + return FALSE; + } + + return TRUE; +} + +gboolean bank_proposal_derive_french_rib(BankProposal *proposal) +{ + if (proposal == NULL || proposal->normalized_iban == NULL) + return FALSE; + + /* Vérification d'un IBAN français : "FR76..." (27 caractères) */ + if (strlen(proposal->normalized_iban) != 27 || + g_ascii_strncasecmp(proposal->normalized_iban, "FR", 2) != 0) + { + return FALSE; + } + + if (!proposal->is_iban_valid) + return FALSE; + + proposal->bank_code = g_strndup(proposal->normalized_iban + 4, 5); + proposal->branch_code = g_strndup(proposal->normalized_iban + 9, 5); + proposal->account_number = g_strndup(proposal->normalized_iban + 14, 11); + proposal->rib_key = g_strndup(proposal->normalized_iban + 25, 2); + proposal->is_derived_bban = TRUE; + + return TRUE; +} + +BankProposal *bank_proposal_analyze_text(const char *raw_text, const char *evidence_id) +{ + if (raw_text == NULL || raw_text[0] == '\0') + return NULL; + + /* Nettoyage des espaces pour recherche d'IBAN */ + GString *clean = g_string_new(NULL); + gsize raw_len = strlen(raw_text); + for (gsize i = 0; i < raw_len; i++) + { + char c = raw_text[i]; + if (g_ascii_isalnum(c)) + { + g_string_append_c(clean, g_ascii_toupper(c)); + } + } + + /* Recherche de motif IBAN (ex: FR76...) */ + const char *data = clean->str; + const char *iban_start = strstr(data, "FR"); + if (iban_start == NULL) + { + /* Essai avec d'autres codes pays à 2 lettres */ + if (clean->len >= 15 && g_ascii_isalpha(data[0]) && g_ascii_isalpha(data[1])) + iban_start = data; + } + + if (iban_start == NULL) + { + g_string_free(clean, TRUE); + return NULL; + } + + BankProposal *p = g_new0(BankProposal, 1); + p->id = g_uuid_string_random(); + p->raw_iban = g_strdup(raw_text); + p->normalized_iban = g_strndup(iban_start, 27 < strlen(iban_start) ? 27 : strlen(iban_start)); + p->country_code = g_strndup(p->normalized_iban, 2); + p->is_iban_valid = bank_proposal_validate_iban(p->normalized_iban); + p->verification_status = g_strdup("proposed"); + p->provenance_kind = g_strdup("ocr"); + p->evidence_id = evidence_id != NULL ? g_strdup(evidence_id) : NULL; + + /* Horodatage UTC courant */ + GDateTime *now = g_date_time_new_now_utc(); + p->created_at = g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ"); + p->updated_at = g_strdup(p->created_at); + g_date_time_unref(now); + + /* Dérivation RIB si IBAN français */ + if (g_ascii_strcasecmp(p->country_code, "FR") == 0) + { + bank_proposal_derive_french_rib(p); + } + + g_string_free(clean, TRUE); + return p; +} diff --git a/src/core/controlled_vocab.c b/src/core/controlled_vocab.c new file mode 100644 index 0000000..2688929 --- /dev/null +++ b/src/core/controlled_vocab.c @@ -0,0 +1,182 @@ +/****************************************************************************** + * @file controlled_vocab.c + * @brief Implémentation du vocabulaire contrôlé pour les domaines fermés. + ******************************************************************************/ +#include "core/controlled_vocab.h" +#include + +static const ControlledVocabItem VERIFICATION_STATUSES[] = { + { "proposed", "Proposé" }, + { "confirmed", "Confirmé" }, + { "rejected", "Rejeté" }, + { "conflicted", "Contradictoire" }, + { "invalid", "Invalide" } +}; +static const size_t VERIFICATION_STATUSES_COUNT = sizeof(VERIFICATION_STATUSES) / sizeof(VERIFICATION_STATUSES[0]); + +static const ControlledVocabItem PROVENANCE_KINDS[] = { + { "observed", "Observé directement" }, + { "ocr", "Extrait par OCR" }, + { "header", "Extrait d'un en-tête" }, + { "metadata", "Extrait des métadonnées" }, + { "derived", "Dérivé localement" }, + { "manual", "Saisi manuellement" } +}; +static const size_t PROVENANCE_KINDS_COUNT = sizeof(PROVENANCE_KINDS) / sizeof(PROVENANCE_KINDS[0]); + +static const ControlledVocabItem EMAIL_ROLES[] = { + { "from", "Expéditeur principal (From)" }, + { "sender", "Expéditeur réel (Sender)" }, + { "reply_to", "Adresse de réponse (Reply-To)" }, + { "return_path", "Adresse de retour (Return-Path)" }, + { "to", "Destinataire principal (To)" }, + { "cc", "Copie (Cc)" }, + { "bcc", "Copie cachée (Bcc)" }, + { "message_id_domain", "Domaine Message-ID" }, + { "other", "Autre rôle" } +}; +static const size_t EMAIL_ROLES_COUNT = sizeof(EMAIL_ROLES) / sizeof(EMAIL_ROLES[0]); + +static const ControlledVocabItem SMTP_ROLES[] = { + { "declared_source", "Source déclarée" }, + { "smtp_relay", "Relais SMTP" }, + { "destination_server", "Serveur destinataire" }, + { "private_infrastructure","Infrastructure locale/privée" }, + { "unknown", "Rôle indéterminé" } +}; +static const size_t SMTP_ROLES_COUNT = sizeof(SMTP_ROLES) / sizeof(SMTP_ROLES[0]); + +static const ControlledVocabItem VALUE_TYPES[] = { + { "text", "Texte" }, + { "identifier", "Identifiant" }, + { "email", "Adresse e-mail" }, + { "iban", "IBAN" }, + { "bic", "BIC / SWIFT" }, + { "ip_address", "Adresse IP" }, + { "domain", "Nom de domaine" }, + { "uri", "URI / URL" }, + { "integer", "Nombre entier" }, + { "decimal", "Nombre décimal" }, + { "date", "Date" }, + { "datetime", "Date et heure" }, + { "boolean", "Booléen" }, + { "json", "JSON" } +}; +static const size_t VALUE_TYPES_COUNT = sizeof(VALUE_TYPES) / sizeof(VALUE_TYPES[0]); + +const ControlledVocabItem *controlled_vocab_get_items(ControlledVocabCategory category, + size_t *out_count) +{ + switch (category) + { + case CONTROLLED_VOCAB_VERIFICATION_STATUS: + if (out_count != NULL) *out_count = VERIFICATION_STATUSES_COUNT; + return VERIFICATION_STATUSES; + case CONTROLLED_VOCAB_PROVENANCE_KIND: + if (out_count != NULL) *out_count = PROVENANCE_KINDS_COUNT; + return PROVENANCE_KINDS; + case CONTROLLED_VOCAB_EMAIL_ROLE: + if (out_count != NULL) *out_count = EMAIL_ROLES_COUNT; + return EMAIL_ROLES; + case CONTROLLED_VOCAB_SMTP_ROLE: + if (out_count != NULL) *out_count = SMTP_ROLES_COUNT; + return SMTP_ROLES; + case CONTROLLED_VOCAB_VALUE_TYPE: + if (out_count != NULL) *out_count = VALUE_TYPES_COUNT; + return VALUE_TYPES; + default: + if (out_count != NULL) *out_count = 0; + return NULL; + } +} + +gboolean controlled_vocab_is_valid_code(ControlledVocabCategory category, + const char *code) +{ + size_t count = 0; + const ControlledVocabItem *items = NULL; + + if (code == NULL || code[0] == '\0') + return FALSE; + + items = controlled_vocab_get_items(category, &count); + if (items == NULL) + return FALSE; + + for (size_t i = 0; i < count; i++) + { + if (g_ascii_strcasecmp(items[i].code, code) == 0) + return TRUE; + } + + return FALSE; +} + +const char *controlled_vocab_get_label(ControlledVocabCategory category, + const char *code) +{ + size_t count = 0; + const ControlledVocabItem *items = NULL; + + if (code == NULL || code[0] == '\0') + return NULL; + + items = controlled_vocab_get_items(category, &count); + if (items == NULL) + return NULL; + + for (size_t i = 0; i < count; i++) + { + if (g_ascii_strcasecmp(items[i].code, code) == 0) + return items[i].label; + } + + return NULL; +} + +const char *controlled_vocab_get_code_from_label(ControlledVocabCategory category, + const char *label) +{ + size_t count = 0; + const ControlledVocabItem *items = NULL; + + if (label == NULL || label[0] == '\0') + return NULL; + + items = controlled_vocab_get_items(category, &count); + if (items == NULL) + return NULL; + + for (size_t i = 0; i < count; i++) + { + if (g_ascii_strcasecmp(items[i].label, label) == 0) + return items[i].code; + } + + return NULL; +} + +gboolean controlled_vocab_is_valid_verification_status(const char *code) +{ + return controlled_vocab_is_valid_code(CONTROLLED_VOCAB_VERIFICATION_STATUS, code); +} + +gboolean controlled_vocab_is_valid_provenance_kind(const char *code) +{ + return controlled_vocab_is_valid_code(CONTROLLED_VOCAB_PROVENANCE_KIND, code); +} + +gboolean controlled_vocab_is_valid_email_role(const char *code) +{ + return controlled_vocab_is_valid_code(CONTROLLED_VOCAB_EMAIL_ROLE, code); +} + +gboolean controlled_vocab_is_valid_smtp_role(const char *code) +{ + return controlled_vocab_is_valid_code(CONTROLLED_VOCAB_SMTP_ROLE, code); +} + +gboolean controlled_vocab_is_valid_value_type(const char *code) +{ + return controlled_vocab_is_valid_code(CONTROLLED_VOCAB_VALUE_TYPE, code); +} diff --git a/src/core/eml_integration.c b/src/core/eml_integration.c index 24bffb1..52606be 100644 --- a/src/core/eml_integration.c +++ b/src/core/eml_integration.c @@ -3,25 +3,40 @@ * @brief Intégration transactionnelle de propositions issues d'un EML. ******************************************************************************/ #include "core/eml_integration.h" +#include "core/controlled_vocab.h" #include "dao/entity_dao.h" #include "dao/evidence_entity_dao.h" #include "database/transaction.h" #include "models/entity_record.h" EmlEntityProposal *eml_entity_proposal_new(const char *type, const char *value) +{ + return eml_entity_proposal_new_with_metadata(type, value, "proposed", + "header"); +} +EmlEntityProposal *eml_entity_proposal_new_with_metadata(const char *type, + const char *value, const char *verification_status, + const char *provenance_kind) { EmlEntityProposal *proposal = NULL; if (type == NULL || type[0] == '\0' || value == NULL || value[0] == '\0') return NULL; proposal = g_new0(EmlEntityProposal, 1); proposal->type_identifier = g_strdup(type); proposal->value = g_strdup(value); - if (proposal->type_identifier == NULL || proposal->value == NULL) + proposal->verification_status = g_strdup(verification_status != NULL + ? verification_status : "proposed"); + proposal->provenance_kind = g_strdup(provenance_kind != NULL + ? provenance_kind : "header"); + if (proposal->type_identifier == NULL || proposal->value == NULL || + proposal->verification_status == NULL || proposal->provenance_kind == NULL) { eml_entity_proposal_free(proposal); return NULL; } return proposal; } void eml_entity_proposal_free(EmlEntityProposal *proposal) { if (proposal == NULL) return; - g_free(proposal->type_identifier); g_free(proposal->value); g_free(proposal); + g_free(proposal->type_identifier); g_free(proposal->value); + g_free(proposal->verification_status); g_free(proposal->provenance_kind); + g_free(proposal); } /** @brief Recherche une entité existante avec le même type et la même valeur. */ static const EntityRecord *eml_integration_find_existing(const GPtrArray *entities, @@ -64,6 +79,30 @@ gboolean eml_integration_apply(Database *database, const char *evidence_identifi "Sélectionnez au moins une proposition EML."); return FALSE; } + for (guint i = 0; i < proposals->len; i++) + { + const EmlEntityProposal *proposal = g_ptr_array_index( + (GPtrArray *) proposals, i); + if (proposal == NULL || + !controlled_vocab_is_valid_verification_status( + proposal->verification_status) || + !controlled_vocab_is_valid_provenance_kind( + proposal->provenance_kind)) + { + g_set_error_literal(error, + g_quark_from_static_string("eml-integration-error"), 2, + "Le statut ou la provenance d’une proposition est invalide."); + return FALSE; + } + if (g_strcmp0(proposal->verification_status, "rejected") == 0 || + g_strcmp0(proposal->verification_status, "invalid") == 0) + { + g_set_error_literal(error, + g_quark_from_static_string("eml-integration-error"), 3, + "Une proposition rejetée ou invalide ne peut pas être intégrée."); + return FALSE; + } + } if (!database_transaction_begin(database)) return FALSE; active = TRUE; diff --git a/src/core/eml_mime_extractor.c b/src/core/eml_mime_extractor.c new file mode 100644 index 0000000..d05892a --- /dev/null +++ b/src/core/eml_mime_extractor.c @@ -0,0 +1,388 @@ +/****************************************************************************** + * @file eml_mime_extractor.c + * @brief Extraction MIME sécurisée et inventaire des pièces jointes d'un EML. + ******************************************************************************/ +#include "core/eml_mime_extractor.h" +#include "core/file_hash.h" +#include +#include +#include + +#define EML_MIME_MAX_FILE_SIZE (50U * 1024U * 1024U) +#define EML_MIME_MAX_PARTS 100U +#define EML_MIME_MAX_DEPTH 10U + +void eml_attachment_free(EmlAttachment *attachment) +{ + if (attachment == NULL) + return; + g_free(attachment->part_index); + g_free(attachment->declared_filename); + g_free(attachment->sanitized_filename); + g_free(attachment->extracted_path); + g_free(attachment->relative_path); + g_free(attachment->content_type); + g_free(attachment->detected_mime); + g_free(attachment->content_id); + g_free(attachment->transfer_encoding); + g_free(attachment->sha256); + g_free(attachment); +} + +void eml_mime_result_free(EmlMimeResult *result) +{ + if (result == NULL) + return; + if (result->attachments != NULL) + g_ptr_array_unref(result->attachments); + if (result->warnings != NULL) + g_ptr_array_unref(result->warnings); + g_free(result); +} + +char *eml_mime_sanitize_filename(const char *raw_filename) +{ + if (raw_filename == NULL || raw_filename[0] == '\0') + return g_strdup("attachment.bin"); + + char *clean = g_strdup(raw_filename); + + /* Décodage simple RFC 2047 si présent =?...?= */ + if (strstr(clean, "=?") != NULL) + { + /* Traitement basique ou suppression de préfixe */ + char *start = strstr(clean, "?B?"); + if (start == NULL) start = strstr(clean, "?b?"); + if (start != NULL) + { + char *end = strstr(start + 3, "?="); + if (end != NULL) + { + *end = '\0'; + gsize out_len = 0; + guchar *decoded = g_base64_decode(start + 3, &out_len); + if (decoded != NULL && out_len > 0) + { + char *valid = g_utf8_make_valid((const char *) decoded, (gssize) out_len); + g_free(clean); + clean = valid; + g_free(decoded); + } + } + } + } + + /* Remplacement des caractères dangereux ou des séparateurs de chemin */ + gsize len = strlen(clean); + GString *sanitized = g_string_new_len(NULL, (gssize) len); + + for (gsize i = 0; i < len; i++) + { + char c = clean[i]; + if (c == '/' || c == '\\' || c == ':' || c == '\0' || c == '\r' || c == '\n' || c == '\t') + { + g_string_append_c(sanitized, '_'); + } + else + { + g_string_append_c(sanitized, c); + } + } + g_free(clean); + + /* Suppression des séquences '..' */ + char *res = g_strdup(sanitized->str); + g_string_free(sanitized, TRUE); + + while (strstr(res, "..") != NULL) + { + char *pos = strstr(res, ".."); + pos[0] = '_'; + pos[1] = '_'; + } + + g_strstrip(res); + if (res[0] == '\0' || strcmp(res, ".") == 0 || strcmp(res, "..") == 0) + { + g_free(res); + return g_strdup("attachment.bin"); + } + + return res; +} + +static GBytes *decode_transfer_encoding(const char *encoding, const char *raw_data, gsize raw_len) +{ + if (encoding != NULL && g_ascii_strcasecmp(encoding, "base64") == 0) + { + gsize out_len = 0; + guchar *decoded = g_base64_decode(raw_data, &out_len); + if (decoded != NULL) + { + return g_bytes_new_take(decoded, out_len); + } + } + + if (encoding != NULL && + g_ascii_strcasecmp(encoding, "quoted-printable") == 0) + { + GByteArray *decoded = g_byte_array_new(); + for (gsize index = 0; index < raw_len; index++) + { + if (raw_data[index] == '=' && index + 2 < raw_len && + raw_data[index + 1] == '\r' && raw_data[index + 2] == '\n') + { + index += 2; + continue; + } + if (raw_data[index] == '=' && index + 2 < raw_len && + g_ascii_isxdigit(raw_data[index + 1]) && + g_ascii_isxdigit(raw_data[index + 2])) + { + char hex[3] = { raw_data[index + 1], raw_data[index + 2], 0 }; + guint8 value = (guint8) g_ascii_strtoll(hex, NULL, 16); + g_byte_array_append(decoded, &value, 1); + index += 2; + continue; + } + g_byte_array_append(decoded, (const guint8 *) &raw_data[index], 1); + } + return g_byte_array_free_to_bytes(decoded); + } + + /* Traitement par défaut ou quoted-printable simple */ + return g_bytes_new(raw_data, raw_len); +} + +EmlMimeResult *eml_mime_extract_attachments(const char *eml_path, + const char *target_dir, + GError **error) +{ + g_return_val_if_fail(error == NULL || *error == NULL, NULL); + + if (eml_path == NULL || eml_path[0] == '\0' || target_dir == NULL || target_dir[0] == '\0') + { + g_set_error_literal(error, G_FILE_ERROR, G_FILE_ERROR_INVAL, + "Les chemins de l'EML et du dossier cible doivent être valides."); + 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); + const char *data = g_mapped_file_get_contents(mapped); + + if (size == 0 || size > EML_MIME_MAX_FILE_SIZE) + { + g_mapped_file_unref(mapped); + g_set_error_literal(error, G_FILE_ERROR, G_FILE_ERROR_INVAL, + "Fichier EML vide ou trop volumineux."); + return NULL; + } + + if (g_mkdir_with_parents(target_dir, 0755) != 0) + { + g_mapped_file_unref(mapped); + g_set_error_literal(error, G_FILE_ERROR, G_FILE_ERROR_ACCES, + "Impossible de créer le dossier de destination des pièces jointes."); + 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); + + /* Détection de boundary MIME si multipart */ + const char *boundary_key = "boundary="; + const char *b_pos = strstr(data, boundary_key); + char *boundary = NULL; + + if (b_pos != NULL) + { + const char *b_start = b_pos + strlen(boundary_key); + if (*b_start == '"') + { + b_start++; + const char *b_end = strchr(b_start, '"'); + if (b_end != NULL) + boundary = g_strndup(b_start, (gsize)(b_end - b_start)); + } + else + { + const char *b_end = b_start; + while (*b_end && *b_end != '\r' && *b_end != '\n' && *b_end != ';') + b_end++; + boundary = g_strndup(b_start, (gsize)(b_end - b_start)); + } + } + + if (boundary != NULL) + { + char *delimiter = g_strdup_printf("--%s", boundary); + char **parts = g_strsplit(data, delimiter, EML_MIME_MAX_PARTS); + g_free(delimiter); + g_free(boundary); + + for (guint i = 1; parts[i] != NULL && parts[i][0] != '\0'; i++) + { + if (strncmp(parts[i], "--", 2) == 0) + break; /* Fin du multipart */ + + const char *part_content = parts[i]; + const char *hdr_end = strstr(part_content, "\r\n\r\n"); + if (hdr_end == NULL) hdr_end = strstr(part_content, "\n\n"); + if (hdr_end == NULL) continue; + + gsize hdr_len = (gsize)(hdr_end - part_content); + char *headers = g_strndup(part_content, hdr_len); + const char *body = hdr_end + (strstr(hdr_end, "\r\n\r\n") == hdr_end ? 4 : 2); + + /* Extraction du nom de fichier et content-type */ + char *filename = NULL; + const char *fn_pos = strstr(headers, "filename="); + if (fn_pos == NULL) fn_pos = strstr(headers, "name="); + if (fn_pos != NULL) + { + const char *fn_start = fn_pos + (strstr(fn_pos, "filename=") == fn_pos ? 9 : 5); + if (*fn_start == '"') + { + fn_start++; + const char *fn_end = strchr(fn_start, '"'); + if (fn_end != NULL) filename = g_strndup(fn_start, (gsize)(fn_end - fn_start)); + } + else + { + const char *fn_end = fn_start; + while (*fn_end && *fn_end != '\r' && *fn_end != '\n' && *fn_end != ';') fn_end++; + filename = g_strndup(fn_start, (gsize)(fn_end - fn_start)); + } + } + + gboolean is_attachment = (strstr(headers, "attachment") != NULL) || (filename != NULL); + if (is_attachment) + { + char *sanitized = eml_mime_sanitize_filename(filename); + char *dest_path = g_build_filename(target_dir, sanitized, NULL); + + /* Gestion des collisions */ + guint counter = 1; + while (g_file_test(dest_path, G_FILE_TEST_EXISTS)) + { + g_free(dest_path); + char *new_name = g_strdup_printf("%u_%s", counter++, sanitized); + dest_path = g_build_filename(target_dir, new_name, NULL); + g_free(new_name); + } + + /* Extraction du Content-Transfer-Encoding */ + char *encoding = NULL; + const char *enc_pos = strstr(headers, "Content-Transfer-Encoding:"); + if (enc_pos != NULL) + { + const char *enc_val = enc_pos + 26; + const char *enc_end = strchr(enc_val, '\n'); + if (enc_end != NULL) encoding = g_strndup(enc_val, (gsize)(enc_end - enc_val)); + if (encoding != NULL) g_strstrip(encoding); + } + + /* Extraction du Content-Type */ + char *content_type = NULL; + const char *ct_pos = strstr(headers, "Content-Type:"); + if (ct_pos != NULL) + { + const char *ct_val = ct_pos + 13; + const char *ct_end = strchr(ct_val, ';'); + if (ct_end == NULL) ct_end = strchr(ct_val, '\n'); + if (ct_end != NULL) content_type = g_strndup(ct_val, (gsize)(ct_end - ct_val)); + if (content_type != NULL) g_strstrip(content_type); + } + + /* Extraction du Content-ID */ + char *content_id = NULL; + const char *cid_pos = strstr(headers, "Content-ID:"); + if (cid_pos != NULL) + { + const char *cid_val = cid_pos + 11; + const char *cid_end = strchr(cid_val, '\n'); + if (cid_end != NULL) content_id = g_strndup(cid_val, (gsize)(cid_end - cid_val)); + if (content_id != NULL) g_strstrip(content_id); + } + + gsize body_len = strlen(body); + GBytes *decoded_bytes = decode_transfer_encoding(encoding, body, body_len); + gsize decoded_len = 0; + gconstpointer decoded_data = g_bytes_get_data(decoded_bytes, &decoded_len); + + GError *write_error = NULL; + if (g_file_set_contents(dest_path, decoded_data, (gssize) decoded_len, &write_error)) + { + char *sha256 = NULL; + guint64 file_size = 0; + file_hash_compute_sha256(dest_path, NULL, &sha256, + &file_size, NULL); + + EmlAttachment *att = g_new0(EmlAttachment, 1); + att->part_index = g_strdup_printf("1.%u", i); + att->declared_filename = filename != NULL ? g_strdup(filename) : g_strdup("attachment.bin"); + att->sanitized_filename = g_strdup(sanitized); + att->extracted_path = dest_path; dest_path = NULL; + att->content_type = content_type != NULL ? content_type : g_strdup("application/octet-stream"); content_type = NULL; + att->content_id = content_id; content_id = NULL; + att->transfer_encoding = encoding != NULL ? encoding : g_strdup("7bit"); encoding = NULL; + att->is_inline = (strstr(headers, "inline") != NULL); + att->encoded_size = body_len; + att->decoded_size = decoded_len; + att->sha256 = sha256; + { + char *target_name = g_path_get_basename(target_dir); + att->relative_path = g_build_filename( + "02_Preuves_Traitees", "eml_attachments", + target_name, sanitized, NULL); + g_free(target_name); + } + if (content_type != NULL && + g_content_type_is_a(content_type, "text/plain")) + att->detected_mime = g_strdup("text/plain"); + else + att->detected_mime = g_content_type_guess( + dest_path != NULL ? dest_path : att->extracted_path, + decoded_data, decoded_len, NULL); + if (att->detected_mime == NULL) + att->detected_mime = g_strdup("application/octet-stream"); + { + const char *dot = strrchr(att->sanitized_filename, '.'); + if (dot != NULL && g_ascii_strcasecmp(dot + 1, "txt") == 0 && + g_strcmp0(att->detected_mime, "text/plain") != 0) + att->has_inconsistency = TRUE; + } + + g_ptr_array_add(result->attachments, att); + } + else + { + char *warn = g_strdup_printf("Impossible d'écrire la pièce jointe %s : %s", + sanitized, write_error->message); + g_ptr_array_add(result->warnings, warn); + g_error_free(write_error); + g_free(dest_path); + } + + g_bytes_unref(decoded_bytes); + g_free(content_type); + g_free(content_id); + g_free(encoding); + g_free(sanitized); + } + + g_free(filename); + g_free(headers); + } + + g_strfreev(parts); + } + + g_mapped_file_unref(mapped); + return result; +} diff --git a/src/core/eml_pipeline_task.c b/src/core/eml_pipeline_task.c new file mode 100644 index 0000000..0aa166e --- /dev/null +++ b/src/core/eml_pipeline_task.c @@ -0,0 +1,177 @@ +/****************************************************************************** + * @file eml_pipeline_task.c + * @brief Pipeline d'analyse asynchrone complète pour fichier EML. + ******************************************************************************/ +#include "core/eml_pipeline_task.h" +#include "core/file_hash.h" +#include "core/rib_ocr.h" +#include +#include +#include + +typedef struct +{ + char *eml_path; + char *processed_evidence_dir; + char *evidence_id; +} EmlPipelineTaskData; + +static void eml_pipeline_task_data_free(gpointer user_data) +{ + EmlPipelineTaskData *data = user_data; + if (data == NULL) + return; + g_free(data->eml_path); + g_free(data->processed_evidence_dir); + g_free(data->evidence_id); + g_free(data); +} + +void eml_pipeline_result_free(EmlPipelineResult *res) +{ + if (res == NULL) + return; + if (res->analysis != NULL) + eml_analysis_free(res->analysis); + if (res->mime_result != NULL) + eml_mime_result_free(res->mime_result); + if (res->bank_proposals != NULL) + g_ptr_array_unref(res->bank_proposals); + if (res->warnings != NULL) + g_ptr_array_unref(res->warnings); + g_free(res); +} + +static gboolean eml_pipeline_task_worker(BackgroundTask *task, + GCancellable *cancellable, + gpointer worker_data, + gpointer *out_result, + GError **error) +{ + EmlPipelineTaskData *data = worker_data; + g_return_val_if_fail(data != NULL, FALSE); + + background_task_report_progress(task, 0.10, "Analyse des en-têtes EML..."); + if (g_cancellable_is_cancelled(cancellable)) + return FALSE; + + EmlAnalysis *analysis = eml_analyzer_analyze_file(data->eml_path, error); + if (analysis == NULL) + return FALSE; + + background_task_report_progress(task, 0.30, "Calcul d'empreinte SHA-256..."); + char *eml_hash = NULL; + guint64 size = 0; + file_hash_compute_sha256(data->eml_path, cancellable, &eml_hash, &size, + NULL); + + char *target_dir = g_build_filename(data->processed_evidence_dir, "eml_attachments", + eml_hash != NULL ? eml_hash : "default", NULL); + g_free(eml_hash); + + background_task_report_progress(task, 0.50, "Extraction sécurisée des pièces jointes..."); + if (g_cancellable_is_cancelled(cancellable)) + { + g_free(target_dir); + eml_analysis_free(analysis); + return FALSE; + } + + EmlMimeResult *mime_res = eml_mime_extract_attachments(data->eml_path, target_dir, error); + g_free(target_dir); + + if (mime_res == NULL) + { + /* Si l'extraction MIME échoue, on conserve quand même l'analyse des en-têtes (résultat partiel) */ + mime_res = g_new0(EmlMimeResult, 1); + mime_res->attachments = g_ptr_array_new_with_free_func((GDestroyNotify) eml_attachment_free); + mime_res->warnings = g_ptr_array_new_with_free_func(g_free); + 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..."); + GPtrArray *bank_proposals = g_ptr_array_new_with_free_func((GDestroyNotify) bank_proposal_free); + + for (guint i = 0; mime_res->attachments != NULL && i < mime_res->attachments->len; i++) + { + EmlAttachment *att = g_ptr_array_index(mime_res->attachments, i); + if (att->extracted_path == NULL) + continue; + + /* Analyse bancaire sur les fichiers texte/images compatibles */ + if (g_str_has_suffix(att->extracted_path, ".txt") || g_str_has_suffix(att->extracted_path, ".eml")) + { + char *content = NULL; + if (g_file_get_contents(att->extracted_path, &content, NULL, NULL)) + { + BankProposal *bp = bank_proposal_analyze_text(content, data->evidence_id); + if (bp != NULL) + { + bp->extraction_id = g_strdup(att->part_index); + g_ptr_array_add(bank_proposals, bp); + } + g_free(content); + } + } + else if (g_str_has_suffix(att->extracted_path, ".png") || g_str_has_suffix(att->extracted_path, ".jpg") || g_str_has_suffix(att->extracted_path, ".jpeg")) + { + char *ocr_text = NULL; + char *ocr_version = NULL; + (void) rib_ocr_extract_text(att->extracted_path, &ocr_text, + &ocr_version, NULL); + g_free(ocr_version); + if (ocr_text != NULL) + { + BankProposal *bp = bank_proposal_analyze_text(ocr_text, data->evidence_id); + if (bp != NULL) + { + bp->extraction_id = g_strdup(att->part_index); + g_ptr_array_add(bank_proposals, bp); + } + g_free(ocr_text); + } + } + } + + background_task_report_progress(task, 1.0, "Analyse EML terminée avec succès."); + + EmlPipelineResult *res = g_new0(EmlPipelineResult, 1); + res->analysis = analysis; + res->mime_result = mime_res; + res->bank_proposals = bank_proposals; + res->warnings = g_ptr_array_new_with_free_func(g_free); + + if (out_result != NULL) + *out_result = res; + + return TRUE; +} + +BackgroundTask *eml_pipeline_task_new(const char *eml_path, + const char *processed_evidence_dir, + const char *evidence_id) +{ + if (eml_path == NULL || processed_evidence_dir == NULL) + return NULL; + + EmlPipelineTaskData *data = g_new0(EmlPipelineTaskData, 1); + data->eml_path = g_strdup(eml_path); + data->processed_evidence_dir = g_strdup(processed_evidence_dir); + data->evidence_id = g_strdup(evidence_id); + + BackgroundTask *task = background_task_new( + "Analyse du message EML et de ses pièces jointes"); + GError *start_error = NULL; + if (task == NULL || !background_task_start(task, eml_pipeline_task_worker, + data, eml_pipeline_task_data_free, (GDestroyNotify) + eml_pipeline_result_free, NULL, NULL, NULL, &start_error)) + { + if (task != NULL) + background_task_unref(task); + else + eml_pipeline_task_data_free(data); + g_clear_error(&start_error); + return NULL; + } + return task; +} diff --git a/src/database/database.c b/src/database/database.c index 26c9055..d9a4aae 100644 --- a/src/database/database.c +++ b/src/database/database.c @@ -16,12 +16,12 @@ /** * @brief Version actuelle du schéma SQLite. */ -#define DATABASE_SCHEMA_VERSION_CURRENT 9 +#define DATABASE_SCHEMA_VERSION_CURRENT 10 /** * @brief Version actuelle sous forme textuelle pour metadata. */ -#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "9" +#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "10" /** * @brief Nom de l'application enregistré dans les métadonnées. @@ -797,6 +797,21 @@ rollback: return false; } +static bool database_migrate_v9_to_v10(Database *database) +{ + bool transaction_started = false; + if (database == NULL || !database_transaction_begin(database)) return false; + transaction_started = true; + if (!schema_install_v10(database) || + !database_update_schema_version(database, "10") || + !database_transaction_commit(database)) goto rollback; + return true; +rollback: + if (transaction_started && !database_transaction_rollback(database)) + g_warning("Impossible d’annuler la migration SQLite V9 vers V10."); + return false; +} + /** * @brief Garantit atomiquement la présence des extensions du schéma courant. */ @@ -1022,6 +1037,10 @@ bool database_migrate_to_latest( if (!database_migrate_v8_to_v9(database)) return false; schema_version = 9; break; + case 9: + if (!database_migrate_v9_to_v10(database)) return false; + schema_version = 10; + break; default: database_set_error( @@ -1156,7 +1175,8 @@ bool database_initialize( if (!schema_install_v7(database) || !schema_install_v8(database) || - !schema_install_v9(database)) + !schema_install_v9(database) || + !schema_install_v10(database)) { goto rollback; } diff --git a/src/database/schema.c b/src/database/schema.c index ec6018f..cef56b7 100644 --- a/src/database/schema.c +++ b/src/database/schema.c @@ -347,6 +347,12 @@ bool schema_install_v9(Database *database) schema_v9_migrate_relation_types(database); } +bool schema_install_v10(Database *database) +{ + return schema_execute_file(database, "database/schema_v10.sql", + "la migration SQLite V10"); +} + bool schema_ensure_current( Database *database ) diff --git a/src/views/eml_analysis_dialog.c b/src/views/eml_analysis_dialog.c index fd4eb3e..f3a0260 100644 --- a/src/views/eml_analysis_dialog.c +++ b/src/views/eml_analysis_dialog.c @@ -3,6 +3,7 @@ * @brief Présentation en lecture seule d'une analyse EML. ******************************************************************************/ #include "views/eml_analysis_dialog.h" +#include "widgets/controlled_vocab_dropdown.h" typedef struct { GtkWindow *window; GtkWidget *proposals_box; EmlAnalysisDialogCallback callback; gpointer user_data; gboolean completed; } EmlAnalysisDialogState; @@ -29,11 +30,20 @@ static void eml_analysis_dialog_on_integrate(GtkButton *button, gpointer data) for (GtkWidget *child = gtk_widget_get_first_child(state->proposals_box); child != NULL; child = gtk_widget_get_next_sibling(child)) { - const char *type = g_object_get_data(G_OBJECT(child), "eml-type"); - const char *value = g_object_get_data(G_OBJECT(child), "eml-value"); - if (GTK_IS_CHECK_BUTTON(child) && gtk_check_button_get_active( - GTK_CHECK_BUTTON(child))) - g_ptr_array_add(selected, eml_entity_proposal_new(type, value)); + GtkWidget *check = GTK_IS_BOX(child) ? gtk_widget_get_first_child(child) : NULL; + if (check != NULL && GTK_IS_CHECK_BUTTON(check) && + gtk_check_button_get_active(GTK_CHECK_BUTTON(check))) + { + const char *type = g_object_get_data(G_OBJECT(child), "eml-type"); + const char *value = g_object_get_data(G_OBJECT(child), "eml-value"); + GtkWidget *status = g_object_get_data(G_OBJECT(child), "eml-status"); + GtkWidget *provenance = g_object_get_data(G_OBJECT(child), + "eml-provenance"); + g_ptr_array_add(selected, eml_entity_proposal_new_with_metadata( + type, value, + controlled_vocab_dropdown_get_selected_code(status), + controlled_vocab_dropdown_get_selected_code(provenance))); + } } if (selected->len == 0) { g_ptr_array_unref(selected); return; } @@ -50,10 +60,22 @@ static void eml_analysis_dialog_add_proposals(GtkWidget *box, { const char *value = g_ptr_array_index((GPtrArray *) values, i); char *text = g_strdup_printf("%s : %s", label, value); + GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget *check = gtk_check_button_new_with_label(text); - g_object_set_data_full(G_OBJECT(check), "eml-type", g_strdup(type), g_free); - g_object_set_data_full(G_OBJECT(check), "eml-value", g_strdup(value), g_free); - gtk_box_append(GTK_BOX(box), check); g_free(text); + GtkWidget *status = controlled_vocab_dropdown_new( + CONTROLLED_VOCAB_VERIFICATION_STATUS, "proposed"); + GtkWidget *provenance = controlled_vocab_dropdown_new( + CONTROLLED_VOCAB_PROVENANCE_KIND, + g_strcmp0(type, "ip_address") == 0 ? "header" : "header"); + g_object_set_data_full(G_OBJECT(row), "eml-type", g_strdup(type), g_free); + g_object_set_data_full(G_OBJECT(row), "eml-value", g_strdup(value), g_free); + g_object_set_data(G_OBJECT(row), "eml-status", status); + g_object_set_data(G_OBJECT(row), "eml-provenance", provenance); + gtk_widget_set_hexpand(check, TRUE); + gtk_box_append(GTK_BOX(row), check); + gtk_box_append(GTK_BOX(row), status); + gtk_box_append(GTK_BOX(row), provenance); + gtk_box_append(GTK_BOX(box), row); g_free(text); } } /** @brief Ajoute une ligne de métadonnée sélectionnable. */ diff --git a/src/widgets/controlled_vocab_dropdown.c b/src/widgets/controlled_vocab_dropdown.c new file mode 100644 index 0000000..0921027 --- /dev/null +++ b/src/widgets/controlled_vocab_dropdown.c @@ -0,0 +1,83 @@ +/****************************************************************************** + * @file controlled_vocab_dropdown.c + * @brief Implémentation du widget GTK4 GtkDropDown pour le vocabulaire contrôlé. + ******************************************************************************/ +#include "widgets/controlled_vocab_dropdown.h" + +#define CONTROLLED_VOCAB_CATEGORY_KEY "labfy-vocab-category" + +GtkWidget *controlled_vocab_dropdown_new(ControlledVocabCategory category, + const char *default_code) +{ + size_t count = 0; + const ControlledVocabItem *items = controlled_vocab_get_items(category, &count); + if (items == NULL || count == 0) + return NULL; + + const char **labels = g_new0(const char *, count + 1); + for (size_t i = 0; i < count; i++) + { + labels[i] = items[i].label; + } + labels[count] = NULL; + + GtkStringList *string_list = gtk_string_list_new(labels); + g_free(labels); + + GtkWidget *dropdown = gtk_drop_down_new(G_LIST_MODEL(string_list), NULL); + g_object_set_data(G_OBJECT(dropdown), CONTROLLED_VOCAB_CATEGORY_KEY, + GUINT_TO_POINTER(category)); + + if (default_code != NULL) + { + controlled_vocab_dropdown_set_selected_code(dropdown, default_code); + } + + return dropdown; +} + +const char *controlled_vocab_dropdown_get_selected_code(GtkWidget *dropdown) +{ + if (dropdown == NULL || !GTK_IS_DROP_DOWN(dropdown)) + return NULL; + + guint category_val = GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(dropdown), CONTROLLED_VOCAB_CATEGORY_KEY)); + ControlledVocabCategory category = (ControlledVocabCategory) category_val; + + guint selected = gtk_drop_down_get_selected(GTK_DROP_DOWN(dropdown)); + if (selected == GTK_INVALID_LIST_POSITION) + return NULL; + + size_t count = 0; + const ControlledVocabItem *items = controlled_vocab_get_items(category, &count); + if (items == NULL || selected >= count) + return NULL; + + return items[selected].code; +} + +gboolean controlled_vocab_dropdown_set_selected_code(GtkWidget *dropdown, + const char *code) +{ + if (dropdown == NULL || !GTK_IS_DROP_DOWN(dropdown) || code == NULL) + return FALSE; + + guint category_val = GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(dropdown), CONTROLLED_VOCAB_CATEGORY_KEY)); + ControlledVocabCategory category = (ControlledVocabCategory) category_val; + + size_t count = 0; + const ControlledVocabItem *items = controlled_vocab_get_items(category, &count); + if (items == NULL) + return FALSE; + + for (size_t i = 0; i < count; i++) + { + if (g_ascii_strcasecmp(items[i].code, code) == 0) + { + gtk_drop_down_set_selected(GTK_DROP_DOWN(dropdown), (guint) i); + return TRUE; + } + } + + return FALSE; +} diff --git a/tests/test_bank_proposal.c b/tests/test_bank_proposal.c new file mode 100644 index 0000000..2c1beec --- /dev/null +++ b/tests/test_bank_proposal.c @@ -0,0 +1,58 @@ +/****************************************************************************** + * @file test_bank_proposal.c + * @brief Tests unitaires du modèle bancaire et de l'analyse d'IBAN / RIB / BIC. + ******************************************************************************/ +#include "core/bank_proposal.h" +#include + +static void test_iban_validation(void) +{ + /* IBAN français synthétique avec MOD-97 et RIB valide */ + const char *valid_fr = "FR4830002005500000000000052"; + g_assert_true(bank_proposal_validate_iban(valid_fr)); + + + + /* IBAN invalide (mauvaise clé) */ + const char *invalid_fr = "FR0030002005500000000000021"; + g_assert_false(bank_proposal_validate_iban(invalid_fr)); + + /* IBAN trop court */ + g_assert_false(bank_proposal_validate_iban("FR763000")); +} + +static void test_bic_validation(void) +{ + g_assert_true(bank_proposal_validate_bic("BNPAFRPPXXX")); + g_assert_true(bank_proposal_validate_bic("BNPAFRPP")); + g_assert_false(bank_proposal_validate_bic("BNPAFR")); + g_assert_false(bank_proposal_validate_bic("INVALID BIC!")); +} + +static void test_french_rib_derivation(void) +{ + const char *valid_fr = "FR4830002005500000000000052"; + BankProposal *proposal = bank_proposal_analyze_text(valid_fr, "evidence-123"); + g_assert_nonnull(proposal); + g_assert_true(proposal->is_iban_valid); + g_assert_true(proposal->is_derived_bban); + g_assert_cmpstr(proposal->country_code, ==, "FR"); + g_assert_cmpstr(proposal->bank_code, ==, "30002"); + g_assert_cmpstr(proposal->branch_code, ==, "00550"); + g_assert_cmpstr(proposal->account_number, ==, "00000000000"); + g_assert_cmpstr(proposal->rib_key, ==, "52"); + g_assert_cmpstr(proposal->evidence_id, ==, "evidence-123"); + g_assert_cmpstr(proposal->verification_status, ==, "proposed"); + + bank_proposal_free(proposal); +} + + +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/bank-proposal/iban-validation", test_iban_validation); + g_test_add_func("/bank-proposal/bic-validation", test_bic_validation); + g_test_add_func("/bank-proposal/french-rib-derivation", test_french_rib_derivation); + return g_test_run(); +} diff --git a/tests/test_controlled_vocab.c b/tests/test_controlled_vocab.c new file mode 100644 index 0000000..4526c32 --- /dev/null +++ b/tests/test_controlled_vocab.c @@ -0,0 +1,91 @@ +/****************************************************************************** + * @file test_controlled_vocab.c + * @brief Tests unitaires du vocabulaire contrôlé. + ******************************************************************************/ +#include "core/controlled_vocab.h" +#include + +static void test_verification_statuses(void) +{ + size_t count = 0; + const ControlledVocabItem *items = controlled_vocab_get_items(CONTROLLED_VOCAB_VERIFICATION_STATUS, &count); + g_assert_nonnull(items); + g_assert_cmpuint(count, ==, 5); + + g_assert_true(controlled_vocab_is_valid_verification_status("proposed")); + g_assert_true(controlled_vocab_is_valid_verification_status("confirmed")); + g_assert_true(controlled_vocab_is_valid_verification_status("rejected")); + g_assert_true(controlled_vocab_is_valid_verification_status("conflicted")); + g_assert_true(controlled_vocab_is_valid_verification_status("invalid")); + + g_assert_false(controlled_vocab_is_valid_verification_status("unknown_status")); + g_assert_false(controlled_vocab_is_valid_verification_status(NULL)); + g_assert_false(controlled_vocab_is_valid_verification_status("")); + + g_assert_cmpstr(controlled_vocab_get_label(CONTROLLED_VOCAB_VERIFICATION_STATUS, "proposed"), ==, "Proposé"); + g_assert_cmpstr(controlled_vocab_get_code_from_label(CONTROLLED_VOCAB_VERIFICATION_STATUS, "Proposé"), ==, "proposed"); +} + +static void test_provenance_kinds(void) +{ + size_t count = 0; + const ControlledVocabItem *items = controlled_vocab_get_items(CONTROLLED_VOCAB_PROVENANCE_KIND, &count); + g_assert_nonnull(items); + g_assert_cmpuint(count, ==, 6); + + g_assert_true(controlled_vocab_is_valid_provenance_kind("observed")); + g_assert_true(controlled_vocab_is_valid_provenance_kind("ocr")); + g_assert_true(controlled_vocab_is_valid_provenance_kind("header")); + g_assert_true(controlled_vocab_is_valid_provenance_kind("metadata")); + g_assert_true(controlled_vocab_is_valid_provenance_kind("derived")); + g_assert_true(controlled_vocab_is_valid_provenance_kind("manual")); + + g_assert_false(controlled_vocab_is_valid_provenance_kind("invalid_kind")); +} + +static void test_email_roles(void) +{ + size_t count = 0; + const ControlledVocabItem *items = controlled_vocab_get_items(CONTROLLED_VOCAB_EMAIL_ROLE, &count); + g_assert_nonnull(items); + g_assert_cmpuint(count, ==, 9); + + g_assert_true(controlled_vocab_is_valid_email_role("from")); + g_assert_true(controlled_vocab_is_valid_email_role("reply_to")); + g_assert_false(controlled_vocab_is_valid_email_role("custom_role")); +} + +static void test_smtp_roles(void) +{ + size_t count = 0; + const ControlledVocabItem *items = controlled_vocab_get_items(CONTROLLED_VOCAB_SMTP_ROLE, &count); + g_assert_nonnull(items); + g_assert_cmpuint(count, ==, 5); + + g_assert_true(controlled_vocab_is_valid_smtp_role("declared_source")); + g_assert_true(controlled_vocab_is_valid_smtp_role("smtp_relay")); + g_assert_false(controlled_vocab_is_valid_smtp_role("fraudster_ip")); +} + +static void test_value_types(void) +{ + size_t count = 0; + const ControlledVocabItem *items = controlled_vocab_get_items(CONTROLLED_VOCAB_VALUE_TYPE, &count); + g_assert_nonnull(items); + g_assert_cmpuint(count, ==, 14); + + g_assert_true(controlled_vocab_is_valid_value_type("iban")); + g_assert_true(controlled_vocab_is_valid_value_type("email")); + g_assert_false(controlled_vocab_is_valid_value_type("invalid_type")); +} + +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/controlled-vocab/verification-status", test_verification_statuses); + g_test_add_func("/controlled-vocab/provenance-kinds", test_provenance_kinds); + g_test_add_func("/controlled-vocab/email-roles", test_email_roles); + g_test_add_func("/controlled-vocab/smtp-roles", test_smtp_roles); + g_test_add_func("/controlled-vocab/value-types", test_value_types); + return g_test_run(); +} diff --git a/tests/test_database.c b/tests/test_database.c index a2df087..a256053 100644 --- a/tests/test_database.c +++ b/tests/test_database.c @@ -525,7 +525,8 @@ static void test_database_initialize_valid_database(void) "FROM investigation;" ); - assert(strcmp(schema_version, "9") == 0); + assert(strcmp(schema_version, "10") == 0); + test_database_assert_table_exists(database, "bank_account_entities"); test_database_assert_table_exists(database, "relation_types"); test_database_assert_table_exists(database, "graph_viewport"); test_database_assert_table_exists(database, "osint_executions"); @@ -991,7 +992,7 @@ static void test_database_migrate_v1_to_v2(void) assert( strcmp( schema_version, - "9" + "10" ) == 0 ); diff --git a/tests/test_eml_pipeline_task.c b/tests/test_eml_pipeline_task.c new file mode 100644 index 0000000..004e3fa --- /dev/null +++ b/tests/test_eml_pipeline_task.c @@ -0,0 +1,94 @@ +/****************************************************************************** + * @file test_eml_pipeline_task.c + * @brief Tests unitaires du pipeline asynchrone d'analyse EML et pièces jointes. + ******************************************************************************/ +#include "core/eml_pipeline_task.h" +#include +#include +#include + +static void test_eml_pipeline_basic(void) +{ + char *tmp_dir = g_dir_make_tmp("labfy-eml-test-XXXXXX", NULL); + g_assert_nonnull(tmp_dir); + + char *eml_path = g_build_filename(tmp_dir, "test.eml", NULL); + const char *eml_content = + "From: Alice \r\n" + "To: Bob \r\n" + "Subject: Rib suspect\r\n" + "Date: Thu, 23 Jul 2026 12:00:00 +0200\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: multipart/mixed; boundary=\"BOUNDARY123\"\r\n" + "\r\n" + "--BOUNDARY123\r\n" + "Content-Type: text/plain; charset=utf-8\r\n" + "\r\n" + "Veuillez trouver ci-joint mon RIB.\r\n" + "--BOUNDARY123\r\n" + "Content-Type: text/plain; name=\"../rib_suspect.txt\"\r\n" + "Content-Disposition: attachment; filename=\"../rib_suspect.txt\"\r\n" + "\r\n" + "FR4830002005500000000000052\r\n" + "--BOUNDARY123\r\n" + "Content-Type: text/plain; name=\"../rib_suspect.txt\"\r\n" + "Content-Disposition: attachment; filename=\"../rib_suspect.txt\"\r\n" + "Content-Transfer-Encoding: quoted-printable\r\n" + "\r\n" + "Deuxi=C3=A8me RIB\r\n" + "--BOUNDARY123--\r\n"; + + g_file_set_contents(eml_path, eml_content, -1, NULL); + + char *processed_dir = g_build_filename(tmp_dir, "02_Preuves_Traitees", NULL); + g_mkdir_with_parents(processed_dir, 0755); + + BackgroundTask *task = eml_pipeline_task_new(eml_path, processed_dir, "evidence-uuid-1"); + g_assert_nonnull(task); + + g_assert_cmpint(background_task_get_state(task), !=, + BACKGROUND_TASK_STATE_FAILED); + + /* Attente de la fin de la tâche */ + while (background_task_get_state(task) == BACKGROUND_TASK_STATE_RUNNING || + background_task_get_state(task) == BACKGROUND_TASK_STATE_PENDING) + { + g_main_context_iteration(NULL, TRUE); + } + + g_assert_cmpint(background_task_get_state(task), ==, BACKGROUND_TASK_STATE_COMPLETED); + + EmlPipelineResult *result = background_task_get_result(task); + g_assert_nonnull(result); + g_assert_nonnull(result->analysis); + g_assert_nonnull(result->mime_result); + + /* Vérification de la protection contre les traversées de chemin "../" */ + g_assert_cmpuint(result->mime_result->attachments->len, ==, 2); + EmlAttachment *att = g_ptr_array_index(result->mime_result->attachments, 0); + g_assert_cmpstr(att->sanitized_filename, ==, "___rib_suspect.txt"); + att = g_ptr_array_index(result->mime_result->attachments, 1); + g_assert_cmpstr(att->sanitized_filename, ==, "___rib_suspect.txt"); + g_assert_true(g_str_has_suffix(att->extracted_path, + "/1____rib_suspect.txt")); + g_assert_cmpuint(att->decoded_size, >, 0U); + + /* Vérification de la détection de la proposition bancaire dans la pièce jointe */ + g_assert_cmpuint(result->bank_proposals->len, ==, 1); + BankProposal *bp = g_ptr_array_index(result->bank_proposals, 0); + g_assert_cmpstr(bp->normalized_iban, ==, "FR4830002005500000000000052"); + g_assert_true(bp->is_iban_valid); + + background_task_unref(task); + g_remove(eml_path); + g_free(eml_path); + g_free(processed_dir); + g_free(tmp_dir); +} + +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/eml-pipeline-task/basic", test_eml_pipeline_basic); + return g_test_run(); +} diff --git a/watch_20260723-211101 b/watch_20260723-211101 new file mode 100644 index 0000000..18a636d --- /dev/null +++ b/watch_20260723-211101 @@ -0,0 +1,5 @@ +NAME ID SIZE PROCESSOR CONTEXT UNTIL + + total utilisé libre partagé tamp/cache disponible +Mem: 14Gi 5,1Gi 7,9Gi 70Mi 2,2Gi 9,8Gi +Échange: 4,0Gi 3,8Gi 224Mi