Compare commits
3 commits
d65f9559e6
...
7053bcc345
| Author | SHA1 | Date | |
|---|---|---|---|
| 7053bcc345 | |||
| 9495e1258a | |||
| 3488f67b2a |
162 changed files with 8840 additions and 48384 deletions
57
CHANGELOG.md
57
CHANGELOG.md
|
|
@ -1,5 +1,36 @@
|
|||
## [0.1.0-dev] — en préparation, non publiée
|
||||
|
||||
### Interface Web finale locale
|
||||
|
||||
- Web devient l’interface utilisateur `CURRENT` unique : coque Catppuccin
|
||||
Mocha/Lavender Agent | Graphe | Activité, drawer transversal et cartes
|
||||
SPECIMEN pour conversation, plan, hypothèse, autorisation et résultat.
|
||||
- Retrait du client, des vues, widgets, tests, headers et dépendance de build
|
||||
GTK4 ; les services C, SQLite V20, fondation financière V21, capabilities et
|
||||
JobStore V4 sont conservés.
|
||||
- Ajout de `tools/labfy` : instance loopback vérifiée, navigateur ouvert et
|
||||
session HttpOnly/SameSite automatique sans code ni jeton dans l’URL ; les
|
||||
mutations restent protégées par Host, Origin exact et CSRF.
|
||||
|
||||
### Connexion Web
|
||||
|
||||
- La session de `3600` secondes (une heure) commence à l'authentification, et non au démarrage
|
||||
du serveur ; une reconnexion après expiration renouvelle cookie et CSRF.
|
||||
- Régressions HTTP synthétiques : première connexion tardive, expiration
|
||||
absolue, ancien cookie refusé et échec de connexion sans prolongation.
|
||||
- Le parcours Firefox synthétique couvre désormais l'expiration pendant les
|
||||
pollings, l'arrêt unique des requêtes, la reconnexion sur le même serveur et
|
||||
le refus du couple cookie/CSRF précédent. L'interface ne rejoue aucune
|
||||
mutation refusée ou d'admission inconnue.
|
||||
|
||||
### Validation Web
|
||||
|
||||
- Le runner navigateur comptabilise `MISSING`, `LAUNCH_ERROR`, `EXIT_CODE`,
|
||||
`SIGNALED`, `TIMEOUT`, `INTERRUPTED` et `SUCCESS`. Sur Linux, il ne termine
|
||||
que le groupe et la session de processus qu'il a créés : `SIGTERM`, grâce
|
||||
bornée, puis `SIGKILL` si nécessaire. Ses régressions synthétiques vérifient
|
||||
qu'un témoin indépendant survit.
|
||||
|
||||
### Pivots locaux J7
|
||||
|
||||
- Index C typé des observations persistées, rapprochements explicables
|
||||
|
|
@ -127,6 +158,21 @@
|
|||
- Finalisation JobStore V3 : budgets opposables au claim, temps monotone,
|
||||
réserve conservée après crash, états de plan exportés et scénario Firefox J8.
|
||||
|
||||
# Changements locaux non publiés — recherche OSINT assistée V1
|
||||
|
||||
- JobStore V4 additif à V3 : persistance atomique et idempotente des plans,
|
||||
actions, grants, campagnes, résultats et reçus de recherche, sans évolution
|
||||
de V20 ni de la tranche financière V21.
|
||||
- Policy explicite de sélection, exclusions, révocation, expiration, identité
|
||||
d'enquête/plan/action et budgets ; aucune propagation implicite par seed,
|
||||
CNAME, redirection ou nouvelle URL.
|
||||
- Transport commun indisponible sans backend lié et libcurl couvert seulement
|
||||
par fixtures/loopback ; aucun fournisseur public n'est configuré ou qualifié.
|
||||
- Adapters normalisateurs DNS, RDAP, CDX, Brave, SearXNG et page, avec résultats
|
||||
bornés, provenance C et absence de suivi implicite de liens, scripts ou pièces jointes.
|
||||
- Parcours Web synthétique de préparation, approbation/refus, deux vagues et
|
||||
révocation durable, avec isolation vérifiée entre espaces de travail A/B.
|
||||
|
||||
# Changements locaux non publiés — J9
|
||||
|
||||
## Espace local et import navigateur (non publié)
|
||||
|
|
@ -167,6 +213,17 @@
|
|||
|
||||
# Changements locaux non publiés — poste Web UX V1
|
||||
|
||||
## Poste Web local et bibliothèque (non publié)
|
||||
|
||||
- Lanceur local à instance unique, bibliothèque explicitement choisie,
|
||||
création C idempotente et ouverture d'une seule enquête active par instance.
|
||||
- État d'instance, configuration et code d'amorçage séparés dans les répertoires
|
||||
XDG privés ; écoute loopback et contrôles de santé avant `status`, `stop` ou
|
||||
consultation du code.
|
||||
- Validation Web intégrée : bridges C, unités Node, découverte Python et
|
||||
parcours navigateur exécutés séquentiellement par
|
||||
`make web-workspace-web-check`.
|
||||
|
||||
- Réorganisation graph-first en barre compacte, projections, inspecteur à
|
||||
onglets et tiroir Tâches/Actions/Plans/Rapports responsive.
|
||||
- Ajout d’actions ancrées près des nœuds, avec clic droit, `Maj+F10`, fermeture
|
||||
|
|
|
|||
153
Makefile
153
Makefile
|
|
@ -1,7 +1,7 @@
|
|||
CC = gcc
|
||||
|
||||
PKG_CONFIG = pkg-config
|
||||
REQUIRED_PACKAGES = gtk4 sqlite3 libheif poppler-glib json-glib-1.0
|
||||
REQUIRED_PACKAGES = glib-2.0 gio-2.0 sqlite3 libheif poppler-glib json-glib-1.0
|
||||
ifeq ($(shell $(PKG_CONFIG) --exists $(REQUIRED_PACKAGES) && echo yes),)
|
||||
$(error Dépendances de compilation manquantes : $(REQUIRED_PACKAGES). Voir docs/DEPENDENCE.md)
|
||||
endif
|
||||
|
|
@ -11,19 +11,35 @@ endif
|
|||
SOURCE_SIZE_LIMIT := 2000
|
||||
# Exceptions historiques : plafonds constatés avant la tranche V18.
|
||||
SOURCE_SIZE_EXCEPTIONS := \
|
||||
src/core/application.c:9325 \
|
||||
src/widgets/investigation_graph_view.c:4901 \
|
||||
src/widgets/workspace.c:4071 \
|
||||
src/views/main_window.c:2239 \
|
||||
src/views/create_relation_dialog.c:2043
|
||||
src/core/local_job_store.c:2000
|
||||
|
||||
.PHONY: check-source-size web-workspace web-workspace-j7 web-workspace-j8 web-workspace-j9 web-workspace-local web-workspace-review-check
|
||||
.PHONY: check-source-size check-research-libcurl web web-start web-status web-stop web-workspace web-workspace-j7 web-workspace-j8 web-workspace-j9 web-workspace-local web-workspace-review-check web-workspace-web-check
|
||||
.NOTPARALLEL: web-workspace-review-check
|
||||
|
||||
# CONTRACT: le poste Web possède sa bibliothèque explicite, séparée des
|
||||
# enquêtes existantes. XDG_DATA_HOME est évalué par Python afin que Make ne
|
||||
# propage pas un chemin personnel figé dans le dépôt.
|
||||
PORT ?= 8081
|
||||
LIBRARY ?= $(shell python3 -c 'import os; from pathlib import Path; print(Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "labfy-investigation" / "web-8081-lab")')
|
||||
|
||||
web: tools/local-jobs
|
||||
LABFY_LIBRARY="$(LIBRARY)" tools/labfy
|
||||
|
||||
web-start: tools/local-jobs
|
||||
tools/labfy-web start --automatic-session --open-browser --port "$(PORT)" --library "$(LIBRARY)"
|
||||
|
||||
web-status:
|
||||
tools/labfy-web status
|
||||
|
||||
web-stop:
|
||||
tools/labfy-web stop
|
||||
|
||||
check-source-size:
|
||||
@limit=$(SOURCE_SIZE_LIMIT); failed=0; \
|
||||
files="$$(git ls-files --cached --others --exclude-standard -- \
|
||||
'src/*.c' 'src/**/*.c' | sort -u)"; \
|
||||
for file in $$files; do \
|
||||
[ -f "$$file" ] || continue; \
|
||||
lines=$$(wc -l < "$$file"); allowed=$$limit; historical=0; \
|
||||
for exception in $(SOURCE_SIZE_EXCEPTIONS); do \
|
||||
case "$$exception" in "$$file":*) \
|
||||
|
|
@ -39,6 +55,14 @@ check-source-size:
|
|||
done; \
|
||||
exit $$failed
|
||||
|
||||
# CONTRACT: le diagnostic libcurl est limité aux cibles qui compilent le
|
||||
# transport HTTP concret ; les tests de policy/adapters restent indépendants.
|
||||
check-research-libcurl:
|
||||
@$(PKG_CONFIG) --exists libcurl || { \
|
||||
echo "Dépendance de compilation manquante : libcurl (requis par le transport HTTP de recherche)." >&2; \
|
||||
exit 2; \
|
||||
}
|
||||
|
||||
web-workspace: tools/local-jobs
|
||||
@test -n "$(WORKSPACE)" || (echo "Usage: make web-workspace WORKSPACE=/tmp/labfy-local-workspace"; exit 2)
|
||||
python3 prototypes/web-graph/run_web_workspace.py --workspace "$(WORKSPACE)"
|
||||
|
|
@ -72,6 +96,15 @@ web-workspace-review-check: tools/local-jobs tools/local-jobs-test
|
|||
@echo "Phase 2/2 — parcours navigateur de l'espace de travail local"
|
||||
node prototypes/web-graph/tests/test_local_workspace_import_browser.mjs
|
||||
|
||||
# CONTRACT: construit les bridges C avant les validations Web qui les invoquent.
|
||||
web-workspace-web-check: $(LOCAL_JOBS) $(LOCAL_JOBS_TEST) $(CORE_GRAPH_DEMO) $(EML_GRAPH_DEMO) $(LOCAL_TOOLKIT_DEMO)
|
||||
@echo "Phase 1/3 — unité Node du graphe Web"
|
||||
cd prototypes/web-graph && npm test
|
||||
@echo "Phase 2/3 — découverte Python du Web"
|
||||
python3 -m unittest discover -s prototypes/web-graph/tests -p 'test_*.py'
|
||||
@echo "Phase 3/3 — parcours navigateur Web séquentiels"
|
||||
cd prototypes/web-graph && npm run test:browser
|
||||
|
||||
CFLAGS = -std=c17 \
|
||||
-Wall \
|
||||
-Wextra \
|
||||
|
|
@ -80,9 +113,9 @@ CFLAGS = -std=c17 \
|
|||
-Iinclude \
|
||||
-MMD \
|
||||
-MP \
|
||||
$(shell $(PKG_CONFIG) --cflags gtk4 sqlite3 libheif poppler-glib json-glib-1.0)
|
||||
$(shell $(PKG_CONFIG) --cflags glib-2.0 gio-2.0 sqlite3 libheif poppler-glib json-glib-1.0)
|
||||
|
||||
LDFLAGS = $(shell $(PKG_CONFIG) --libs gtk4 sqlite3 libheif poppler-glib json-glib-1.0) -ljpeg
|
||||
LDFLAGS = $(shell $(PKG_CONFIG) --libs glib-2.0 gio-2.0 sqlite3 libheif poppler-glib json-glib-1.0) -ljpeg
|
||||
|
||||
TEST_CFLAGS = -std=c17 \
|
||||
-Wall \
|
||||
|
|
@ -99,16 +132,6 @@ EVIDENCE_TYPE_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
|
|||
ENTITY_TYPE_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
|
||||
EVIDENCE_TYPE_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
|
||||
GRAPH_NODE_POSITION_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
|
||||
EVIDENCE_IMPORT_DIALOG_TEST_CFLAGS := \
|
||||
-std=c17 \
|
||||
-Wall \
|
||||
-Wextra \
|
||||
-Werror \
|
||||
-Iinclude \
|
||||
$(shell $(PKG_CONFIG) --cflags gtk4)
|
||||
|
||||
EVIDENCE_IMPORT_DIALOG_TEST_LDFLAGS := $(shell $(PKG_CONFIG) --libs gtk4)
|
||||
|
||||
EVIDENCE_INTEGRITY_VERIFIER_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
|
||||
ENTITY_RECORD_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
|
||||
ENTITY_TYPE_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
|
||||
|
|
@ -254,6 +277,31 @@ EML_GRAPH_DEMO := tools/eml_graph_demo
|
|||
LOCAL_TOOLKIT_DEMO := tools/local_toolkit_demo
|
||||
LOCAL_JOBS := tools/local-jobs
|
||||
LOCAL_JOBS_TEST := tools/local-jobs-test
|
||||
TEST_RESEARCH_STORE := tests/test_research_store
|
||||
TEST_RESEARCH_POLICY := tests/test_research_policy
|
||||
TEST_RESEARCH_TRANSPORT := tests/test_research_transport
|
||||
TEST_RESEARCH_ADAPTERS := tests/test_research_adapters
|
||||
|
||||
# CONTRACT: seul le transport HTTP concret lie libcurl ; les contrats et
|
||||
# adapters restent utilisables dans les tests et outils sans backend réseau.
|
||||
RESEARCH_SOURCES := \
|
||||
src/core/research_contracts.c \
|
||||
src/core/research_policy.c \
|
||||
src/core/research_transport.c \
|
||||
src/core/research_http_transport_curl.c \
|
||||
src/core/research_adapter_dns.c \
|
||||
src/core/research_adapter_rdap.c \
|
||||
src/core/research_adapter_cdx.c \
|
||||
src/core/research_adapter_search.c \
|
||||
src/core/research_adapter_page.c
|
||||
RESEARCH_CURL_CFLAGS := $(shell $(PKG_CONFIG) --cflags libcurl)
|
||||
RESEARCH_CURL_LDFLAGS := $(shell $(PKG_CONFIG) --libs libcurl)
|
||||
RESEARCH_CURL_TARGETS := $(TARGET) $(TEST_EVIDENCE_METADATA_DIALOG_GTK) \
|
||||
$(TEST_EVIDENCE_IDENTITY_IMPORT_GTK) $(TEST_WORKSPACE_IDENTITY_OCR_GTK)
|
||||
|
||||
$(RESEARCH_CURL_TARGETS): check-research-libcurl
|
||||
$(RESEARCH_CURL_TARGETS): CFLAGS += $(RESEARCH_CURL_CFLAGS)
|
||||
$(RESEARCH_CURL_TARGETS): LDFLAGS += $(RESEARCH_CURL_LDFLAGS)
|
||||
|
||||
LOCAL_REVIEW_PREVIEW_SOURCES := \
|
||||
src/core/observation_review_service.c \
|
||||
|
|
@ -331,6 +379,28 @@ $(TEST_LOCAL_CAPABILITY_REGISTRY): tests/test_local_capability_registry.c \
|
|||
$(TEST_LOCAL_JOB_STORE): tests/test_local_job_store.c src/core/local_job_store.c
|
||||
$(CC) $(CORE_GRAPH_CFLAGS) -DLOCAL_JOB_STORE_ENABLE_TEST_HOOKS $^ -o $@ $(CORE_GRAPH_LDFLAGS)
|
||||
|
||||
$(TEST_RESEARCH_STORE): tests/test_research_store.c \
|
||||
src/core/local_job_store.c src/core/research_contracts.c
|
||||
$(CC) $(CORE_GRAPH_CFLAGS) $^ -o $@ $(CORE_GRAPH_LDFLAGS)
|
||||
|
||||
$(TEST_RESEARCH_POLICY): tests/test_research_policy.c \
|
||||
src/core/research_contracts.c src/core/research_policy.c
|
||||
$(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS)
|
||||
|
||||
$(TEST_RESEARCH_TRANSPORT): check-research-libcurl \
|
||||
tests/test_research_transport.c src/core/research_contracts.c \
|
||||
src/core/research_policy.c src/core/research_transport.c \
|
||||
src/core/research_http_transport_curl.c
|
||||
$(CC) $(TEST_CFLAGS) -Wpedantic -DRESEARCH_TRANSPORT_ENABLE_TEST_HOOKS \
|
||||
$(RESEARCH_CURL_CFLAGS) $(filter %.c,$^) -o $@ $(TEST_LDFLAGS) \
|
||||
$(RESEARCH_CURL_LDFLAGS)
|
||||
|
||||
$(TEST_RESEARCH_ADAPTERS): tests/test_research_adapters.c \
|
||||
src/core/research_contracts.c src/core/research_adapter_dns.c \
|
||||
src/core/research_adapter_rdap.c src/core/research_adapter_cdx.c \
|
||||
src/core/research_adapter_search.c src/core/research_adapter_page.c
|
||||
$(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS)
|
||||
|
||||
$(TEST_LOCAL_CORRELATION): tests/test_local_correlation.c \
|
||||
src/core/local_correlation_service.c \
|
||||
src/dao/evidence_dao.c src/dao/evidence_entity_dao.c \
|
||||
|
|
@ -356,13 +426,17 @@ $(TEST_OBSERVATION_REVIEW_SERVICE): tests/test_observation_review_service.c \
|
|||
|
||||
$(LOCAL_JOBS): tools/local_jobs.c src/core/local_job_store.c \
|
||||
src/core/local_planner_service.c \
|
||||
$(LOCAL_REVIEW_PREVIEW_SOURCES) $(LOCAL_TOOLKIT_SOURCES)
|
||||
$(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS)
|
||||
$(LOCAL_REVIEW_PREVIEW_SOURCES) $(LOCAL_TOOLKIT_SOURCES) $(RESEARCH_SOURCES) \
|
||||
check-research-libcurl
|
||||
$(CC) $(CFLAGS) $(RESEARCH_CURL_CFLAGS) $(filter %.c,$^) -o $@ $(LDFLAGS) \
|
||||
$(RESEARCH_CURL_LDFLAGS)
|
||||
|
||||
$(LOCAL_JOBS_TEST): tools/local_jobs.c src/core/local_job_store.c \
|
||||
src/core/local_planner_service.c \
|
||||
$(LOCAL_REVIEW_PREVIEW_SOURCES) $(LOCAL_TOOLKIT_SOURCES)
|
||||
$(CC) $(CFLAGS) -DLOCAL_JOBS_ENABLE_CRASH_HOOK $^ -o $@ $(LDFLAGS)
|
||||
$(LOCAL_REVIEW_PREVIEW_SOURCES) $(LOCAL_TOOLKIT_SOURCES) $(RESEARCH_SOURCES) \
|
||||
check-research-libcurl
|
||||
$(CC) $(CFLAGS) -DLOCAL_JOBS_ENABLE_CRASH_HOOK $(RESEARCH_CURL_CFLAGS) \
|
||||
$(filter %.c,$^) -o $@ $(LDFLAGS) $(RESEARCH_CURL_LDFLAGS)
|
||||
|
||||
$(TEST_DIALOG_GEOMETRY_GTK): tests/test_dialog_geometry_gtk.c \
|
||||
src/views/dialog_geometry.c
|
||||
|
|
@ -423,7 +497,10 @@ $(TEST_PERSON_OCR_PROJECTION_EDITOR_GTK): tests/test_person_ocr_projection_edito
|
|||
src/models/identity_traceability.c
|
||||
$(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS)
|
||||
|
||||
all: $(TARGET)
|
||||
# CONTRACT: le produit utilisateur est le poste Web local. La cible principale
|
||||
# construit le bridge C qui conserve les services, SQLite et le JobStore V4,
|
||||
# sans lier un renderer de bureau historique.
|
||||
all: $(LOCAL_JOBS)
|
||||
|
||||
.PHONY: core-graph-demo eml-graph-demo local-toolkit-demo
|
||||
core-graph-demo: $(CORE_GRAPH_DEMO)
|
||||
|
|
@ -1368,7 +1445,7 @@ $(TEST_INVESTIGATION_GRAPH_LOAD_TASK): \
|
|||
$(CC) $(INVESTIGATION_GRAPH_LOAD_TASK_TEST_CFLAGS) $^ -o $@ \
|
||||
$(TEST_LDFLAGS) -lsqlite3
|
||||
|
||||
test: \
|
||||
test-gtk-legacy: \
|
||||
$(TEST_NODE) \
|
||||
$(TEST_TREE_MODEL) \
|
||||
$(TEST_TREE_BUILDER) \
|
||||
|
|
@ -1463,6 +1540,10 @@ test: \
|
|||
$(TEST_LOCAL_TOOL_RUNNER) \
|
||||
$(TEST_LOCAL_CAPABILITY_REGISTRY) \
|
||||
$(TEST_LOCAL_JOB_STORE) \
|
||||
$(TEST_RESEARCH_STORE) \
|
||||
$(TEST_RESEARCH_POLICY) \
|
||||
$(TEST_RESEARCH_TRANSPORT) \
|
||||
$(TEST_RESEARCH_ADAPTERS) \
|
||||
$(TEST_LOCAL_CORRELATION) \
|
||||
$(TEST_LOCAL_PLANNER) \
|
||||
$(TEST_LOCAL_REPORT) \
|
||||
|
|
@ -1578,6 +1659,10 @@ test: \
|
|||
@$(TEST_LOCAL_TOOL_RUNNER)
|
||||
@$(TEST_LOCAL_CAPABILITY_REGISTRY)
|
||||
@$(TEST_LOCAL_JOB_STORE)
|
||||
@$(TEST_RESEARCH_STORE)
|
||||
@$(TEST_RESEARCH_POLICY)
|
||||
@$(TEST_RESEARCH_TRANSPORT)
|
||||
@$(TEST_RESEARCH_ADAPTERS)
|
||||
@$(TEST_LOCAL_CORRELATION)
|
||||
@$(TEST_LOCAL_REPORT)
|
||||
@$(TEST_OBSERVATION_REVIEW_SERVICE)
|
||||
|
|
@ -1601,6 +1686,24 @@ test: \
|
|||
%.o: %.c
|
||||
$(CC) $(CFLAGS) -c $< -o $@
|
||||
|
||||
# CONTRACT: la suite canonique valide les services restant dans le produit
|
||||
# Web-only. Les anciennes assertions de widgets GTK sont retirées avec leur
|
||||
# renderer, sans retirer les tests C de persistance, graphes, jobs et policy.
|
||||
test: $(TEST_LOCAL_JOB_STORE) $(TEST_RESEARCH_STORE) $(TEST_RESEARCH_POLICY) \
|
||||
$(TEST_RESEARCH_TRANSPORT) $(TEST_RESEARCH_ADAPTERS) \
|
||||
$(TEST_SCHEMA_V21) $(TEST_FINANCIAL_DAO) $(TEST_FINANCIAL_FOUNDATION) \
|
||||
$(TEST_CORE_GRAPH) $(LOCAL_JOBS_TEST)
|
||||
@./$(TEST_LOCAL_JOB_STORE)
|
||||
@./$(TEST_RESEARCH_STORE)
|
||||
@./$(TEST_RESEARCH_POLICY)
|
||||
@./$(TEST_RESEARCH_TRANSPORT)
|
||||
@./$(TEST_RESEARCH_ADAPTERS)
|
||||
@./$(TEST_SCHEMA_V21)
|
||||
@./$(TEST_FINANCIAL_DAO)
|
||||
@./$(TEST_FINANCIAL_FOUNDATION)
|
||||
@./$(TEST_CORE_GRAPH)
|
||||
@echo "Tous les tests Web-only C sont valides."
|
||||
|
||||
run: $(TARGET)
|
||||
./$(TARGET)
|
||||
|
||||
|
|
|
|||
56
README.md
56
README.md
|
|
@ -3,6 +3,11 @@
|
|||
Le parcours `CURRENT` de jobs locaux persistants et de reprise J5 est documenté
|
||||
dans [docs/architecture/LOCAL_JOBS_RECOVERY.md](docs/architecture/LOCAL_JOBS_RECOVERY.md).
|
||||
|
||||
La [recherche OSINT assistée V1](docs/architecture/OSINT_ASSISTED_RESEARCH.md)
|
||||
ajoute au JobStore V4 des plans, grants et reçus de policy distincts, validés
|
||||
uniquement avec des fixtures et un fournisseur loopback `SPECIMEN`. Aucun
|
||||
fournisseur public n'est configuré ou qualifié ; V20 et V21 ne sont pas modifiés.
|
||||
|
||||
Le premier poste Web local pilotable J6 est documenté dans
|
||||
[docs/architecture/WEB_WORKSPACE_CONTROL.md](docs/architecture/WEB_WORKSPACE_CONTROL.md).
|
||||
|
||||
|
|
@ -10,10 +15,11 @@ Le premier lot J7 ajoute un index C recalculable et des rapprochements locaux
|
|||
explicables pour les e-mails, domaines et IP observés :
|
||||
[pivots locaux et corrélation](docs/architecture/LOCAL_PIVOTS_CORRELATION.md).
|
||||
|
||||
Le [poste Web local](docs/ui/WEB_WORKBENCH.md) rassemble désormais le graphe,
|
||||
l’inspecteur contextuel, les tâches, le planner J8 et les rapports J9 dans une
|
||||
surface `SPECIMEN` responsive. Cette disponibilité locale ne change ni le
|
||||
statut de production, ni l’interdiction d’utiliser une enquête réelle.
|
||||
Le [poste Web local](docs/ui/WEB_WORKBENCH.md) rassemble désormais une
|
||||
bibliothèque d’enquêtes explicitement choisie, le graphe, l’inspecteur
|
||||
contextuel, les tâches, le planner J8 et les rapports J9. Cette disponibilité
|
||||
locale ne change ni le statut de production, ni l’interdiction d’utiliser une
|
||||
enquête réelle pendant les validations de développement.
|
||||
|
||||
Le parcours local expérimental d’import et de revue est détaillé dans
|
||||
[le contrat d’espace local](docs/architecture/LOCAL_WORKSPACE_IMPORT.md) :
|
||||
|
|
@ -66,14 +72,14 @@ données. Labfy vise à relier leurs résultats dans une enquête cohérente :
|
|||
La cible de développement est **v0.1.0**. Le projet reste en développement actif
|
||||
et n'est pas prêt pour un usage opérationnel en production.
|
||||
|
||||
**Interface GTK conservée ; poste Web local `SPECIMEN` disponible.** La
|
||||
fondation v0.1.0 reste en développement : le poste Web réutilise le moteur de
|
||||
jobs persistant existant, sans constituer une interface de production ni une
|
||||
autonomie générale.
|
||||
**Web est l’interface utilisateur CURRENT ; GTK est supprimé.** La fondation
|
||||
v0.1.0 reste en développement : le poste local réutilise le moteur de jobs
|
||||
persistant, la bibliothèque explicitement choisie et les capabilities C, sans
|
||||
constituer une autonomie générale ni un service distant.
|
||||
|
||||
### Disponible aujourd'hui
|
||||
|
||||
L'application actuelle est une application de bureau GTK4 en C17. Elle fournit
|
||||
Le produit actuel est un poste Web local-first devant le cœur C17. Il fournit
|
||||
notamment :
|
||||
|
||||
- création et ouverture d'enquêtes autonomes avec SQLite versionné ;
|
||||
|
|
@ -206,10 +212,11 @@ effectuées par des adapters, sous contrôle des politiques applicables.
|
|||
|
||||
## Interface et expérience cible
|
||||
|
||||
GTK demeure temporairement l'interface fonctionnelle existante. La future
|
||||
interface principale sera Web, avec le graphe comme espace de travail central.
|
||||
Le protocole local, le mécanisme d'événements et le framework frontend ne sont
|
||||
pas encore sélectionnés.
|
||||
Le Web est l’interface fonctionnelle unique, avec le graphe comme espace de
|
||||
travail central. Sa coque organise **Agent | Graphe | Activité**, complétée par
|
||||
un drawer transversal de détails, provenance, timeline, recherches,
|
||||
observations, finance, rapports et tâches. Qwen local, tool calling et le
|
||||
governor AMD/RAM/swap restent `TARGET`.
|
||||
|
||||
La cible comprend :
|
||||
|
||||
|
|
@ -277,7 +284,7 @@ Les dépendances et procédures Arch Linux et Ubuntu sont décrites dans
|
|||
```bash
|
||||
make -j8
|
||||
make test
|
||||
make run
|
||||
tools/labfy
|
||||
```
|
||||
|
||||
La démonstration cœur/Web synthétique se lance séparément :
|
||||
|
|
@ -292,8 +299,23 @@ python3 run_core_demo.py --port 8765
|
|||
# ou : python3 run_local_toolkit_demo.py --port 8765
|
||||
```
|
||||
|
||||
Le projet exige C17, GTK4, GLib/GIO, SQLite, libheif et Poppler GLib. Plusieurs
|
||||
outils d'analyse sont optionnels et ne sont jamais installés automatiquement.
|
||||
Le projet exige C17, GLib/GIO, SQLite, libheif et Poppler GLib. GTK4 n’est plus
|
||||
une dépendance de compilation. Plusieurs outils d'analyse sont optionnels et
|
||||
ne sont jamais installés automatiquement.
|
||||
outils d'analyse sont optionnels et ne sont jamais installés automatiquement.
|
||||
|
||||
Le poste Web local se lance depuis une bibliothèque explicitement désignée ;
|
||||
il écoute par défaut sur `127.0.0.1:8081` :
|
||||
|
||||
```bash
|
||||
LABFY_LIBRARY=/chemin/vers/la-bibliotheque-locale tools/labfy
|
||||
```
|
||||
|
||||
Le lanceur rejoint ou démarre une instance locale possédée, ouvre le navigateur
|
||||
et établit une session HttpOnly sans code manuel ni jeton dans l’URL. Les
|
||||
contrats de stockage local et de session sont décrits dans
|
||||
[le contrôle du poste Web](docs/architecture/WEB_WORKSPACE_CONTROL.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
|
|
@ -304,7 +326,9 @@ outils d'analyse sont optionnels et ne sont jamais installés automatiquement.
|
|||
- [Décisions architecturales](docs/architecture/decisions/0001-web-local-first.md)
|
||||
- [Toolkit et statuts d'intégration](docs/osint/TOOLKIT.md)
|
||||
- [Toolkit local et runner borné J4](docs/architecture/LOCAL_TOOLKIT_RUNNER.md)
|
||||
- [Recherche OSINT assistée V1 en laboratoire](docs/architecture/OSINT_ASSISTED_RESEARCH.md)
|
||||
- [Design system cible](docs/ui/DESIGN_SYSTEM.md)
|
||||
- [Matrice de parité Web / GTK](docs/ui/WEB_GTK_PARITY.md)
|
||||
- [Sécurité, scope et secrets](docs/security/SECURITY_MODEL.md)
|
||||
- [Roadmap canonique](docs/ROADMAP.md)
|
||||
- [Backlog préparé](docs/BACKLOG.md)
|
||||
|
|
@ -368,7 +392,7 @@ explicitement autorisée :
|
|||
make web-workspace-local WORKSPACE=/tmp/labfy-local-workspace
|
||||
```
|
||||
|
||||
Après saisie du code éphémère, créer l’enquête puis utiliser **Ajouter des
|
||||
Le workbench s’ouvre directement ; créer l’enquête puis utiliser **Ajouter des
|
||||
preuves** pour sélectionner des EML, PNG ou JPEG (4 Mio par fichier). La
|
||||
réception, la confirmation, le planner, les analyses, le graphe et les rapports
|
||||
restent séparés. La réouverture reprend le même UUID sans exemple ni duplication.
|
||||
|
|
|
|||
|
|
@ -8,10 +8,19 @@ Le contrôle navigateur local sur espace exclusivement synthétique est décrit
|
|||
dans [WEB_WORKSPACE_CONTROL.md](architecture/WEB_WORKSPACE_CONTROL.md). Cette
|
||||
surface mutationnelle J6 est distincte des démonstrations read-only J2–J5.
|
||||
|
||||
La [recherche OSINT assistée V1](architecture/OSINT_ASSISTED_RESEARCH.md) est
|
||||
`CURRENT` dans le worktree uniquement pour son parcours de laboratoire : le
|
||||
JobStore V4 est additif à V3, les grants sont explicites et aucun fournisseur
|
||||
public n'est configuré ou qualifié. Elle ne modifie ni V20 ni V21.
|
||||
|
||||
> **Statut :** direction canonique v0.1.0
|
||||
> **Dernière mise à jour :** 2026-09-25
|
||||
> **Dernière mise à jour :** 2026-09-27
|
||||
> **Portée :** architecture actuelle et cible Labfy V2
|
||||
|
||||
> **Interface CURRENT :** le Web local est l'unique interface utilisateur ;
|
||||
> GTK et ses tests/widgets de renderer sont retirés. Le cœur C17, SQLite, les
|
||||
> services, capabilities et JobStore V4 n'en dépendent pas.
|
||||
|
||||
## 1. Objet et niveaux de maturité
|
||||
|
||||
Labfy Investigation est un environnement local-first d'investigation numérique
|
||||
|
|
@ -123,11 +132,11 @@ une hypothèse. Il ne devient pas silencieusement une identité confirmée.
|
|||
|
||||
## 3. Architecture actuelle — CURRENT
|
||||
|
||||
L'application existante est principalement écrite en C17 avec GTK4, GLib/GIO
|
||||
Le produit actuel est principalement écrit en C17 avec GLib/GIO
|
||||
et SQLite.
|
||||
|
||||
```text
|
||||
GTK views/widgets
|
||||
Web frontend local
|
||||
│ intentions et affichage
|
||||
Application / services / tasks
|
||||
│ orchestration
|
||||
|
|
@ -154,7 +163,7 @@ Le socle comprend déjà :
|
|||
|
||||
- enquêtes autonomes et base versionnée ;
|
||||
- preuves, extractions, entités et relations ;
|
||||
- graphe interactif GTK et positions persistantes ;
|
||||
- graphe Web interactif et positions persistantes ;
|
||||
- tâches asynchrones en mémoire ;
|
||||
- registre initial d'outils ;
|
||||
- premiers parcours DNS et documentaires ;
|
||||
|
|
@ -169,6 +178,11 @@ Le socle comprend déjà :
|
|||
extraction et observations `proposed` sont publiés atomiquement en V20, puis
|
||||
projetés en snapshot v3 sans reconstruction dans le pont Web ; voir
|
||||
[EML_ANALYSIS_TO_GRAPH.md](architecture/EML_ANALYSIS_TO_GRAPH.md).
|
||||
- poste Web local contrôlé : une instance loopback possède une bibliothèque
|
||||
explicitement choisie et ne rend active qu'une enquête à la fois. Le bridge
|
||||
C reste le créateur des enquêtes, le lecteur des projections et l'unique
|
||||
accès aux stockages métier ; voir
|
||||
[WEB_WORKSPACE_CONTROL.md](architecture/WEB_WORKSPACE_CONTROL.md).
|
||||
|
||||
Ces composants doivent être réutilisés ou migrés. La direction V2 n'autorise
|
||||
pas une réécriture générale du cœur fonctionnel.
|
||||
|
|
@ -203,7 +217,7 @@ pas une réécriture générale du cœur fonctionnel.
|
|||
SQLite
|
||||
```
|
||||
|
||||
La future interface principale est Web. GTK reste l'interface fonctionnelle
|
||||
L'interface principale actuelle est Web. GTK est supprimé ;
|
||||
temporaire et ne sera pas supprimé avant qu'un remplacement validé couvre les
|
||||
parcours nécessaires.
|
||||
|
||||
|
|
@ -591,9 +605,20 @@ analyse approuvée et rapport.
|
|||
## 17. Espace local, revue et projections — CURRENT expérimental
|
||||
|
||||
Le parcours local est limité à une instance loopback et à des validations sur
|
||||
fixtures `SPECIMEN`. Il ne change pas le statut de la surface Web : GTK reste
|
||||
le parcours fonctionnel actuel et le serveur, son packaging ainsi que le choix
|
||||
du renderer de production restent `UNDECIDED`.
|
||||
fixtures `SPECIMEN`. Son lanceur peut ouvrir une bibliothèque locale
|
||||
explicitement choisie, en créer les enquêtes par le bridge C et n'en active
|
||||
qu'une seule dans une instance. Il ne rouvre pas implicitement l'enquête d'une
|
||||
session précédente. Cette surface ne change pas son statut : le Web reste le
|
||||
parcours fonctionnel actuel et le serveur, son packaging ainsi que le choix du
|
||||
renderer de production restent `UNDECIDED`.
|
||||
|
||||
Le runtime métier demeure V20 et le JobStore opérationnel demeure V3 ; le
|
||||
poste Web ne migre ni ne modifie la tranche financière V21. L'état d'instance
|
||||
et le secret d'amorçage sont séparés dans le runtime privé XDG (avec un repli
|
||||
privé propre à l'UID lorsque `XDG_RUNTIME_DIR` est absent) ; la configuration
|
||||
durable est placée dans l'état XDG privé. La bibliothèque est une destination
|
||||
locale fournie explicitement au lancement : elle n'est pas déduite d'une
|
||||
dernière enquête ouverte.
|
||||
|
||||
Après la publication d'une preuve V20, le bridge Web demande au cœur C un
|
||||
aperçu EML, PNG ou JPEG. L'original, son chemin et son répertoire ne sont jamais
|
||||
|
|
@ -631,3 +656,25 @@ signaux exploratoires ; ils ne prouvent ni identité ni relation métier.
|
|||
Le durcissement P01–P06 couvre le cycle de vie des imports, les réservations,
|
||||
le rejeu, les états terminaux et le framing HTTP du corps. Il ne transforme pas
|
||||
le protocole local en service distant ni en garantie de production.
|
||||
|
||||
## 18. Recherche OSINT assistée V1 — CURRENT de laboratoire
|
||||
|
||||
Le JobStore V4 conserve le worker mono-propriétaire et ajoute des objets
|
||||
distincts de `local_plan.v1` et de session : plan de recherche, action,
|
||||
`ScopeGrant`, campagne, résultat et reçu de policy. Les admissions sont
|
||||
idempotentes et atomiques ; la policy expose les refus d'exclusion, révocation,
|
||||
expiration, identité d'enquête/plan/action ou budget. Une seed dérivée, une
|
||||
redirection, un CNAME ou une URL nouvelle ne propage jamais le scope.
|
||||
|
||||
Le transport commun demeure indisponible sans backend lié. La variante libcurl
|
||||
est validée seulement contre le fournisseur loopback `SPECIMEN` du harness et
|
||||
rejette les adresses non publiques ; elle ne qualifie aucun fournisseur public
|
||||
ni configuration de production. Les adapters DNS, RDAP, CDX, Brave, SearXNG et
|
||||
page normalisent des fixtures bornées ; une `next_piste` demande toujours une
|
||||
nouvelle préparation et décision humaine.
|
||||
|
||||
Le panneau Web local prépare un plan, permet l'approbation ou le refus explicite
|
||||
des actions, puis admet une campagne en deux vagues séparées. Les sessions et
|
||||
CSRF Web restent hors des contrats durables ; les tests synthétiques vérifient
|
||||
l'isolation des espaces A/B. Le détail canonique, les procédures de harness et
|
||||
les limites sont dans [OSINT_ASSISTED_RESEARCH.md](architecture/OSINT_ASSISTED_RESEARCH.md).
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ n’est persisté.
|
|||
| Vidéo GTK | `gst-plugins-base`, `gst-plugins-good` | `gstreamer1.0-plugins-base`, `gstreamer1.0-plugins-good` |
|
||||
|
||||
Les codecs vidéo supplémentaires restent optionnels.
|
||||
Le Makefile vérifie explicitement GTK4, SQLite, libheif, Poppler GLib et
|
||||
Le Makefile vérifie explicitement GLib/GIO, SQLite, libheif, Poppler GLib et
|
||||
JSON-GLib avant
|
||||
compilation et renvoie vers ce document si une dépendance manque.
|
||||
|
||||
|
|
@ -54,6 +54,7 @@ export PATH="$HOME/.local/share/labfy-osint/bin:$PATH"
|
|||
| Résolution réseau | `host` | `sudo pacman -S bind` | `sudo apt install bind9-host` |
|
||||
| WHOIS | `whois` | `sudo pacman -S whois` | `sudo apt install whois` |
|
||||
| Requêtes HTTPS | `curl` | `sudo pacman -S curl` | `sudo apt install curl` |
|
||||
| Transport HTTP C de recherche (harness V1) | `libcurl` | `sudo pacman -S curl` | `sudo apt install libcurl4-openssl-dev` |
|
||||
| Certificats/TLS | `openssl` | `sudo pacman -S openssl` | `sudo apt install openssl` |
|
||||
| Récupération PDF | `qpdf` | `sudo pacman -S qpdf` | `sudo apt install qpdf` |
|
||||
| Audit de mot de passe PDF | `john`, `pdf2john` | `sudo pacman -S john` | `sudo apt install john` |
|
||||
|
|
@ -131,3 +132,21 @@ vérifiée avec `command -v`, puis avec son option de version (`--version` ou
|
|||
L’import d’espace local n’ajoute aucune dépendance : le transport borné utilise
|
||||
la bibliothèque HTTP Python existante, et la publication/empreinte réutilise le
|
||||
cœur C/GLib. Firefox et `puppeteer-core` restent nécessaires au scénario Web.
|
||||
|
||||
Le poste Web local requiert également l'interpréteur `python3` déjà utilisé par
|
||||
les lanceurs du prototype. Il ne requiert ni serveur HTTP système, ni base de
|
||||
données distincte, ni installation automatique. Ses fichiers d'instance et de
|
||||
configuration suivent XDG (`XDG_RUNTIME_DIR`, `XDG_STATE_HOME`) ; la
|
||||
bibliothèque d'enquêtes est une destination locale explicitement fournie par
|
||||
`LIBRARY`, et non une dépendance à installer.
|
||||
|
||||
Le harness de recherche OSINT assistée V1 utilise libcurl pour ses tests de
|
||||
transport avec un fournisseur loopback `SPECIMEN`. Cette dépendance ne
|
||||
configure, ne qualifie et ne contacte aucun fournisseur public ; les
|
||||
fournisseurs externes restent indisponibles tant que leur configuration et
|
||||
leur autorisation ne sont pas établies par une tranche dédiée.
|
||||
|
||||
GTK4 n’est plus une dépendance de Labfy : le build Web-only exige GLib/GIO,
|
||||
SQLite, libheif, Poppler GLib, JSON-GLib, Python 3 et Firefox/`puppeteer-core`
|
||||
pour les validations visuelles. Aucun environnement Wayland, GSK_RENDERER ou
|
||||
display GTK n’est requis par la suite canonique.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Documentation de Labfy Investigation
|
||||
|
||||
> **Version cible :** v0.1.0 Foundation, en préparation et non publiée
|
||||
> **Interface actuelle :** GTK4
|
||||
> **Interface actuelle :** Web local-first ; GTK est retiré du produit.
|
||||
> **Interface cible :** Web local-first ; prototype expérimental J2/J3 présent
|
||||
|
||||
Cette page sépare l'usage du logiciel actuel, la direction acceptée et les
|
||||
|
|
@ -16,8 +16,14 @@ déjà disponible.
|
|||
- [architecture de la base](database/DATABASE_ARCHITECTURE.md) et
|
||||
[audit courant du schéma](database/SCHEMA_AUDIT_CURRENT.md) ;
|
||||
- [test manuel du pivot EML](testing/EML_PIVOT_MANUAL_TEST.md).
|
||||
- [poste Web local contrôlé](architecture/WEB_WORKSPACE_CONTROL.md) et
|
||||
[guide du poste Web](ui/WEB_WORKBENCH.md).
|
||||
- [recherche OSINT assistée V1 en laboratoire](architecture/OSINT_ASSISTED_RESEARCH.md) :
|
||||
JobStore V4 additif, grants explicites et parcours Web synthétique.
|
||||
|
||||
Les guides GTK restent applicables tant que l'interface actuelle est maintenue.
|
||||
Les guides GTK archivés décrivent des parcours retirés ; ils ne sont plus des
|
||||
instructions `CURRENT`. Le poste Web local `CURRENT` remplace le renderer GTK et décide le
|
||||
serveur, le renderer ou le packaging de production.
|
||||
|
||||
## Direction v0.1.0 — TARGET
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ construction sans démarrer la refonte fonctionnelle.
|
|||
**Dépendances.** État réel du dépôt et préservation des travaux locaux, dont la
|
||||
tranche financière V21 indépendante.
|
||||
|
||||
> **Interface CURRENT local :** la tranche Web finale a remplacé GTK par une
|
||||
> surface locale Agent | Graphe | Activité + drawer. Le raccord Qwen local,
|
||||
> tool calling et resource governor AMD/RAM/swap restent `TARGET`.
|
||||
|
||||
**Livrables.** README, bannière et source éditable, index documentaire,
|
||||
architecture canonique, contrat du graphe, ADR Web/graph-first/polyglotte,
|
||||
Toolkit, sécurité, design system, roadmap et backlog préparé.
|
||||
|
|
|
|||
163
docs/architecture/OSINT_ASSISTED_RESEARCH.md
Normal file
163
docs/architecture/OSINT_ASSISTED_RESEARCH.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# Recherche OSINT assistée V1 — contrat de laboratoire
|
||||
|
||||
> **Statut :** `CURRENT` dans le worktree, validation exclusivement synthétique
|
||||
> **Portée :** persistance V4, décisions de scope et parcours Web local de
|
||||
> recherche assistée ; pas une qualification de fournisseur public
|
||||
|
||||
## 1. Limites de ce qui est livré
|
||||
|
||||
La recherche assistée V1 ajoute une persistance opérationnelle au `JobStore`
|
||||
local. La migration est additive de V3 vers V4 : elle conserve les objets et
|
||||
contrats de jobs locaux V3. Elle ne remplace pas la base métier V20 et ne
|
||||
modifie pas la tranche financière V21.
|
||||
|
||||
Le `JobStore` reste servi par son worker mono-propriétaire existant. Les
|
||||
opérations de recherche n'introduisent ni second ordonnanceur ni exécution
|
||||
concurrente implicite. L'admission des objets de recherche ne démarre pas, à
|
||||
elle seule, une campagne.
|
||||
|
||||
Cette tranche est qualifiée uniquement par des fixtures `SPECIMEN`, des
|
||||
réponses injectées et un fournisseur de laboratoire loopback explicitement
|
||||
configuré par le harness. Aucun fournisseur public n'est configuré ou qualifié
|
||||
par ce contrat. Une absence de configuration ou d'autorisation de stockage
|
||||
rend l'adapter concerné indisponible ; elle ne déclenche ni installation, ni
|
||||
appel externe, ni repli vers un endpoint implicite.
|
||||
|
||||
## 2. Contrats persistants distincts
|
||||
|
||||
Les contrats versionnés de recherche assistée sont distincts des contrats
|
||||
`labfy.local_plan.v1` et de session Web. Un cookie, un CSRF ou l'état de
|
||||
l'interface ne font pas partie de leur contenu durable.
|
||||
|
||||
| Objet | Contrat | Rôle durable |
|
||||
| --- | --- | --- |
|
||||
| `ResearchPlan` | `labfy.research_plan.v1` | intention bornée, seeds, actions exactes et empreintes d'entrée |
|
||||
| `ResearchAction` | composant du plan | capability, fournisseur, endpoint, sujet, divulgation, classe de contact et plafonds |
|
||||
| `ScopeGrant` | `labfy.scope_grant.v1` | décision liée à une enquête et à un plan, actions sélectionnées, exclusions, expiration et budgets |
|
||||
| `ResearchCampaign` | `labfy.research_campaign.v1` | identité idempotente d'une campagne admise ; elle ne vaut pas démarrage automatique |
|
||||
| `ResearchResult` | `labfy.research_result.v1` | résultat brut référencé par chemin relatif et empreinte de contenu |
|
||||
| `ResearchReceipt` | `labfy.research_receipt.v1` | décision de policy associée au résultat, à l'action, au grant et à la campagne |
|
||||
|
||||
L'admission est atomique : une même clé d'idempotence et le même contenu
|
||||
retournent l'objet durable existant ; une même clé et un contenu différent sont
|
||||
en conflit. Un plan ou un grant invalide ne laisse pas d'écriture partielle.
|
||||
Un résultat ne peut pas être publié sans reçu visant une action explicitement
|
||||
sélectionnée dans le grant de sa campagne.
|
||||
|
||||
## 3. Scope, policy et destinations
|
||||
|
||||
Avant tout contact, une décision est évaluée sur l'enquête, l'empreinte du
|
||||
plan, l'action exacte, le grant et les budgets demandés. Les motifs observables
|
||||
incluent notamment l'exclusion, la révocation, l'expiration, une enquête ou un
|
||||
plan différent, une action non sélectionnée ou altérée, et un budget dépassé.
|
||||
La révocation est durable et ne supprime pas l'historique du grant.
|
||||
|
||||
La validation HTTP compare exactement le sujet, l'endpoint, le port et la
|
||||
sélection. Le profil réseau est une allowlist d'endpoints et de ports, jamais
|
||||
une règle par suffixe. Les adresses non publiquement routables sont refusées
|
||||
par le contrat du transport de production. Une redirection, un CNAME, une URL
|
||||
nouvelle ou une seed dérivée ne propage jamais le scope : chacun constitue une
|
||||
nouvelle destination à décider explicitement.
|
||||
|
||||
Le transport commun reste sans backend réseau lié et retourne donc
|
||||
`UNAVAILABLE` sans contact. Son contrat interdit proxy, cookies, `netrc`,
|
||||
authentification ambiante, redirection automatique et résolution fournie par
|
||||
l'appelant. La variante libcurl est testée uniquement avec le routage de
|
||||
fixture vers loopback ; son callback réseau refuse les adresses non publiques.
|
||||
Les préfixes privés, locaux, documentaires et de benchmark ne sont pas des
|
||||
adresses publiques. Les plafonds du corps compressé reçu sur le fil, du corps
|
||||
décompressé et des en-têtes sont appliqués séparément, y compris sans
|
||||
`Content-Length` et avec un transfert chunked.
|
||||
Cela ne qualifie aucune configuration de production ni aucun fournisseur
|
||||
externe.
|
||||
|
||||
## 4. Adapters normalisateurs — CURRENT en laboratoire
|
||||
|
||||
Les adapters suivants normalisent des données de fixture et préservent leurs
|
||||
limites de périmètre :
|
||||
|
||||
- DNS : types bornés A, AAAA, CNAME, MX, NS, TXT, SOA et PTR ; ni `ANY`, ni
|
||||
AXFR, ni trace n'appartiennent au type public. Un cycle CNAME est signalé et
|
||||
ne crée pas de piste suivante implicite.
|
||||
- RDAP : sélection du service depuis un bootstrap versionné et borné ; le
|
||||
bootstrap routé ne vaut jamais scope pour le service retourné.
|
||||
- CDX : correspondance d'URL ou d'hôte exact, captures normalisées et curseur
|
||||
explicite ; il n'y a pas d'élargissement de la cible.
|
||||
- Brave et SearXNG : disponibilité explicite (`UNAVAILABLE_CONFIG` ou
|
||||
`UNAVAILABLE_STORAGE_AUTHORIZATION`) tant que l'endpoint, la référence de
|
||||
secret et l'autorisation nécessaires ne sont pas réunis. Aucun secret n'est
|
||||
persisté dans ces contrats.
|
||||
- Page : production de texte inerte borné ; scripts, liens, sous-ressources et
|
||||
pièces jointes ne sont ni exécutés ni suivis.
|
||||
|
||||
Ces résultats peuvent indiquer une `next_piste`, qui reste une information à
|
||||
préparer et approuver ; elle ne confère aucune autorisation dérivée.
|
||||
|
||||
## 5. Publication et provenance C
|
||||
|
||||
Le cœur C sépare la décision, l'artefact brut, le résultat et le reçu. Le
|
||||
résultat conserve un chemin relatif d'artefact et son SHA-256 ; la publication
|
||||
atomique exige le reçu de policy correspondant. Cette provenance permet de
|
||||
revenir au grant, à l'action et à la campagne, sans assimiler un contenu reçu
|
||||
à une identité confirmée ou à une autorisation de poursuivre.
|
||||
|
||||
Les résultats de laboratoire du parcours Web sont stockés comme artefacts
|
||||
privés de l'espace de travail de test. Le bridge expose un snapshot de
|
||||
recherche, pas un accès arbitraire au répertoire d'artefacts ni une base de
|
||||
provenance indépendante du cœur C.
|
||||
|
||||
La publication fichier/SQLite utilise un journal privé créé avant le renommage
|
||||
final. Il lie l'identité déterministe du résultat à l'empreinte attendue. Si le
|
||||
processus s'interrompt après le renommage, le rejeu de la même campagne vérifie
|
||||
ce journal et l'artefact, puis enregistre atomiquement résultat et reçu sans
|
||||
recontacter le fournisseur. Un état possédé incohérent est compensé avant de
|
||||
retourner une erreur. Le journal est supprimé après publication durable ; cette
|
||||
reprise n'ajoute ni table ni numéro de schéma.
|
||||
|
||||
## 6. Poste Web local — préparation et décision humaine
|
||||
|
||||
Le panneau de recherche du poste Web local prépare un plan depuis une
|
||||
sélection et une révision de snapshot précises. L'interface présente les
|
||||
actions et leurs divulgations, puis exige une décision explicite :
|
||||
|
||||
1. préparer un plan ;
|
||||
2. sélectionner ou refuser les actions pour former le `ScopeGrant` ;
|
||||
3. admettre une campagne pour les seules actions accordées ;
|
||||
4. préparer et approuver séparément une seconde vague éventuelle.
|
||||
|
||||
L'action refusée reste refusée ; elle ne devient pas admissible au lancement
|
||||
d'une autre action. La révocation du grant est une mutation explicite et
|
||||
durable. Une révision de sélection périmée, un identifiant étranger ou une
|
||||
forme de requête invalide sont refusés.
|
||||
|
||||
Les sessions, cookies et CSRF restent des contrôles de la surface Web et sont
|
||||
distincts des contrats ci-dessus. Les validations couvrent l'isolation de deux
|
||||
espaces de travail synthétiques A/B : un plan, grant, campagne ou résultat de
|
||||
l'espace A n'est pas visible ni réutilisable dans l'espace B.
|
||||
|
||||
## 7. Procédure de validation autorisée
|
||||
|
||||
Les seuls parcours documentés ici utilisent les harnesses synthétiques du
|
||||
dépôt et leurs répertoires temporaires. Les contrôles Web dédiés sont :
|
||||
|
||||
```sh
|
||||
python3 prototypes/web-graph/tests/test_research_web.py
|
||||
node prototypes/web-graph/tests/test_research_browser.mjs
|
||||
```
|
||||
|
||||
Ils démarrent leur fournisseur `SPECIMEN` de loopback à durée de vie bornée et
|
||||
ne nécessitent ni endpoint public, ni jeton, ni enquête réelle. Les tests C
|
||||
`test_research_store`, `test_research_policy`, `test_research_transport` et
|
||||
`test_research_adapters` couvrent respectivement la migration/persistance, les
|
||||
décisions de policy, les bornes du transport/libcurl et les normalisations de
|
||||
fixtures. Leur exécution relève du harness de validation de la tranche ; elle
|
||||
ne constitue pas une autorisation de contacter des services externes.
|
||||
|
||||
## 8. Ce qui reste TARGET ou non qualifié
|
||||
|
||||
Restent hors de ce livrable : qualification et configuration d'un fournisseur
|
||||
public, gestion opérationnelle de secrets, exécution de recherche hors
|
||||
laboratoire, propagation automatique de scope, ordonnancement autonome de
|
||||
recherche, ainsi que le serveur, le protocole d'événements, le renderer et le
|
||||
packaging Web de production. Le Web local ne change pas le statut de GTK comme
|
||||
parcours fonctionnel existant.
|
||||
|
|
@ -1,24 +1,79 @@
|
|||
# Poste de travail Web contrôlé — J6 local
|
||||
|
||||
> État : `CURRENT` pour le seul espace `SPECIMEN` produit par le lanceur J6.
|
||||
> Runtime métier : V20 inchangé. JobStore : V1 inchangé. V21 reste indépendante.
|
||||
> État : `CURRENT` pour le poste Web local contrôlé et sa bibliothèque
|
||||
> explicitement choisie. Runtime métier : V20 inchangé. JobStore : V3 inchangé.
|
||||
> V21 reste indépendante.
|
||||
|
||||
## Parcours
|
||||
|
||||
Une commande prépare ou reprend un espace synthétique, lance l’API loopback et
|
||||
son worker possédé, puis affiche l’URL et un code de session éphémère :
|
||||
La commande principale lance l’API loopback sur une bibliothèque locale
|
||||
explicitement choisie, ouvre le navigateur et affiche directement le workbench :
|
||||
|
||||
```sh
|
||||
make -j8 tools/local-jobs
|
||||
make web-workspace WORKSPACE=/tmp/labfy-j6-specimen
|
||||
make web PORT=8081 LIBRARY=/chemin/vers/la-bibliotheque-locale
|
||||
```
|
||||
|
||||
L’utilisateur ouvre l’URL, saisit le code, sélectionne l’EML ou l’image dans le
|
||||
graphe et choisit « Analyser les en-têtes » ou « Examiner les métadonnées ».
|
||||
L’intention est persistée avant la réponse HTTP `202`. Le worker C exécute le
|
||||
service J3/J4 correspondant, puis C republie atomiquement les snapshots jobs et
|
||||
métier. Le navigateur les relit sans rechargement de page. Pause, reprise, stop
|
||||
et annulation sont disponibles dans le panneau Jobs.
|
||||
L'instance écoute sur `127.0.0.1:8081` dans cet exemple. La navigation racine
|
||||
établit automatiquement une session locale HttpOnly et ouvre directement la
|
||||
bibliothèque, sans formulaire ni code de session. La bibliothèque permet de
|
||||
créer une enquête locale par le bridge C ou d'ouvrir une enquête enregistrée.
|
||||
Une seule enquête peut être active par instance ; une
|
||||
autre ouverture exige le redémarrage du poste local. L'utilisateur sélectionne
|
||||
ensuite l’EML ou l’image dans le graphe et choisit « Analyser les en-têtes » ou
|
||||
« Examiner les métadonnées ». L’intention est persistée avant la réponse HTTP
|
||||
`202`. Le worker C exécute le service J3/J4 correspondant, puis C republie
|
||||
atomiquement les snapshots jobs et métier. Le navigateur les relit sans
|
||||
rechargement de page. Pause, reprise, stop et annulation sont disponibles dans
|
||||
le panneau Jobs.
|
||||
|
||||
Le lanceur gère une seule instance authentifiée. Ses sous-commandes sont :
|
||||
|
||||
```sh
|
||||
python3 prototypes/web-graph/web_app.py start --library /chemin/vers/la-bibliotheque-locale --port 8081
|
||||
python3 prototypes/web-graph/web_app.py status
|
||||
python3 prototypes/web-graph/web_app.py stop
|
||||
python3 prototypes/web-graph/web_app.py code
|
||||
```
|
||||
|
||||
`start` refuse une seconde instance active. `status`, `stop` et `code` valident
|
||||
l'identité du processus et l'état de santé loopback avant d'agir ou de révéler
|
||||
le code éphémère. Ne pas consigner ce code dans des scripts, URLs, journaux ou
|
||||
documents. Le mode `--workspace` reste le parcours ciblé existant ; il ne crée
|
||||
pas une bibliothèque implicite.
|
||||
|
||||
## Bibliothèque, état XDG et limites
|
||||
|
||||
La bibliothèque est privée (`0700`), refuse les liens symboliques et conserve
|
||||
un registre atomique borné à 256 enquêtes. Chaque enquête est un enfant direct
|
||||
identifié par UUID ; le bridge C existant est le seul créateur de son runtime
|
||||
V20 et de son JobStore V3. La création est idempotente pour une même clé et le
|
||||
même titre ; réemployer la clé avec un autre titre est refusé. L'ouverture exige
|
||||
la génération courante du registre afin de ne pas utiliser une projection
|
||||
périmée.
|
||||
|
||||
L'instance place ses verrous, identité et code d'amorçage dans
|
||||
`$XDG_RUNTIME_DIR/labfy-investigation-web/`, ou dans un repli privé par UID
|
||||
sous `/tmp` si `XDG_RUNTIME_DIR` n'est pas défini. Sa configuration durable est
|
||||
dans `$XDG_STATE_HOME/labfy-investigation-web/` (ou le repli XDG usuel). Ces
|
||||
fichiers ne sont ni des données métier, ni une bibliothèque : la destination
|
||||
des enquêtes reste l'argument explicite `LIBRARY`.
|
||||
|
||||
La session a une durée maximale de `3600` secondes (une heure) à compter de chaque connexion
|
||||
réussie, indépendamment de l'heure de démarrage du serveur. Après expiration,
|
||||
le code de l'instance permet une nouvelle authentification : les secrets de
|
||||
session et CSRF sont renouvelés et l'ancien cookie reste invalide. Un code
|
||||
incorrect ou une origine refusée ne renouvelle jamais cette durée.
|
||||
Le premier refus `session_required` reçu par le navigateur arrête ses flux et
|
||||
pollings, invalide les secrets seulement en mémoire et présente une reconnexion
|
||||
explicite. Il ne redirige pas en boucle et ne rejoue ni une mutation refusée ni
|
||||
une admission dont l'issue est inconnue. Les brouillons autorisés restent dans
|
||||
la session du navigateur, tandis que les jobs déjà admis conservent leurs
|
||||
budgets et leur cycle de vie propres.
|
||||
Les limites HTTP et opérationnelles
|
||||
restent opposables : corps JSON de commande de 4 Kio, huit fichiers sélectionnés
|
||||
pour 16 Mio, 4 Mio par réception, 64 Mio de staging, deux réceptions actives et
|
||||
deux générations de rapport simultanées. Une limite, une annulation ou une
|
||||
erreur d'export est un état explicite et ne prouve jamais l'absence de résultat.
|
||||
|
||||
## Décision HTTP et bridge
|
||||
|
||||
|
|
@ -55,7 +110,7 @@ Le serveur écoute exclusivement sur l’autorité annoncée
|
|||
`127.0.0.1:<port>`. Le code d’amorçage et les secrets de session proviennent de
|
||||
`secrets`, ne sont pas placés dans une URL et ne sont pas journalisés par les
|
||||
routes. Après amorçage, le cookie est host-only, `HttpOnly`, `SameSite=Strict`,
|
||||
`Path=/`, limité à une heure. Il n’a pas l’attribut `Secure`, puisque ce mode
|
||||
`Path=/`, avec `Max-Age=3600` secondes (une heure). Il n’a pas l’attribut `Secure`, puisque ce mode
|
||||
testé utilise HTTP loopback et ne revendique pas TLS.
|
||||
|
||||
Lectures opérationnelles et mutations exigent la session. Les mutations exigent
|
||||
|
|
@ -109,8 +164,25 @@ zoom, filtres, repli et positions épinglées restent dans l’état du navigate
|
|||
|
||||
## Limites
|
||||
|
||||
Ce poste est une fondation locale synthétique : pas d’enquête réelle, LAN,
|
||||
TLS, reverse proxy, service permanent, autonomie réseau, recherche OSINT,
|
||||
worker distribué ou mutation de la V21. Les anciens modes J2–J5 restent
|
||||
strictement read-only. `ThreadingHTTPServer` n’est pas présenté comme un serveur
|
||||
Internet ou une frontière de sécurité de production.
|
||||
Ce poste est une fondation locale contrôlée : les validations automatisées
|
||||
emploient exclusivement des fixtures `SPECIMEN`. Il n'autorise ni enquête
|
||||
réelle, ni LAN, TLS, reverse proxy, service permanent, autonomie réseau,
|
||||
recherche OSINT, worker distribué ou mutation de la V21. Les anciens modes
|
||||
J2–J5 restent strictement read-only. `ThreadingHTTPServer` n’est pas présenté
|
||||
comme un serveur Internet ou une frontière de sécurité de production.
|
||||
|
||||
## Validation Web
|
||||
|
||||
La cible `make web-workspace-web-check` construit d'abord les bridges C requis,
|
||||
puis exécute les unités Node, la découverte Python et les parcours navigateur
|
||||
séquentiels. Le runner navigateur classe chaque scénario avec l’un des statuts
|
||||
`MISSING`, `LAUNCH_ERROR`, `EXIT_CODE`, `SIGNALED`, `TIMEOUT`, `INTERRUPTED`
|
||||
ou `SUCCESS`. Sur Linux, il crée et possède le groupe et la session de
|
||||
processus de chaque scénario ; son nettoyage est limité à ce groupe/session et
|
||||
envoie `SIGTERM`, attend une grâce bornée, puis escalade avec `SIGKILL` si
|
||||
nécessaire. Il ne recherche ni ne termine des processus utilisateur.
|
||||
Les régressions emploient un Firefox, des profils, ports, XDG et bibliothèques
|
||||
`SPECIMEN` isolés, notamment pour la connexion tardive, l'expiration et la
|
||||
reconnexion sans redémarrer le serveur. Elles ne constituent pas à elles seules
|
||||
une certification de sécurité, de production, de navigateur ou de données non
|
||||
synthétiques.
|
||||
|
|
|
|||
21
docs/ui/WEB_GTK_PARITY.md
Normal file
21
docs/ui/WEB_GTK_PARITY.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Matrice de parité Web avant retrait GTK
|
||||
|
||||
> État : `CURRENT` local — `LABFY_WEB_FINAL_INTERFACE_AND_REMOVE_GTK_V1`.
|
||||
> Les contrôles utilisent exclusivement les workspaces `SPECIMEN`.
|
||||
|
||||
| Fonction GTK historique | Équivalent Web CURRENT | Test Web | État |
|
||||
| --- | --- | --- | --- |
|
||||
| Graphe, sélection, pan, zoom, focus et retour | SVG interactif central, recherche et actions contextuelles | `test_workbench_browser.mjs` | remplacé |
|
||||
| Positions et zoom persistés | Projection C et positions épinglées conservées au rafraîchissement | `test_workbench_populated_browser.mjs` | remplacé |
|
||||
| Arbre / détails d'entité, preuve et relation | Drawer Détails et inspecteur à onglets | `test_local_workspace_import_browser.mjs` | remplacé |
|
||||
| Provenance et revue | Onglets Provenance et Observations, promotion explicite | `test_j9_browser.mjs` | remplacé |
|
||||
| Activité, tâches et annulation | Drawer Tâches, JobStore V4 et contrôles de file | `test_j8_runtime_browser.mjs` | remplacé |
|
||||
| Actions OSINT contextuelles | Capability → service → job, plan/grant/campagne explicites | `test_research_browser.mjs` | remplacé |
|
||||
| Import, aperçu et analyse de preuve | Réception privée, preview dérivé, analyses C | `test_local_workspace_import_browser.mjs` | remplacé |
|
||||
| Rapports | Prévisualisation et dossier hors ligne vérifiable | `test_j9_browser.mjs` | remplacé |
|
||||
| Dialogues de création GTK et formulaires spécialisés | Parcours non exposés par le bridge Web actuel | aucun | retiré, pas de logique métier supprimée |
|
||||
| Widgets OCR de rendu GTK | Données, correction et provenance conservées dans services/DAO ; renderer retiré | tests C de services conservés | retiré, renderer seulement |
|
||||
|
||||
Les parcours retirés ne constituaient pas une API métier : ils dépendaient du
|
||||
renderer GTK. Les DAO, services, migrations V20, fondation financière V21 et
|
||||
JobStore V4 restent hors de cette suppression.
|
||||
|
|
@ -1,12 +1,23 @@
|
|||
# Poste Web local — guide d’utilisation
|
||||
|
||||
> **Statut CURRENT local :** poste utilisable sur une enquête `SPECIMEN`, au-dessus
|
||||
> des services C et du JobStore V3 existants. Il ne constitue pas une autorisation
|
||||
> d’ouvrir une enquête réelle ni un service distant.
|
||||
> **Statut CURRENT local :** interface utilisateur Web unique, utilisable au-dessus des services C et du
|
||||
> JobStore V4 existants, depuis une bibliothèque d'enquêtes explicitement
|
||||
> choisie. Les validations emploient des fixtures `SPECIMEN`. Il ne constitue
|
||||
> ni une autorisation d’ouvrir une enquête réelle, ni un service distant.
|
||||
|
||||
## Organisation
|
||||
|
||||
Le graphe reste la surface principale. La barre haute porte l’identité de
|
||||
À l'ouverture, le lanceur `tools/labfy` démarre ou rejoint l’instance loopback
|
||||
possédée, ouvre le navigateur et établit une session HttpOnly locale sans code
|
||||
manuel. La bibliothèque affiche les enquêtes locales enregistrées et
|
||||
permet d'en créer une avec un titre explicite. Une enquête ouverte reste la
|
||||
seule enquête active de cette instance ; le poste n'en restaure pas une autre
|
||||
automatiquement. Le titre et la génération de la bibliothèque identifient le
|
||||
contexte affiché, afin qu'une réponse tardive d'une enquête précédente ne le
|
||||
repeuple pas.
|
||||
|
||||
Le graphe reste la surface principale. La coque est **Agent | Graphe |
|
||||
Activité**, plus un tiroir transversal. La barre haute porte l’identité de
|
||||
l’enquête, le statut local et la recherche. La rangée suivante change de
|
||||
projection (`Réseau`, `Preuves`, `Chronologie`, `Infrastructure locale`) sans
|
||||
créer une seconde vérité métier.
|
||||
|
|
@ -82,16 +93,18 @@ Une date sans fuseau ou invalide reste non positionnable.
|
|||
|
||||
## Lancement et limites
|
||||
|
||||
Créer exclusivement une fixture synthétique dans un répertoire temporaire :
|
||||
Lancer le poste local sur une bibliothèque explicitement choisie :
|
||||
|
||||
```bash
|
||||
make -j8 tools/local-jobs
|
||||
tmp_workspace=$(mktemp -d /tmp/labfy-workbench-specimen-XXXXXX)
|
||||
tools/local-jobs init-j7-specimen --workspace "$tmp_workspace"
|
||||
make web-workspace-j9 WORKSPACE="$tmp_workspace"
|
||||
LABFY_LIBRARY=/chemin/vers/la-bibliotheque-locale tools/labfy
|
||||
```
|
||||
|
||||
L’écoute reste loopback, la session est authentifiée et les mutations exigent
|
||||
L’écoute reste loopback, la session est HttpOnly/SameSite et les mutations exigent
|
||||
le jeton CSRF. Le poste n’expose pas le répertoire des preuves et n’installe
|
||||
aucun outil. Les projections vides, erreurs, annulations et limites sont des
|
||||
états explicites ; elles ne prouvent jamais une absence d’information.
|
||||
|
||||
Pour les validations, utiliser seulement une bibliothèque temporaire et des
|
||||
enquêtes `SPECIMEN`. Les commandes d'exploitation `start`, `status`, `stop` et
|
||||
`code`, les répertoires XDG privés et les plafonds de réception sont documentés
|
||||
dans [le contrat du poste Web](../architecture/WEB_WORKSPACE_CONTROL.md).
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file application.h
|
||||
* @brief Interface publique du cycle de vie de l'application.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_APPLICATION_H
|
||||
#define LABFY_INVESTIGATION_APPLICATION_H
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque d'une session d'enquête.
|
||||
*/
|
||||
typedef struct InvestigationSession InvestigationSession;
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque de l'application.
|
||||
*
|
||||
* Les détails GTK sont volontairement masqués au reste du programme.
|
||||
*/
|
||||
typedef struct Application Application;
|
||||
|
||||
/**
|
||||
* @brief Crée et initialise une application Labfy Investigation.
|
||||
*
|
||||
* @return Une nouvelle application, ou NULL en cas d'échec.
|
||||
*/
|
||||
Application *application_new(void);
|
||||
|
||||
/**
|
||||
* @brief Lance la boucle principale de l'application.
|
||||
*
|
||||
* @param application Application à exécuter.
|
||||
* @param argc Nombre d'arguments de la ligne de commande.
|
||||
* @param argv Tableau des arguments de la ligne de commande.
|
||||
*
|
||||
* @return Code de sortie retourné par GTK.
|
||||
*/
|
||||
int application_run(
|
||||
Application *application,
|
||||
int argc,
|
||||
char **argv
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne la session d'enquête actuellement ouverte.
|
||||
*
|
||||
* Le pointeur retourné appartient à l'application.
|
||||
* Le code appelant ne doit pas fermer cette session.
|
||||
*
|
||||
* @param application Application concernée.
|
||||
*
|
||||
* @return La session active, ou NULL si aucune enquête n'est ouverte
|
||||
* ou si application vaut NULL.
|
||||
*/
|
||||
const InvestigationSession *application_get_session(
|
||||
const Application *application
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Libère les ressources possédées par l'application.
|
||||
*
|
||||
* Cette fonction accepte NULL.
|
||||
*
|
||||
* @param application Application à libérer.
|
||||
*/
|
||||
void application_free(
|
||||
Application *application
|
||||
);
|
||||
|
||||
#endif
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/******************************************************************************
|
||||
* @file local_job_store.h
|
||||
* @brief Stockage opérationnel V3 des jobs et plans locaux J5/J8.
|
||||
* @brief Stockage opérationnel V4 des jobs, plans locaux et grants assistés.
|
||||
******************************************************************************/
|
||||
#ifndef LABFY_INVESTIGATION_LOCAL_JOB_STORE_H
|
||||
#define LABFY_INVESTIGATION_LOCAL_JOB_STORE_H
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
#define LOCAL_JOB_STORE_SCHEMA_VERSION 3
|
||||
#define LOCAL_JOB_STORE_SCHEMA_VERSION 4
|
||||
#define LOCAL_JOB_STORE_MAX_JOBS 128U
|
||||
#define LOCAL_JOB_STORE_MAX_ATTEMPTS 3U
|
||||
#define LOCAL_JOB_DIAGNOSTIC_MAX 2048U
|
||||
|
|
|
|||
14
include/core/research_adapter_cdx.h
Normal file
14
include/core/research_adapter_cdx.h
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#ifndef LABFY_INVESTIGATION_RESEARCH_ADAPTER_CDX_H
|
||||
#define LABFY_INVESTIGATION_RESEARCH_ADAPTER_CDX_H
|
||||
#include "core/research_contracts.h"
|
||||
G_BEGIN_DECLS
|
||||
typedef enum { RESEARCH_CDX_EXACT_URL, RESEARCH_CDX_EXACT_HOST } ResearchCdxMatch;
|
||||
typedef struct { const char *original_url; const char *timestamp; const char *digest;
|
||||
guint status_code; } ResearchCdxRow;
|
||||
typedef struct { const ResearchCdxRow *rows; gsize row_count; const char *next_cursor; } ResearchCdxFixture;
|
||||
typedef struct { GPtrArray *captures; char *next_cursor; gboolean next_piste; } ResearchCdxResult;
|
||||
gboolean research_adapter_cdx_normalize(const ResearchAction *action,
|
||||
ResearchCdxMatch match,const ResearchCdxFixture *fixture,ResearchCdxResult *out,GError **error);
|
||||
void research_cdx_result_clear(ResearchCdxResult *result);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
23
include/core/research_adapter_dns.h
Normal file
23
include/core/research_adapter_dns.h
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#ifndef LABFY_INVESTIGATION_RESEARCH_ADAPTER_DNS_H
|
||||
#define LABFY_INVESTIGATION_RESEARCH_ADAPTER_DNS_H
|
||||
#include "core/research_contracts.h"
|
||||
G_BEGIN_DECLS
|
||||
typedef enum { RESEARCH_DNS_A, RESEARCH_DNS_AAAA, RESEARCH_DNS_CNAME,
|
||||
RESEARCH_DNS_MX, RESEARCH_DNS_NS, RESEARCH_DNS_TXT, RESEARCH_DNS_SOA,
|
||||
RESEARCH_DNS_PTR } ResearchDnsType;
|
||||
typedef enum { RESEARCH_DNS_STATUS_OK, RESEARCH_DNS_STATUS_NXDOMAIN,
|
||||
RESEARCH_DNS_STATUS_NODATA, RESEARCH_DNS_STATUS_SERVFAIL,
|
||||
RESEARCH_DNS_STATUS_TIMEOUT } ResearchDnsStatus;
|
||||
typedef struct { ResearchDnsType type; const char *owner; const char *value;
|
||||
guint ttl; guint preference; } ResearchDnsRecord;
|
||||
typedef struct { ResearchDnsStatus status; const ResearchDnsRecord *records;
|
||||
gsize record_count; } ResearchDnsFixture;
|
||||
typedef struct { ResearchDnsStatus status; GPtrArray *records;
|
||||
gboolean cname_cycle; gboolean next_piste; } ResearchDnsResult;
|
||||
/** CONTRACT: ANY, AXFR et trace n'existent pas dans le type public. */
|
||||
gboolean research_adapter_dns_normalize(const ResearchAction *action,
|
||||
ResearchDnsType requested_type, const ResearchDnsFixture *fixture,
|
||||
ResearchDnsResult *out, GError **error);
|
||||
void research_dns_result_clear(ResearchDnsResult *result);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
13
include/core/research_adapter_page.h
Normal file
13
include/core/research_adapter_page.h
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
#ifndef LABFY_INVESTIGATION_RESEARCH_ADAPTER_PAGE_H
|
||||
#define LABFY_INVESTIGATION_RESEARCH_ADAPTER_PAGE_H
|
||||
#include "core/research_contracts.h"
|
||||
G_BEGIN_DECLS
|
||||
typedef struct { const char *content_type; const guint8 *body; gsize body_size; } ResearchPageFixture;
|
||||
typedef struct { char *inert_text; gboolean links_ignored; gboolean attachments_ignored;
|
||||
gboolean next_piste; } ResearchPageResult;
|
||||
/** CONTRACT: produit du texte inerte; aucun script, lien, sous-ressource ou attachment n'est suivi. */
|
||||
gboolean research_adapter_page_normalize(const ResearchAction *action,
|
||||
const ResearchPageFixture *fixture,gsize max_text_bytes,ResearchPageResult *out,GError **error);
|
||||
void research_page_result_clear(ResearchPageResult *result);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
19
include/core/research_adapter_rdap.h
Normal file
19
include/core/research_adapter_rdap.h
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#ifndef LABFY_INVESTIGATION_RESEARCH_ADAPTER_RDAP_H
|
||||
#define LABFY_INVESTIGATION_RESEARCH_ADAPTER_RDAP_H
|
||||
#include "core/research_contracts.h"
|
||||
G_BEGIN_DECLS
|
||||
typedef enum { RESEARCH_RDAP_DOMAIN_SERVICE, RESEARCH_RDAP_IPV4_SERVICE,
|
||||
RESEARCH_RDAP_IPV6_SERVICE } ResearchRdapServiceKind;
|
||||
typedef struct { ResearchRdapServiceKind kind; const char *prefix;
|
||||
const char *base_url; } ResearchRdapBootstrapEntry;
|
||||
typedef struct { const ResearchRdapBootstrapEntry *entries; gsize entry_count;
|
||||
const char *fetched_at; const char *expires_at; const char *etag; } ResearchRdapBootstrap;
|
||||
typedef struct { char *service_url; guint prefix_length; char *cache_fetched_at;
|
||||
char *cache_expires_at; char *cache_etag; gboolean next_piste; } ResearchRdapResult;
|
||||
/** WHY: le bootstrap routé ne vaut jamais autorisation sur l'objet retourné. */
|
||||
gboolean research_adapter_rdap_select(const ResearchAction *action,
|
||||
ResearchRdapServiceKind kind, const ResearchRdapBootstrap *bootstrap,
|
||||
ResearchRdapResult *out, GError **error);
|
||||
void research_rdap_result_clear(ResearchRdapResult *result);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
19
include/core/research_adapter_search.h
Normal file
19
include/core/research_adapter_search.h
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#ifndef LABFY_INVESTIGATION_RESEARCH_ADAPTER_SEARCH_H
|
||||
#define LABFY_INVESTIGATION_RESEARCH_ADAPTER_SEARCH_H
|
||||
#include "core/research_contracts.h"
|
||||
G_BEGIN_DECLS
|
||||
typedef enum { RESEARCH_SEARCH_BRAVE, RESEARCH_SEARCH_SEARXNG } ResearchSearchProvider;
|
||||
typedef enum { RESEARCH_SEARCH_AVAILABLE, RESEARCH_SEARCH_UNAVAILABLE_CONFIG,
|
||||
RESEARCH_SEARCH_UNAVAILABLE_STORAGE_AUTHORIZATION } ResearchSearchAvailability;
|
||||
typedef struct { ResearchSearchProvider provider; const char *endpoint;
|
||||
const char *secret_reference; gboolean storage_authorized; } ResearchSearchConfig;
|
||||
typedef struct { const char *title; const char *url; const char *snippet; } ResearchSearchHit;
|
||||
typedef struct { ResearchSearchAvailability availability; GPtrArray *hits;
|
||||
gboolean next_piste; } ResearchSearchResult;
|
||||
ResearchSearchAvailability research_adapter_search_availability(const ResearchSearchConfig *config);
|
||||
gboolean research_adapter_search_normalize(const ResearchAction *action,
|
||||
const ResearchSearchConfig *config,const ResearchSearchHit *hits,gsize hit_count,
|
||||
ResearchSearchResult *out,GError **error);
|
||||
void research_search_result_clear(ResearchSearchResult *result);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
172
include/core/research_contracts.h
Normal file
172
include/core/research_contracts.h
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/******************************************************************************
|
||||
* @file research_contracts.h
|
||||
* @brief Contrats versionnés de recherche assistée, sans exécution réseau.
|
||||
******************************************************************************/
|
||||
#ifndef LABFY_INVESTIGATION_RESEARCH_CONTRACTS_H
|
||||
#define LABFY_INVESTIGATION_RESEARCH_CONTRACTS_H
|
||||
|
||||
#include <gio/gio.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
#define RESEARCH_PLAN_CONTRACT "labfy.research_plan.v1"
|
||||
#define RESEARCH_GRANT_CONTRACT "labfy.scope_grant.v1"
|
||||
#define RESEARCH_CAMPAIGN_CONTRACT "labfy.research_campaign.v1"
|
||||
#define RESEARCH_RESULT_CONTRACT "labfy.research_result.v1"
|
||||
#define RESEARCH_RECEIPT_CONTRACT "labfy.research_receipt.v1"
|
||||
|
||||
typedef enum {
|
||||
RESEARCH_SEED_DOMAIN,
|
||||
RESEARCH_SEED_IP,
|
||||
RESEARCH_SEED_HTTP_URL,
|
||||
RESEARCH_SEED_EMAIL,
|
||||
RESEARCH_SEED_SEARCH_TERM
|
||||
} ResearchSeedKind;
|
||||
|
||||
typedef enum {
|
||||
RESEARCH_CONTACT_NONE,
|
||||
RESEARCH_CONTACT_THIRD_PARTY,
|
||||
RESEARCH_CONTACT_TARGET
|
||||
} ResearchContactClass;
|
||||
|
||||
typedef struct {
|
||||
ResearchSeedKind kind;
|
||||
const char *subject;
|
||||
} ResearchSeed;
|
||||
|
||||
typedef struct {
|
||||
const char *action_id;
|
||||
const char *capability_id;
|
||||
const char *provider_id;
|
||||
const char *endpoint;
|
||||
const char *subject;
|
||||
ResearchContactClass contact;
|
||||
const char *disclosure;
|
||||
guint max_requests;
|
||||
guint64 max_response_bytes;
|
||||
guint max_active_ms;
|
||||
} ResearchAction;
|
||||
|
||||
typedef struct {
|
||||
const char *contract;
|
||||
const char *plan_id;
|
||||
const char *idempotency_key;
|
||||
const char *content_fingerprint;
|
||||
const char *input_fingerprint;
|
||||
guint64 input_revision;
|
||||
const char *created_at;
|
||||
const ResearchSeed *seeds;
|
||||
gsize seed_count;
|
||||
const ResearchAction *actions;
|
||||
gsize action_count;
|
||||
} ResearchPlan;
|
||||
|
||||
typedef enum {
|
||||
RESEARCH_ACTION_AUTHORIZE,
|
||||
RESEARCH_ACTION_DEFER,
|
||||
RESEARCH_ACTION_REFUSE
|
||||
} ResearchActionDecisionCode;
|
||||
|
||||
typedef struct {
|
||||
const char *action_id;
|
||||
ResearchActionDecisionCode code;
|
||||
} ResearchActionDecision;
|
||||
|
||||
typedef struct {
|
||||
const char *contract;
|
||||
const char *grant_id;
|
||||
const char *investigation_id;
|
||||
const char *idempotency_key;
|
||||
const char *content_fingerprint;
|
||||
const char *plan_fingerprint;
|
||||
const char *created_at;
|
||||
const char *expires_at;
|
||||
const char *const *selected_action_ids;
|
||||
gsize selected_action_count;
|
||||
const char *const *excluded_subjects;
|
||||
gsize excluded_subject_count;
|
||||
guint max_requests;
|
||||
guint64 max_response_bytes;
|
||||
guint max_active_ms;
|
||||
} ScopeGrant;
|
||||
|
||||
typedef struct {
|
||||
const char *contract;
|
||||
const char *campaign_id;
|
||||
const char *investigation_id;
|
||||
const char *grant_id;
|
||||
const char *idempotency_key;
|
||||
const char *content_fingerprint;
|
||||
const char *created_at;
|
||||
} ResearchCampaign;
|
||||
|
||||
typedef struct {
|
||||
const char *contract;
|
||||
const char *result_id;
|
||||
const char *campaign_id;
|
||||
const char *action_id;
|
||||
const char *raw_artifact_relative_path;
|
||||
const char *content_sha256;
|
||||
const char *status;
|
||||
} ResearchResult;
|
||||
|
||||
typedef struct {
|
||||
const char *contract;
|
||||
const char *receipt_id;
|
||||
const char *result_id;
|
||||
const char *campaign_id;
|
||||
const char *grant_id;
|
||||
const char *action_id;
|
||||
const char *decided_at;
|
||||
const char *decision_code;
|
||||
} ResearchReceipt;
|
||||
|
||||
typedef enum {
|
||||
RESEARCH_POLICY_ALLOW,
|
||||
RESEARCH_POLICY_DENY_EXCLUDED,
|
||||
RESEARCH_POLICY_DENY_REVOKED,
|
||||
RESEARCH_POLICY_DENY_EXPIRED,
|
||||
RESEARCH_POLICY_DENY_INVESTIGATION,
|
||||
RESEARCH_POLICY_DENY_PLAN,
|
||||
RESEARCH_POLICY_DENY_ACTION,
|
||||
RESEARCH_POLICY_DENY_ACTION_CONTENT,
|
||||
RESEARCH_POLICY_DENY_BUDGET
|
||||
} ResearchPolicyDecision;
|
||||
|
||||
typedef struct {
|
||||
const char *investigation_id;
|
||||
const char *plan_fingerprint;
|
||||
const ResearchAction *action;
|
||||
const char *now;
|
||||
guint requested_requests;
|
||||
guint64 requested_response_bytes;
|
||||
guint requested_active_ms;
|
||||
} ResearchPolicyRequest;
|
||||
|
||||
/**
|
||||
* Vérifie la forme complète d'un plan avant toute écriture.
|
||||
* CONTRACT: les identités et empreintes sont stables et toutes les actions
|
||||
* sont bornées. INVARIANT: une seed dérivée n'accorde jamais de scope.
|
||||
*/
|
||||
gboolean research_plan_validate(const ResearchPlan *plan, GError **error);
|
||||
|
||||
/**
|
||||
* Vérifie le contenu durable d'un grant, à l'exclusion des cookies/CSRF qui
|
||||
* n'appartiennent pas à ce contrat persistant.
|
||||
*/
|
||||
gboolean research_scope_grant_validate(const ScopeGrant *grant,
|
||||
GError **error);
|
||||
gboolean research_campaign_validate(const ResearchCampaign *campaign,
|
||||
GError **error);
|
||||
gboolean research_result_validate(const ResearchResult *result,
|
||||
GError **error);
|
||||
gboolean research_receipt_validate(const ResearchReceipt *receipt,
|
||||
GError **error);
|
||||
|
||||
const char *research_seed_kind_code(ResearchSeedKind kind);
|
||||
const char *research_contact_class_code(ResearchContactClass contact);
|
||||
const char *research_policy_decision_code(ResearchPolicyDecision decision);
|
||||
const char *research_action_decision_code(ResearchActionDecisionCode decision);
|
||||
|
||||
G_END_DECLS
|
||||
#endif
|
||||
28
include/core/research_http_transport_curl.h
Normal file
28
include/core/research_http_transport_curl.h
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#ifndef LABFY_INVESTIGATION_RESEARCH_HTTP_TRANSPORT_CURL_H
|
||||
#define LABFY_INVESTIGATION_RESEARCH_HTTP_TRANSPORT_CURL_H
|
||||
|
||||
#include "core/research_transport.h"
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
/**
|
||||
* CONTRACT: exécute une requête préalablement validée, sans état ambiant.
|
||||
* INVARIANT: le callback socket refuse toute adresse qui n'est pas publique.
|
||||
*/
|
||||
gboolean research_http_transport_curl_execute(
|
||||
const ResearchTransportRequest *request, ResearchTransportReply *out_reply,
|
||||
GError **error);
|
||||
|
||||
#ifdef RESEARCH_TRANSPORT_ENABLE_TEST_HOOKS
|
||||
/**
|
||||
* TEST-ONLY: route une autorité exacte vers une adresse loopback finie.
|
||||
* Cette fonction n'existe pas dans l'API de production.
|
||||
*/
|
||||
gboolean research_http_transport_curl_execute_fixture(
|
||||
const ResearchTransportRequest *request, const char *authority,
|
||||
const char *loopback_address, ResearchTransportReply *out_reply,
|
||||
GError **error);
|
||||
#endif
|
||||
|
||||
G_END_DECLS
|
||||
#endif
|
||||
55
include/core/research_policy.h
Normal file
55
include/core/research_policy.h
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/******************************************************************************
|
||||
* @file research_policy.h
|
||||
* @brief Policy pure pour les destinations de recherche assistée V1.
|
||||
******************************************************************************/
|
||||
#ifndef LABFY_INVESTIGATION_RESEARCH_POLICY_H
|
||||
#define LABFY_INVESTIGATION_RESEARCH_POLICY_H
|
||||
|
||||
#include "core/research_contracts.h"
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
typedef struct {
|
||||
const guint16 *allowed_ports;
|
||||
gsize allowed_port_count;
|
||||
const char *const *allowed_endpoints;
|
||||
gsize allowed_endpoint_count;
|
||||
} ResearchNetworkProfile;
|
||||
|
||||
typedef struct {
|
||||
char *scheme;
|
||||
char *host;
|
||||
guint16 port;
|
||||
char *path_and_query;
|
||||
} ResearchHttpTarget;
|
||||
|
||||
typedef enum {
|
||||
RESEARCH_REDIRECT_REQUIRES_DECISION,
|
||||
RESEARCH_REDIRECT_INVALID
|
||||
} ResearchRedirectDecision;
|
||||
|
||||
/**
|
||||
* CONTRACT: valide l'action et le grant exacts avant tout contact réseau.
|
||||
* WHY: le profil décrit une allowlist, jamais une règle par suffixe.
|
||||
* INVARIANT: sujet, endpoint, port et sélection sont comparés exactement.
|
||||
*/
|
||||
gboolean research_policy_validate_http_action(
|
||||
const ResearchAction *action, const ScopeGrant *grant,
|
||||
const ResearchNetworkProfile *profile, ResearchHttpTarget *out_target,
|
||||
GError **error);
|
||||
|
||||
/** INVARIANT: seules les adresses globalement routables sont acceptées. */
|
||||
gboolean research_policy_address_is_public(const char *address);
|
||||
|
||||
/**
|
||||
* CONTRACT: une redirection valide reste une nouvelle destination à décider.
|
||||
* Cette fonction ne propage jamais l'autorisation de l'action initiale.
|
||||
*/
|
||||
ResearchRedirectDecision research_policy_check_redirect(
|
||||
const char *location, const ResearchNetworkProfile *profile,
|
||||
ResearchHttpTarget *out_target, GError **error);
|
||||
|
||||
void research_http_target_clear(ResearchHttpTarget *target);
|
||||
|
||||
G_END_DECLS
|
||||
#endif
|
||||
72
include/core/research_store.h
Normal file
72
include/core/research_store.h
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/******************************************************************************
|
||||
* @file research_store.h
|
||||
* @brief Persistance V4 des autorisations de recherche assistée.
|
||||
******************************************************************************/
|
||||
#ifndef LABFY_INVESTIGATION_RESEARCH_STORE_H
|
||||
#define LABFY_INVESTIGATION_RESEARCH_STORE_H
|
||||
|
||||
#include "core/local_job_store.h"
|
||||
#include "core/research_contracts.h"
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
/**
|
||||
* Admet atomiquement un plan et ses seeds/actions exactes.
|
||||
* CONTRACT: même clé et même contenu retourne le même objet durable; une même
|
||||
* clé avec un contenu différent est un conflit.
|
||||
* INVARIANT: aucune action invalide ne laisse un plan partiellement écrit.
|
||||
*/
|
||||
gboolean research_store_admit_plan(LocalJobStore *store,
|
||||
const ResearchPlan *plan,
|
||||
gboolean *out_reused, GError **error);
|
||||
|
||||
/**
|
||||
* Admet un grant lié au plan et matérialise les sujets/provider/endpoints
|
||||
* autorisés au moment de la décision. Toutes les décisions humaines sont
|
||||
* persistées dans la même transaction. REFUSE est terminal et rend l'action
|
||||
* durablement inadmissible; la vague 2 exige une vague 1 autorisée, exécutée
|
||||
* et documentée par son résultat et son reçu.
|
||||
*/
|
||||
gboolean research_store_admit_grant(LocalJobStore *store,
|
||||
const ScopeGrant *grant,
|
||||
const ResearchActionDecision *decisions,
|
||||
gsize decision_count,
|
||||
const char *decided_at,
|
||||
gboolean *out_reused, GError **error);
|
||||
|
||||
/** Révoque durablement un grant sans supprimer son historique. */
|
||||
gboolean research_store_revoke_grant(LocalJobStore *store,
|
||||
const char *grant_id,
|
||||
const char *revoked_at,
|
||||
GError **error);
|
||||
|
||||
/** Admet l'identité durable d'une campagne; ne démarre aucun travail. */
|
||||
gboolean research_store_admit_campaign(LocalJobStore *store,
|
||||
const ResearchCampaign *campaign,
|
||||
gboolean *out_reused, GError **error);
|
||||
|
||||
/**
|
||||
* Enregistre atomiquement le résultat brut et le reçu de policy correspondant.
|
||||
* INVARIANT: le reçu doit viser une action explicitement sélectionnée du grant
|
||||
* de la campagne; aucun résultat orphelin n'est publié.
|
||||
*/
|
||||
gboolean research_store_record_result(LocalJobStore *store,
|
||||
const ResearchResult *result,
|
||||
const ResearchReceipt *receipt,
|
||||
GError **error);
|
||||
|
||||
/**
|
||||
* Évalue un usage exact du grant sans créer de job ni réserver de budget.
|
||||
* WHY: l'unique worker existant reste le seul ordonnanceur; cette fonction
|
||||
* rend la décision de policy observable avant toute admission ultérieure.
|
||||
* INVARIANT: exclusion, révocation et expiration sont des refus explicites;
|
||||
* redirection, CNAME ou nouvelle URL ne correspondent jamais par construction.
|
||||
*/
|
||||
gboolean research_store_policy_decide(LocalJobStore *store,
|
||||
const char *grant_id,
|
||||
const ResearchPolicyRequest *request,
|
||||
ResearchPolicyDecision *out_decision,
|
||||
GError **error);
|
||||
|
||||
G_END_DECLS
|
||||
#endif
|
||||
94
include/core/research_transport.h
Normal file
94
include/core/research_transport.h
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/******************************************************************************
|
||||
* @file research_transport.h
|
||||
* @brief Transport borné et sans autorité implicite pour la recherche V1.
|
||||
******************************************************************************/
|
||||
#ifndef LABFY_INVESTIGATION_RESEARCH_TRANSPORT_H
|
||||
#define LABFY_INVESTIGATION_RESEARCH_TRANSPORT_H
|
||||
|
||||
#include "core/research_policy.h"
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
typedef enum {
|
||||
RESEARCH_TRANSPORT_OK,
|
||||
RESEARCH_TRANSPORT_UNAVAILABLE,
|
||||
RESEARCH_TRANSPORT_CANCELLED,
|
||||
RESEARCH_TRANSPORT_TIMEOUT,
|
||||
RESEARCH_TRANSPORT_RATE_LIMITED,
|
||||
RESEARCH_TRANSPORT_FORBIDDEN,
|
||||
RESEARCH_TRANSPORT_REDIRECT,
|
||||
RESEARCH_TRANSPORT_TOO_LARGE,
|
||||
RESEARCH_TRANSPORT_INVALID_RESPONSE,
|
||||
RESEARCH_TRANSPORT_FAILED
|
||||
} ResearchTransportState;
|
||||
|
||||
typedef struct {
|
||||
const char *name;
|
||||
const char *value;
|
||||
} ResearchHeader;
|
||||
|
||||
typedef struct {
|
||||
const ResearchAction *action;
|
||||
const ScopeGrant *grant;
|
||||
const ResearchNetworkProfile *profile;
|
||||
const char *method;
|
||||
const ResearchHeader *headers;
|
||||
gsize header_count;
|
||||
guint64 max_compressed_bytes;
|
||||
guint64 max_decompressed_bytes;
|
||||
guint64 max_header_bytes;
|
||||
GCancellable *cancellable;
|
||||
} ResearchTransportRequest;
|
||||
|
||||
typedef struct {
|
||||
ResearchTransportState state;
|
||||
guint status_code;
|
||||
guint retry_after_seconds;
|
||||
gboolean retry_after_present;
|
||||
gboolean body_is_compressed;
|
||||
GBytes *body;
|
||||
GHashTable *headers;
|
||||
char *redirect_location;
|
||||
char *diagnostic;
|
||||
} ResearchTransportReply;
|
||||
|
||||
/**
|
||||
* CONTRACT: la production n'utilise ni proxy, cookies, netrc, auth ambiante,
|
||||
* redirection automatique, ni résolution fournie par l'appelant.
|
||||
* CURRENT: aucun backend réseau n'est lié; retourne UNAVAILABLE sans contact.
|
||||
*/
|
||||
gboolean research_transport_execute(const ResearchTransportRequest *request,
|
||||
ResearchTransportReply *out_reply,
|
||||
GError **error);
|
||||
|
||||
gboolean research_transport_request_validate(
|
||||
const ResearchTransportRequest *request, ResearchHttpTarget *out_target,
|
||||
GError **error);
|
||||
void research_transport_reply_clear(ResearchTransportReply *reply);
|
||||
const char *research_transport_state_code(ResearchTransportState state);
|
||||
|
||||
#ifdef RESEARCH_TRANSPORT_ENABLE_TEST_HOOKS
|
||||
typedef struct {
|
||||
guint status_code;
|
||||
const ResearchHeader *headers;
|
||||
gsize header_count;
|
||||
const guint8 *body;
|
||||
gsize body_size;
|
||||
gboolean compressed;
|
||||
guint64 decompressed_size;
|
||||
const char *peer_address;
|
||||
const char *redirect_location;
|
||||
gboolean timed_out;
|
||||
gboolean unavailable;
|
||||
} ResearchTransportFixture;
|
||||
|
||||
/** TEST-ONLY: réponse finie injectée, sans socket ni résolution réseau. */
|
||||
gboolean research_transport_execute_fixture(
|
||||
const ResearchTransportRequest *request,
|
||||
const ResearchTransportFixture *fixture,
|
||||
const char *const *loopback_authorities, gsize authority_count,
|
||||
ResearchTransportReply *out_reply, GError **error);
|
||||
#endif
|
||||
|
||||
G_END_DECLS
|
||||
#endif
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file application_message_dialog.h
|
||||
* @brief Fenêtre modale affichant un message à l'utilisateur.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_APPLICATION_MESSAGE_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_APPLICATION_MESSAGE_DIALOG_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
/**
|
||||
* @brief Nature du message affiché.
|
||||
*/
|
||||
typedef enum
|
||||
{
|
||||
APPLICATION_MESSAGE_DIALOG_ERROR,
|
||||
APPLICATION_MESSAGE_DIALOG_WARNING,
|
||||
APPLICATION_MESSAGE_DIALOG_INFORMATION
|
||||
} ApplicationMessageDialogType;
|
||||
|
||||
/**
|
||||
* @brief Callback appelé à la fermeture d'un dialogue de confirmation.
|
||||
*
|
||||
* Le callback est appelé une seule fois après un clic sur l'un des boutons
|
||||
* ou après une fermeture manuelle de la fenêtre.
|
||||
*
|
||||
* @param confirmed TRUE si l'utilisateur confirme, sinon FALSE.
|
||||
* @param user_data Données empruntées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*ApplicationMessageDialogConfirmationCallback)(
|
||||
gboolean confirmed,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé par l'action facultative d'un dialogue détaillé.
|
||||
*
|
||||
* @param user_data Données associées à l'action.
|
||||
*/
|
||||
typedef void (*ApplicationMessageDialogActionCallback)(
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche une fenêtre modale contenant un message.
|
||||
*
|
||||
* Les chaînes sont copiées par les widgets GTK.
|
||||
*
|
||||
* @param parent_window Fenêtre parente, ou NULL.
|
||||
* @param message_type Type de message.
|
||||
* @param title Titre de la fenêtre, ou NULL.
|
||||
* @param message Message principal, ou NULL.
|
||||
*/
|
||||
void application_message_dialog_present(
|
||||
GtkWindow *parent_window,
|
||||
ApplicationMessageDialogType message_type,
|
||||
const char *title,
|
||||
const char *message
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche un message accompagné d'un contenu textuel défilable.
|
||||
*
|
||||
* Le contenu détaillé est affiché dans une zone monospace sélectionnable.
|
||||
* Les chaînes sont copiées par les widgets GTK.
|
||||
*
|
||||
* @param parent_window Fenêtre parente, ou NULL.
|
||||
* @param message_type Type de message.
|
||||
* @param title Titre de la fenêtre, ou NULL.
|
||||
* @param message Message principal, ou NULL.
|
||||
* @param details Contenu détaillé, ou NULL.
|
||||
*/
|
||||
void application_message_dialog_present_details(
|
||||
GtkWindow *parent_window,
|
||||
ApplicationMessageDialogType message_type,
|
||||
const char *title,
|
||||
const char *message,
|
||||
const char *details
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche un message détaillé avec une action facultative.
|
||||
*
|
||||
* Le dialogue devient propriétaire de action_data lorsque l'affichage est
|
||||
* créé. Les données sont libérées avec action_data_destroy à sa fermeture.
|
||||
*
|
||||
* @param parent_window Fenêtre parente, ou NULL.
|
||||
* @param message_type Type de message.
|
||||
* @param title Titre de la fenêtre, ou NULL.
|
||||
* @param message Message principal, ou NULL.
|
||||
* @param details Contenu détaillé, ou NULL.
|
||||
* @param action_label Libellé de l'action, ou NULL.
|
||||
* @param action_callback Callback de l'action, ou NULL.
|
||||
* @param action_data Données transmises au callback.
|
||||
* @param action_data_destroy Destructeur des données, ou NULL.
|
||||
*/
|
||||
void application_message_dialog_present_details_action(
|
||||
GtkWindow *parent_window,
|
||||
ApplicationMessageDialogType message_type,
|
||||
const char *title,
|
||||
const char *message,
|
||||
const char *details,
|
||||
const char *action_label,
|
||||
ApplicationMessageDialogActionCallback action_callback,
|
||||
gpointer action_data,
|
||||
GDestroyNotify action_data_destroy
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche un dialogue modal demandant une confirmation.
|
||||
*
|
||||
* Le bouton d'annulation transmet FALSE. Le bouton de confirmation transmet
|
||||
* TRUE. Fermer la fenêtre manuellement équivaut à une annulation.
|
||||
*
|
||||
* callback et user_data sont empruntés jusqu'à la fermeture du dialogue.
|
||||
* confirm_label peut être NULL ou vide pour utiliser « Confirmer ».
|
||||
*
|
||||
* @param parent_window Fenêtre parente, ou NULL.
|
||||
* @param message_type Type de message.
|
||||
* @param title Titre de la fenêtre, ou NULL.
|
||||
* @param message Message principal, ou NULL.
|
||||
* @param confirm_label Libellé du bouton de confirmation, ou NULL.
|
||||
* @param callback Callback facultatif recevant la réponse.
|
||||
* @param user_data Données empruntées transmises au callback.
|
||||
*/
|
||||
void application_message_dialog_present_confirmation(
|
||||
GtkWindow *parent_window,
|
||||
ApplicationMessageDialogType message_type,
|
||||
const char *title,
|
||||
const char *message,
|
||||
const char *confirm_label,
|
||||
ApplicationMessageDialogConfirmationCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
#endif
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file create_investigation_dialog.h
|
||||
* @brief Interface publique du dialogue de création d'une enquête.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_CREATE_INVESTIGATION_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_CREATE_INVESTIGATION_DIALOG_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsque le dialogue est validé ou annulé.
|
||||
*
|
||||
* Lors d'une validation réussie :
|
||||
*
|
||||
* - parent_directory contient le dossier parent sélectionné ;
|
||||
* - investigation_name contient le nom nettoyé de l'enquête.
|
||||
*
|
||||
* Lors d'une annulation :
|
||||
*
|
||||
* - parent_directory vaut NULL ;
|
||||
* - investigation_name vaut NULL.
|
||||
*
|
||||
* Les chaînes transmises restent valides uniquement pendant l'appel du
|
||||
* callback. Le code appelant doit les copier s'il souhaite les conserver.
|
||||
*
|
||||
* @param parent_directory Dossier parent sélectionné, ou NULL.
|
||||
* @param investigation_name Nom de l'enquête, ou NULL.
|
||||
* @param user_data Données utilisateur fournies lors de l'ouverture.
|
||||
*/
|
||||
typedef void (*CreateInvestigationDialogCallback)(
|
||||
const char *parent_directory,
|
||||
const char *investigation_name,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Présente le dialogue de création d'une nouvelle enquête.
|
||||
*
|
||||
* Le dialogue permet :
|
||||
*
|
||||
* - de sélectionner un dossier parent existant ;
|
||||
* - de saisir le nom de l'enquête ;
|
||||
* - de valider ou d'annuler la création.
|
||||
*
|
||||
* Cette fonction ne crée aucun fichier et n'accède pas à SQLite.
|
||||
*
|
||||
* @param parent_window Fenêtre GTK parente, ou NULL.
|
||||
* @param callback Fonction appelée lors de la validation ou de l'annulation.
|
||||
* @param user_data Données transmises au callback.
|
||||
*/
|
||||
void create_investigation_dialog_present(
|
||||
GtkWindow *parent_window,
|
||||
CreateInvestigationDialogCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
#endif
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file create_person_dialog.h
|
||||
* @brief Formulaire GTK de création d'une personne observée.
|
||||
******************************************************************************/
|
||||
#ifndef LABFY_INVESTIGATION_CREATE_PERSON_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_CREATE_PERSON_DIALOG_H
|
||||
#include "core/person_entity_service.h"
|
||||
#include "core/evidence_staging.h"
|
||||
#include "models/evidence_record.h"
|
||||
#include "models/person_evidence_selection.h"
|
||||
#include "core/task_manager.h"
|
||||
#include "core/tool_registry.h"
|
||||
#include <gtk/gtk.h>
|
||||
G_BEGIN_DECLS
|
||||
/** @brief Résultat opaque possédé par le callback. */
|
||||
typedef struct CreatePersonDialogResult CreatePersonDialogResult;
|
||||
/** @brief Callback appelé après validation ou annulation. */
|
||||
typedef void (*CreatePersonDialogCallback)(CreatePersonDialogResult *result,
|
||||
gpointer user_data);
|
||||
typedef gboolean (*CreatePersonDialogSessionCheck)(gpointer user_data);
|
||||
/**
|
||||
* @brief Présente le formulaire de création d'une personne.
|
||||
* @param parent Fenêtre parente.
|
||||
* @param evidence_records Preuves disponibles empruntées.
|
||||
* @param callback Callback de fin.
|
||||
* @param user_data Données privées du callback.
|
||||
* @return TRUE si le dialogue a été présenté.
|
||||
*/
|
||||
gboolean create_person_dialog_present(GtkWindow *parent,
|
||||
Database *database, const GPtrArray *evidence_records,
|
||||
const char *investigation_root_path,
|
||||
TaskManager *task_manager, const ToolInfo *tesseract_tool,
|
||||
CreatePersonDialogSessionCheck session_check,
|
||||
CreatePersonDialogCallback callback, gpointer user_data,
|
||||
GDestroyNotify user_data_destroy);
|
||||
/** @brief Libère un résultat. */
|
||||
void create_person_dialog_result_free(CreatePersonDialogResult *result);
|
||||
/** @brief Retourne les données empruntées du résultat. */
|
||||
const PersonEntityInput *create_person_dialog_result_get_input(
|
||||
const CreatePersonDialogResult *result);
|
||||
const PersonEvidenceSelection *create_person_dialog_result_get_evidence_selection(
|
||||
const CreatePersonDialogResult *result);
|
||||
EvidenceStaging *create_person_dialog_result_steal_staging(
|
||||
CreatePersonDialogResult *result);
|
||||
const GPtrArray *create_person_dialog_result_get_ocr_runs(
|
||||
const CreatePersonDialogResult *result);
|
||||
const GPtrArray *create_person_dialog_result_get_factual_relations(
|
||||
const CreatePersonDialogResult *result);
|
||||
const GPtrArray *create_person_dialog_result_get_ocr_projections(
|
||||
const CreatePersonDialogResult *result);
|
||||
gboolean create_person_dialog_test_overlay_has_region(GtkWindow *window);
|
||||
guint64 create_person_dialog_test_ocr_generation(GtkWindow *window);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file create_relation_dialog.h
|
||||
* @brief Dialogue GTK de préparation d'une relation entre deux entités.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_CREATE_RELATION_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_CREATE_RELATION_DIALOG_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
/**
|
||||
* @brief Résultat opaque produit par le dialogue.
|
||||
*/
|
||||
typedef struct CreateRelationDialogResult CreateRelationDialogResult;
|
||||
typedef struct RelationRecord RelationRecord;
|
||||
|
||||
/**
|
||||
* @brief Codes d'erreur produits par CreateRelationDialog.
|
||||
*/
|
||||
typedef enum
|
||||
{
|
||||
CREATE_RELATION_DIALOG_ERROR_INVALID_ARGUMENT,
|
||||
CREATE_RELATION_DIALOG_ERROR_EMPTY_TARGETS,
|
||||
CREATE_RELATION_DIALOG_ERROR_MEMORY,
|
||||
CREATE_RELATION_DIALOG_ERROR_INVALID_DATA
|
||||
} CreateRelationDialogError;
|
||||
|
||||
/**
|
||||
* @brief Domaine d'erreur du dialogue.
|
||||
*/
|
||||
#define CREATE_RELATION_DIALOG_ERROR \
|
||||
create_relation_dialog_error_quark()
|
||||
|
||||
/**
|
||||
* @brief Callback appelé après validation ou annulation.
|
||||
*
|
||||
* En cas de validation, result appartient au callback et doit être libéré
|
||||
* avec create_relation_dialog_result_free().
|
||||
*
|
||||
* En cas d'annulation, result vaut NULL.
|
||||
*
|
||||
* @param result Résultat possédé par le callback, ou NULL.
|
||||
* @param user_data Données privées fournies lors de l'ouverture.
|
||||
*/
|
||||
typedef void (*CreateRelationDialogCallback)(
|
||||
CreateRelationDialogResult *result,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne le domaine d'erreur du dialogue.
|
||||
*/
|
||||
GQuark create_relation_dialog_error_quark(void);
|
||||
|
||||
/**
|
||||
* @brief Présente le dialogue de création d'une relation.
|
||||
*
|
||||
* entities doit contenir des pointeurs EntityRecord valides. Le dialogue
|
||||
* copie tous les identifiants et libellés nécessaires et ne conserve aucun
|
||||
* pointeur vers les modèles fournis.
|
||||
*
|
||||
* L'entité source est retirée de la liste des cibles.
|
||||
*
|
||||
* Cette fonction ne crée aucune relation et n'accède pas à SQLite.
|
||||
*
|
||||
* @param parent Fenêtre GTK parente.
|
||||
* @param source_entity_identifier UUID de l'entité source.
|
||||
* @param entities Tableau de EntityRecord.
|
||||
* @param evidence_records Tableau de EvidenceRecord disponibles.
|
||||
* @param investigation_root_path Racine canonique de l'enquête.
|
||||
* @param callback Fonction appelée après validation ou annulation.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
* @param error Emplacement facultatif recevant une erreur immédiate.
|
||||
*
|
||||
* @return TRUE si le dialogue a été créé et présenté.
|
||||
*/
|
||||
gboolean create_relation_dialog_present(
|
||||
GtkWindow *parent,
|
||||
const char *source_entity_identifier,
|
||||
const GPtrArray *entities,
|
||||
const GPtrArray *relation_types,
|
||||
const GPtrArray *evidence_records,
|
||||
const char *investigation_root_path,
|
||||
CreateRelationDialogCallback callback,
|
||||
gpointer user_data,
|
||||
GError **error
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Présente le dialogue prérempli pour modifier une relation.
|
||||
*
|
||||
* La source et la cible sont conservées ; les champs métier sont modifiables.
|
||||
* Le dialogue ne réalise aucune écriture SQLite.
|
||||
*
|
||||
* @param parent Fenêtre GTK parente.
|
||||
* @param relation_record Relation existante à modifier.
|
||||
* @param entities Tableau de EntityRecord.
|
||||
* @param evidence_records Tableau de EvidenceRecord disponibles.
|
||||
* @param selected_evidence_identifiers UUID des preuves déjà liées.
|
||||
* @param investigation_root_path Racine canonique de l'enquête.
|
||||
* @param callback Fonction appelée après validation ou annulation.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
* @param error Emplacement facultatif recevant une erreur immédiate.
|
||||
*
|
||||
* @return TRUE si le dialogue a été créé et présenté.
|
||||
*/
|
||||
gboolean edit_relation_dialog_present(
|
||||
GtkWindow *parent,
|
||||
const RelationRecord *relation_record,
|
||||
const GPtrArray *entities,
|
||||
const GPtrArray *relation_types,
|
||||
const GPtrArray *evidence_records,
|
||||
const GPtrArray *selected_evidence_identifiers,
|
||||
const char *investigation_root_path,
|
||||
CreateRelationDialogCallback callback,
|
||||
gpointer user_data,
|
||||
GError **error
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Valide les valeurs obligatoires d'une relation.
|
||||
*
|
||||
* Cette fonction est indépendante de l'affichage et pourra être testée
|
||||
* sans ouvrir de fenêtre GTK.
|
||||
*
|
||||
* @param source_entity_identifier UUID de l'entité source.
|
||||
* @param target_entity_identifier UUID de l'entité cible.
|
||||
* @param relation_type Type de relation non vide.
|
||||
* @param confidence Confiance comprise entre 0 et 100.
|
||||
* @param error Emplacement facultatif recevant une erreur.
|
||||
*
|
||||
* @return TRUE lorsque les valeurs sont valides.
|
||||
*/
|
||||
gboolean create_relation_dialog_validate_values(
|
||||
const char *source_entity_identifier,
|
||||
const char *target_entity_identifier,
|
||||
const char *relation_type,
|
||||
gint confidence,
|
||||
GError **error
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Libère un résultat.
|
||||
*
|
||||
* Cette fonction accepte NULL.
|
||||
*/
|
||||
void create_relation_dialog_result_free(
|
||||
CreateRelationDialogResult *result
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne l'UUID de l'entité source.
|
||||
*/
|
||||
const char *create_relation_dialog_result_get_source_identifier(
|
||||
const CreateRelationDialogResult *result
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne l'UUID de l'entité cible.
|
||||
*/
|
||||
const char *create_relation_dialog_result_get_target_identifier(
|
||||
const CreateRelationDialogResult *result
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne le type de relation.
|
||||
*/
|
||||
const char *create_relation_dialog_result_get_relation_type(
|
||||
const CreateRelationDialogResult *result
|
||||
);
|
||||
gint64 create_relation_dialog_result_get_relation_type_identifier(
|
||||
const CreateRelationDialogResult *result);
|
||||
const char *create_relation_dialog_result_get_relation_type_code(
|
||||
const CreateRelationDialogResult *result);
|
||||
|
||||
/**
|
||||
* @brief Retourne le libellé facultatif.
|
||||
*/
|
||||
const char *create_relation_dialog_result_get_label(
|
||||
const CreateRelationDialogResult *result
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne la justification facultative.
|
||||
*/
|
||||
const char *create_relation_dialog_result_get_justification(
|
||||
const CreateRelationDialogResult *result
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne le niveau de confiance.
|
||||
*/
|
||||
gint create_relation_dialog_result_get_confidence(
|
||||
const CreateRelationDialogResult *result
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne les UUID de preuves sélectionnés.
|
||||
*
|
||||
* @param result Résultat validé du dialogue.
|
||||
*
|
||||
* @return Tableau emprunté d'UUID, ou NULL.
|
||||
*/
|
||||
const GPtrArray *create_relation_dialog_result_get_evidence_identifiers(
|
||||
const CreateRelationDialogResult *result);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file create_social_account_dialog.h
|
||||
* @brief Dialogue GTK de création d'un compte social observé.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_CREATE_SOCIAL_ACCOUNT_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_CREATE_SOCIAL_ACCOUNT_DIALOG_H
|
||||
|
||||
#include "models/evidence_record.h"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
/** @brief Résultat opaque du dialogue de compte social. */
|
||||
typedef struct CreateSocialAccountDialogResult CreateSocialAccountDialogResult;
|
||||
|
||||
/** @brief Callback appelé après validation ou annulation. */
|
||||
typedef void (*CreateSocialAccountDialogCallback)(
|
||||
CreateSocialAccountDialogResult *result,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Présente le formulaire de création d'un compte social.
|
||||
*
|
||||
* Le tableau de preuves est uniquement emprunté pendant cet appel ; les
|
||||
* informations nécessaires au formulaire sont copiées.
|
||||
*
|
||||
* @param parent Fenêtre parente.
|
||||
* @param evidence_records Tableau facultatif de EvidenceRecord.
|
||||
* @param callback Callback de fin.
|
||||
* @param user_data Données privées du callback.
|
||||
* @return TRUE si le dialogue a été présenté.
|
||||
*/
|
||||
gboolean create_social_account_dialog_present(
|
||||
GtkWindow *parent,
|
||||
const GPtrArray *evidence_records,
|
||||
CreateSocialAccountDialogCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Libère un résultat, y compris toutes ses chaînes. */
|
||||
void create_social_account_dialog_result_free(
|
||||
CreateSocialAccountDialogResult *result
|
||||
);
|
||||
|
||||
/** @brief Retourne le code de la plateforme sélectionnée. */
|
||||
const char *create_social_account_dialog_result_get_platform(const CreateSocialAccountDialogResult *result);
|
||||
/** @brief Retourne l'URL complète du profil. */
|
||||
const char *create_social_account_dialog_result_get_profile_url(const CreateSocialAccountDialogResult *result);
|
||||
/** @brief Retourne le pseudonyme affiché. */
|
||||
const char *create_social_account_dialog_result_get_username(const CreateSocialAccountDialogResult *result);
|
||||
/** @brief Retourne l'identifiant stable facultatif de la plateforme. */
|
||||
const char *create_social_account_dialog_result_get_platform_identifier(const CreateSocialAccountDialogResult *result);
|
||||
/** @brief Retourne la date UTC de première observation. */
|
||||
const char *create_social_account_dialog_result_get_first_observed_at(const CreateSocialAccountDialogResult *result);
|
||||
/** @brief Retourne l'état observé du compte. */
|
||||
const char *create_social_account_dialog_result_get_account_state(const CreateSocialAccountDialogResult *result);
|
||||
/** @brief Retourne les notes factuelles facultatives. */
|
||||
const char *create_social_account_dialog_result_get_notes(const CreateSocialAccountDialogResult *result);
|
||||
/** @brief Retourne l'UUID de la preuve associée, ou NULL. */
|
||||
const char *create_social_account_dialog_result_get_evidence_identifier(const CreateSocialAccountDialogResult *result);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
#ifndef LABFY_DIALOG_GEOMETRY_H
|
||||
#define LABFY_DIALOG_GEOMETRY_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
typedef struct {
|
||||
int initial_width;
|
||||
int initial_height;
|
||||
int minimum_width;
|
||||
int minimum_height;
|
||||
} LabfyDialogGeometry;
|
||||
|
||||
LabfyDialogGeometry labfy_dialog_geometry_calculate(
|
||||
int workarea_width, int workarea_height);
|
||||
|
||||
void labfy_dialog_apply_standard_geometry(
|
||||
GtkWindow *window, GtkWindow *parent);
|
||||
|
||||
void labfy_dialog_prepare(
|
||||
GtkWindow *window,
|
||||
GtkWindow *parent,
|
||||
gboolean modal,
|
||||
gboolean destroy_with_parent);
|
||||
|
||||
void labfy_dialog_present(GtkWindow *window);
|
||||
|
||||
void labfy_paned_apply_initial_ratio(
|
||||
GtkPaned *paned, double ratio, int minimum_start, int minimum_end);
|
||||
gboolean labfy_paned_initial_ratio_applied(GtkPaned *paned);
|
||||
|
||||
#endif
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
#ifndef LABFY_DOCUMENT_AUTHENTICITY_EDITOR_H
|
||||
#define LABFY_DOCUMENT_AUTHENTICITY_EDITOR_H
|
||||
#include "database/database.h"
|
||||
#include <gtk/gtk.h>
|
||||
G_BEGIN_DECLS
|
||||
typedef struct DocumentAuthenticityEditor DocumentAuthenticityEditor;
|
||||
DocumentAuthenticityEditor *document_authenticity_editor_new(Database *database,const char *evidence_identifier);
|
||||
GtkWidget *document_authenticity_editor_get_widget(DocumentAuthenticityEditor *editor);
|
||||
gboolean document_authenticity_editor_refresh(DocumentAuthenticityEditor *editor,GError **error);
|
||||
void document_authenticity_editor_free(DocumentAuthenticityEditor *editor);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
#ifndef LABFY_DOCUMENT_IDENTITY_MISUSE_EDITOR_H
|
||||
#define LABFY_DOCUMENT_IDENTITY_MISUSE_EDITOR_H
|
||||
#include "database/database.h"
|
||||
#include <gtk/gtk.h>
|
||||
G_BEGIN_DECLS
|
||||
typedef struct DocumentIdentityMisuseEditor DocumentIdentityMisuseEditor;
|
||||
DocumentIdentityMisuseEditor *document_identity_misuse_editor_new(Database *database,const char *evidence_identifier);
|
||||
GtkWidget *document_identity_misuse_editor_get_widget(DocumentIdentityMisuseEditor *editor);
|
||||
gboolean document_identity_misuse_editor_refresh(DocumentIdentityMisuseEditor *editor,GError **error);
|
||||
void document_identity_misuse_editor_free(DocumentIdentityMisuseEditor *editor);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file eml_analysis_dialog.h
|
||||
* @brief Présentation en lecture seule d'une analyse EML.
|
||||
******************************************************************************/
|
||||
#ifndef LABFY_INVESTIGATION_EML_ANALYSIS_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_EML_ANALYSIS_DIALOG_H
|
||||
#include "core/eml_processing.h"
|
||||
#include "core/eml_integration.h"
|
||||
#include "core/eml_pipeline_task.h"
|
||||
#include <gtk/gtk.h>
|
||||
G_BEGIN_DECLS
|
||||
/** @brief Callback recevant les propositions sélectionnées, ou NULL. */
|
||||
typedef void (*EmlAnalysisDialogCallback)(GPtrArray *proposals,
|
||||
gpointer user_data);
|
||||
/**
|
||||
* @brief Affiche les métadonnées et indicateurs extraits d'une copie EML.
|
||||
* @param parent Fenêtre parente.
|
||||
* @param result Résultat emprunté pendant la construction de la fenêtre.
|
||||
*/
|
||||
void eml_analysis_dialog_present(GtkWindow *parent,
|
||||
const EmlProcessingResult *result, EmlAnalysisDialogCallback callback,
|
||||
gpointer user_data);
|
||||
void eml_analysis_dialog_present_pipeline(
|
||||
GtkWindow *parent,
|
||||
const EmlPipelineResult *result,
|
||||
const char *evidence_name,
|
||||
const char *relative_path,
|
||||
const char *source_sha256,
|
||||
EmlAnalysisDialogCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
#ifndef LABFY_EVIDENCE_IDENTITY_OCR_DIALOG_H
|
||||
#define LABFY_EVIDENCE_IDENTITY_OCR_DIALOG_H
|
||||
|
||||
#include "core/tool_registry.h"
|
||||
#include "core/background_task.h"
|
||||
#include "models/entity_record.h"
|
||||
#include "models/identity_ocr.h"
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
typedef struct EvidenceIdentityOcrDialogResult
|
||||
EvidenceIdentityOcrDialogResult;
|
||||
typedef gboolean (*EvidenceIdentityOcrDialogSessionCheck)(gpointer user_data);
|
||||
typedef void (*EvidenceIdentityOcrDialogCallback)(
|
||||
EvidenceIdentityOcrDialogResult *result, gpointer user_data);
|
||||
|
||||
gboolean evidence_identity_ocr_dialog_file_is_compatible(
|
||||
const char *file_path);
|
||||
gboolean evidence_identity_ocr_dialog_present(
|
||||
GtkWindow *parent, const char *file_path, const GPtrArray *persons,
|
||||
const char *preselected_person_identifier,
|
||||
const ToolInfo *tesseract_tool,
|
||||
EvidenceIdentityOcrDialogSessionCheck session_check,
|
||||
EvidenceIdentityOcrDialogCallback callback,
|
||||
gpointer user_data, GDestroyNotify user_data_destroy,
|
||||
GError **error);
|
||||
gboolean evidence_identity_ocr_dialog_present_review(
|
||||
GtkWindow *parent,const char *file_path,const GPtrArray *persons,
|
||||
const char *preselected_person_identifier,
|
||||
const IdentityOcrRun *persisted_run,
|
||||
EvidenceIdentityOcrDialogSessionCheck session_check,
|
||||
EvidenceIdentityOcrDialogCallback callback,
|
||||
gpointer user_data,GDestroyNotify user_data_destroy,GError **error);
|
||||
void evidence_identity_ocr_dialog_result_free(
|
||||
EvidenceIdentityOcrDialogResult *result);
|
||||
gboolean evidence_identity_ocr_dialog_result_has_ocr(
|
||||
const EvidenceIdentityOcrDialogResult *result);
|
||||
const char *evidence_identity_ocr_dialog_result_get_person_identifier(
|
||||
const EvidenceIdentityOcrDialogResult *result);
|
||||
const char *evidence_identity_ocr_dialog_result_get_temporary_identifier(
|
||||
const EvidenceIdentityOcrDialogResult *result);
|
||||
const char *evidence_identity_ocr_dialog_result_get_sha256(
|
||||
const EvidenceIdentityOcrDialogResult *result);
|
||||
guint64 evidence_identity_ocr_dialog_result_get_size(
|
||||
const EvidenceIdentityOcrDialogResult *result);
|
||||
const char *evidence_identity_ocr_dialog_result_get_mime_type(
|
||||
const EvidenceIdentityOcrDialogResult *result);
|
||||
IdentityOcrRun *evidence_identity_ocr_dialog_result_steal_run(
|
||||
EvidenceIdentityOcrDialogResult *result);
|
||||
GtkWindow *evidence_identity_ocr_dialog_result_get_dialog(
|
||||
const EvidenceIdentityOcrDialogResult *result);
|
||||
void evidence_identity_ocr_dialog_finish_import(
|
||||
GtkWindow *dialog, const GError *error);
|
||||
void evidence_identity_ocr_dialog_set_submission_task(
|
||||
GtkWindow *dialog, BackgroundTask *task);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file evidence_import_dialog.h
|
||||
* @brief Dialogue GTK de saisie des métadonnées d'une preuve.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_EVIDENCE_IMPORT_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_EVIDENCE_IMPORT_DIALOG_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
/**
|
||||
* @brief Résultat opaque produit par le dialogue d'import.
|
||||
*/
|
||||
typedef struct EvidenceImportDialogResult EvidenceImportDialogResult;
|
||||
|
||||
/**
|
||||
* @brief Codes d'erreur produits par EvidenceImportDialog.
|
||||
*/
|
||||
typedef enum
|
||||
{
|
||||
/**
|
||||
* Un argument public est invalide.
|
||||
*/
|
||||
EVIDENCE_IMPORT_DIALOG_ERROR_INVALID_ARGUMENT,
|
||||
|
||||
/**
|
||||
* Aucun type de preuve n'est disponible.
|
||||
*/
|
||||
EVIDENCE_IMPORT_DIALOG_ERROR_EMPTY_TYPES,
|
||||
|
||||
/**
|
||||
* Une allocation ou la création du dialogue a échoué.
|
||||
*/
|
||||
EVIDENCE_IMPORT_DIALOG_ERROR_MEMORY,
|
||||
|
||||
/**
|
||||
* Les métadonnées saisies sont invalides.
|
||||
*/
|
||||
EVIDENCE_IMPORT_DIALOG_ERROR_INVALID_DATA
|
||||
} EvidenceImportDialogError;
|
||||
|
||||
/**
|
||||
* @brief Domaine d'erreur du dialogue d'import.
|
||||
*/
|
||||
#define EVIDENCE_IMPORT_DIALOG_ERROR \
|
||||
evidence_import_dialog_error_quark()
|
||||
|
||||
/**
|
||||
* @brief Callback appelé après la fermeture du dialogue.
|
||||
*
|
||||
* En cas de validation, result est non NULL et appartient au callback.
|
||||
* Il doit être libéré avec evidence_import_dialog_result_free().
|
||||
*
|
||||
* En cas d'annulation, result vaut NULL.
|
||||
*
|
||||
* @param result Résultat possédé par le callback, ou NULL.
|
||||
* @param user_data Données privées fournies lors de l'ouverture.
|
||||
*/
|
||||
typedef void (*EvidenceImportDialogCallback)(
|
||||
EvidenceImportDialogResult *result,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne le domaine d'erreur du dialogue.
|
||||
*
|
||||
* @return Quark GLib associé à EvidenceImportDialog.
|
||||
*/
|
||||
GQuark evidence_import_dialog_error_quark(void);
|
||||
|
||||
/**
|
||||
* @brief Présente le dialogue de saisie des métadonnées.
|
||||
*
|
||||
* Le chemin du fichier est affiché en lecture seule.
|
||||
*
|
||||
* evidence_types doit contenir des pointeurs EvidenceType valides.
|
||||
* Le dialogue copie les codes et les libellés dont il a besoin :
|
||||
* le tableau peut donc être libéré après le retour de cette fonction.
|
||||
*
|
||||
* Le type dont le code vaut "document" est sélectionné par défaut
|
||||
* lorsqu'il est disponible. Sinon, le premier type est sélectionné.
|
||||
*
|
||||
* @param parent Fenêtre GTK parente.
|
||||
* @param file_path Chemin du fichier sélectionné.
|
||||
* @param evidence_types Tableau de EvidenceType.
|
||||
* @param callback Fonction appelée après validation ou annulation.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
* @param error Emplacement facultatif recevant une erreur immédiate.
|
||||
*
|
||||
* @return TRUE si le dialogue a été créé et présenté.
|
||||
*/
|
||||
gboolean evidence_import_dialog_present(
|
||||
GtkWindow *parent,
|
||||
const char *file_path,
|
||||
const GPtrArray *evidence_types,
|
||||
EvidenceImportDialogCallback callback,
|
||||
gpointer user_data,
|
||||
GError **error
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Valide les valeurs obligatoires d'un import.
|
||||
*
|
||||
* Cette fonction ne dépend pas de l'affichage du dialogue et peut être
|
||||
* testée sans ouvrir de fenêtre GTK.
|
||||
*
|
||||
* Le chemin doit désigner un fichier existant.
|
||||
* Le code du type doit être non vide.
|
||||
* La date doit être une date ISO 8601 valide comprenant un fuseau horaire.
|
||||
*
|
||||
* @param file_path Chemin du fichier à importer.
|
||||
* @param type_identifier Code métier du type de preuve.
|
||||
* @param collected_at Date de collecte au format ISO 8601.
|
||||
* @param error Emplacement facultatif recevant une erreur.
|
||||
*
|
||||
* @return TRUE lorsque les valeurs sont valides.
|
||||
*/
|
||||
gboolean evidence_import_dialog_validate_values(
|
||||
const char *file_path,
|
||||
const char *type_identifier,
|
||||
const char *collected_at,
|
||||
GError **error
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Libère le résultat produit par le dialogue.
|
||||
*
|
||||
* Cette fonction accepte NULL.
|
||||
*
|
||||
* @param result Résultat à libérer.
|
||||
*/
|
||||
void evidence_import_dialog_result_free(
|
||||
EvidenceImportDialogResult *result
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne le code du type sélectionné.
|
||||
*
|
||||
* @return Chaîne empruntée, ou NULL.
|
||||
*/
|
||||
const char *evidence_import_dialog_result_get_type_identifier(
|
||||
const EvidenceImportDialogResult *result
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne la date ISO 8601 de collecte.
|
||||
*
|
||||
* @return Chaîne empruntée, ou NULL.
|
||||
*/
|
||||
const char *evidence_import_dialog_result_get_collected_at(
|
||||
const EvidenceImportDialogResult *result
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne la source renseignée.
|
||||
*
|
||||
* @return Chaîne empruntée, ou NULL lorsque la source est vide.
|
||||
*/
|
||||
const char *evidence_import_dialog_result_get_source(
|
||||
const EvidenceImportDialogResult *result
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne la description renseignée.
|
||||
*
|
||||
* @return Chaîne empruntée, ou NULL lorsque la description est vide.
|
||||
*/
|
||||
const char *evidence_import_dialog_result_get_description(
|
||||
const EvidenceImportDialogResult *result
|
||||
);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @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 "database/database.h"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
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
|
||||
);
|
||||
typedef void (*EvidenceMetadataDialogAnalyzeCallback)(
|
||||
GtkWindow *dialog, const char *evidence_identifier,
|
||||
gboolean revise_existing, const char *ocr_run_identifier,
|
||||
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
|
||||
);
|
||||
gboolean evidence_metadata_dialog_present_with_ocr(
|
||||
GtkWindow *parent, const EvidenceRecord *record,
|
||||
const GPtrArray *evidence_types, Database *database,
|
||||
const char *investigation_root,
|
||||
EvidenceMetadataDialogAnalyzeCallback analyze_callback,
|
||||
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
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file file_dialog.h
|
||||
* @brief Interface du dialogue de sélection d’un fichier de preuve.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_FILE_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_FILE_DIALOG_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
/**
|
||||
* @brief Fonction appelée après la fermeture du sélecteur.
|
||||
*
|
||||
* Cas possibles :
|
||||
*
|
||||
* - succès : file_path non NULL et error NULL ;
|
||||
* - annulation : file_path NULL et error NULL ;
|
||||
* - erreur : file_path NULL et error non NULL.
|
||||
*
|
||||
* Le chemin et l’erreur restent valides uniquement pendant l’appel.
|
||||
*
|
||||
* @param file_path Chemin local sélectionné, ou NULL.
|
||||
* @param error Erreur de sélection, ou NULL.
|
||||
* @param user_data Données transmises lors de l’ouverture.
|
||||
*/
|
||||
typedef void (*FileDialogCallback)(
|
||||
const char *file_path,
|
||||
const GError *error,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Fonction appelée après une sélection multiple.
|
||||
*
|
||||
* paths est un tableau emprunté de chemins locaux possédés par le dialogue.
|
||||
* Il n'est valide que pendant l'appel. Une annulation transmet NULL sans
|
||||
* erreur.
|
||||
*
|
||||
* @param paths Chemins sélectionnés, ou NULL.
|
||||
* @param error Erreur de sélection, ou NULL.
|
||||
* @param user_data Données fournies lors de l'ouverture.
|
||||
*/
|
||||
typedef void (*FileDialogMultipleCallback)(
|
||||
const GPtrArray *paths,
|
||||
const GError *error,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Ouvre un dialogue permettant de sélectionner un fichier.
|
||||
*
|
||||
* @param parent Fenêtre parente, éventuellement NULL.
|
||||
* @param callback Fonction appelée lorsque le dialogue est terminé.
|
||||
* @param user_data Données transmises au callback.
|
||||
*/
|
||||
void file_dialog_select_file(
|
||||
GtkWindow *parent,
|
||||
FileDialogCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Ouvre un dialogue permettant de sélectionner plusieurs fichiers.
|
||||
* @param parent Fenêtre parente, éventuellement NULL.
|
||||
* @param callback Fonction appelée à la fermeture du dialogue.
|
||||
* @param user_data Données transmises au callback.
|
||||
*/
|
||||
void file_dialog_select_files(
|
||||
GtkWindow *parent,
|
||||
FileDialogMultipleCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
#endif
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file folder_dialog.h
|
||||
* @brief Interface du dialogue de sélection d'un dossier d'enquête.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_FOLDER_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_FOLDER_DIALOG_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
/**
|
||||
* @brief Fonction appelée après la fermeture du dialogue de sélection.
|
||||
*
|
||||
* @param folder_path Chemin du dossier sélectionné, ou NULL si l'utilisateur
|
||||
* a annulé la sélection.
|
||||
* @param user_data Données transmises lors de l'ouverture du dialogue.
|
||||
*
|
||||
* Le chemin reçu reste valide uniquement pendant l'appel de cette fonction.
|
||||
* Il doit être copié si le code appelant souhaite le conserver.
|
||||
*
|
||||
* @param callback
|
||||
* Fonction appelée lorsque le dialogue est fermé.
|
||||
*
|
||||
* Si l'utilisateur annule la sélection,
|
||||
* folder_path vaut NULL.
|
||||
*/
|
||||
typedef void (*FolderDialogCallback)(
|
||||
const char *folder_path,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Ouvre un dialogue permettant de sélectionner un dossier.
|
||||
*
|
||||
* @param parent Fenêtre parente du dialogue.
|
||||
* Peut être NULL.
|
||||
* @param callback Fonction appelée lorsque le dialogue est terminé.
|
||||
* @param user_data Données transmises à la fonction de rappel.
|
||||
*/
|
||||
void folder_dialog_select_folder(
|
||||
GtkWindow *parent,
|
||||
FolderDialogCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
#endif
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
#ifndef IDENTITY_OCR_LANGUAGE_LOADER_H
|
||||
#define IDENTITY_OCR_LANGUAGE_LOADER_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
void identity_ocr_language_loader_start(GtkWindow *window, const char *tesseract_path, GtkDropDown *language_dropdown);
|
||||
|
||||
#endif
|
||||
|
|
@ -1,758 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file main_window.h
|
||||
* @brief Interface publique de la fenêtre principale.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_MAIN_WINDOW_H
|
||||
#define LABFY_INVESTIGATION_MAIN_WINDOW_H
|
||||
|
||||
#include "core/investigation_tree_model.h"
|
||||
#include "widgets/investigation_tree_view.h"
|
||||
#include "core/investigation_node.h"
|
||||
#include "core/task_manager.h"
|
||||
#include "widgets/evidence_category_model.h"
|
||||
#include "models/evidence_record.h"
|
||||
#include "models/osint_action_catalog.h"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque du graphe d'enquête.
|
||||
*/
|
||||
typedef struct InvestigationGraphModel InvestigationGraphModel;
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque de la disposition du graphe.
|
||||
*/
|
||||
typedef struct InvestigationGraphLayout InvestigationGraphLayout;
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque de la fenêtre principale.
|
||||
*/
|
||||
typedef struct MainWindow MainWindow;
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsque l'utilisateur demande une nouvelle enquête.
|
||||
*
|
||||
* @param user_data Données utilisateur associées au callback.
|
||||
*/
|
||||
typedef void (*MainWindowNewInvestigationCallback)(
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsque l'utilisateur demande
|
||||
* l'import d'une preuve.
|
||||
*
|
||||
* @param user_data Données utilisateur associées au callback.
|
||||
*/
|
||||
typedef void (*MainWindowImportEvidenceCallback)(
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Callback appelé pour ajouter un compte de réseau social. */
|
||||
typedef void (*MainWindowAddSocialAccountCallback)(
|
||||
gpointer user_data
|
||||
);
|
||||
/** @brief Callback appelé pour ajouter une personne. */
|
||||
typedef void (*MainWindowAddPersonCallback)(gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsque l'utilisateur revient au graphe.
|
||||
*
|
||||
* @param user_data Données utilisateur associées au callback.
|
||||
*/
|
||||
typedef void (*MainWindowShowGraphCallback)(
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsqu'une preuve est sélectionnée.
|
||||
*
|
||||
* L'identifiant est emprunté et uniquement valide pendant l'appel.
|
||||
* Une sélection vide ou une catégorie transmet NULL.
|
||||
*
|
||||
* @param evidence_identifier UUID de la preuve, ou NULL.
|
||||
* @param user_data Données privées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*MainWindowEvidenceSelectionCallback)(
|
||||
const char *evidence_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsque l'utilisateur demande une vérification.
|
||||
*
|
||||
* L'identifiant est emprunté et uniquement valide pendant l'appel.
|
||||
*
|
||||
* @param evidence_identifier UUID de la preuve.
|
||||
* @param user_data Données privées du callback.
|
||||
*/
|
||||
typedef void (*MainWindowVerifyEvidenceCallback)(
|
||||
const char *evidence_identifier,
|
||||
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é pour analyser une preuve EML. */
|
||||
typedef void (*MainWindowAnalyzeEmlCallback)(const char *evidence_identifier,
|
||||
gpointer user_data);
|
||||
/** @brief Callback appelé pour analyser un RIB par OCR. */
|
||||
typedef void (*MainWindowAnalyzeRibCallback)(const char *evidence_identifier,
|
||||
gpointer user_data);
|
||||
typedef void (*MainWindowIdentityOcrCallback)(
|
||||
const char *evidence_identifier, gboolean revise_existing,
|
||||
const char *ocr_run_identifier, gpointer user_data);
|
||||
/** @brief Callback appelé pour extraire les métadonnées d'une preuve. */
|
||||
typedef void (*MainWindowExtractMetadataCallback)(const char *evidence_identifier,
|
||||
gpointer user_data);
|
||||
/** @brief Callback appelé pour récupérer le mot de passe d'un PDF. */
|
||||
typedef void (*MainWindowRecoverPdfPasswordCallback)(
|
||||
const char *evidence_identifier, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé après le déplacement effectif d'un nœud.
|
||||
*
|
||||
* @param entity_identifier UUID de l'entité déplacée.
|
||||
* @param x Coordonnée horizontale logique.
|
||||
* @param y Coordonnée verticale logique.
|
||||
* @param user_data Données privées du callback.
|
||||
*/
|
||||
typedef void (*MainWindowGraphNodeMovedCallback)(
|
||||
const char *entity_identifier,
|
||||
double x,
|
||||
double y,
|
||||
gpointer user_data
|
||||
);
|
||||
typedef void (*MainWindowGraphTransformChangedCallback)(gpointer user_data);
|
||||
|
||||
typedef void (*MainWindowExtractionDropCallback)(
|
||||
const char *file_path,
|
||||
const char *target_entity_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lors d'une demande de réinitialisation du graphe.
|
||||
*
|
||||
* @param user_data Données privées du callback.
|
||||
*/
|
||||
typedef void (*MainWindowResetGraphLayoutCallback)(
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lors d'une demande d'ajout d'une relation.
|
||||
*
|
||||
* @param source_entity_identifier UUID de l'entité source.
|
||||
* @param user_data Données privées du callback.
|
||||
*/
|
||||
typedef void (*MainWindowAddRelationCallback)(
|
||||
const char *source_entity_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Callback appelé pour modifier une relation. */
|
||||
typedef void (*MainWindowEditRelationCallback)(
|
||||
const char *relation_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
/** @brief Callback appelé pour supprimer une relation. */
|
||||
typedef void (*MainWindowDeleteRelationCallback)(
|
||||
const char *relation_identifier, gpointer user_data);
|
||||
|
||||
/** @brief Callback appelé quand une relation est sélectionnée. */
|
||||
typedef void (*MainWindowRelationSelectedCallback)(
|
||||
const char *relation_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Callback appelé pour catégoriser une personne. */
|
||||
typedef void (*MainWindowPersonRoleCallback)(const char *entity_identifier,
|
||||
PersonRole role, gpointer user_data);
|
||||
/** @brief Callback appelé pour modifier la confiance d'une personne. */
|
||||
typedef void (*MainWindowPersonConfidenceCallback)(
|
||||
const char *entity_identifier, gint confidence, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lors du déclenchement d'une action OSINT.
|
||||
*
|
||||
* @param action_identifier Identifiant stable de l'action.
|
||||
* @param target_identifier UUID de la sélection ciblée.
|
||||
* @param target_value Valeur métier ciblée.
|
||||
* @param user_data Données privées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*MainWindowOsintActionCallback)(
|
||||
const char *action_identifier,
|
||||
const char *target_identifier,
|
||||
const char *target_value,
|
||||
gpointer user_data
|
||||
);
|
||||
/** @brief Callback appelé pour modifier le nom affiché d'une personne. */
|
||||
typedef void (*MainWindowPersonNameCallback)(const char *entity_identifier,
|
||||
const char *display_name, gpointer user_data);
|
||||
/** @brief Callback appelé pour gérer les preuves d'une personne. */
|
||||
typedef void (*MainWindowPersonEvidenceCallback)(
|
||||
const char *entity_identifier, gpointer user_data);
|
||||
/** @brief Callback appelé quand une entité est sélectionnée. */
|
||||
typedef void (*MainWindowEntitySelectedCallback)(
|
||||
const char *entity_identifier, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de vérification d'une preuve.
|
||||
*
|
||||
* MainWindow relaie la demande provenant du Workspace.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void main_window_set_verify_evidence_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowVerifyEvidenceCallback 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 d'analyse locale d'une preuve EML. */
|
||||
void main_window_set_analyze_eml_callback(MainWindow *main_window,
|
||||
MainWindowAnalyzeEmlCallback callback, gpointer user_data);
|
||||
/** @brief Définit le callback d'analyse OCR d'un RIB. */
|
||||
void main_window_set_analyze_rib_callback(MainWindow *main_window,
|
||||
MainWindowAnalyzeRibCallback callback, gpointer user_data);
|
||||
/** @brief Définit le callback d'extraction locale des métadonnées. */
|
||||
void main_window_set_extract_metadata_callback(MainWindow *main_window,
|
||||
MainWindowExtractMetadataCallback callback, gpointer user_data);
|
||||
/** @brief Définit le callback de récupération d'un mot de passe PDF. */
|
||||
void main_window_set_recover_pdf_password_callback(MainWindow *main_window,
|
||||
MainWindowRecoverPdfPasswordCallback callback, gpointer user_data);
|
||||
void main_window_set_identity_ocr_callback(
|
||||
MainWindow *main_window, MainWindowIdentityOcrCallback callback,
|
||||
gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de fin de déplacement d'un nœud.
|
||||
*
|
||||
* MainWindow relaie uniquement l'événement provenant du Workspace.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void main_window_set_graph_node_moved_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowGraphNodeMovedCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
void main_window_set_graph_transform_changed_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowGraphTransformChangedCallback callback,
|
||||
gpointer user_data);
|
||||
|
||||
void main_window_set_extraction_drop_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowExtractionDropCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de demande de réinitialisation du graphe.
|
||||
*
|
||||
* MainWindow relaie uniquement la demande provenant du Workspace.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void main_window_set_reset_graph_layout_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowResetGraphLayoutCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de demande d'ajout d'une relation.
|
||||
*
|
||||
* MainWindow relaie uniquement la demande provenant du Workspace.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void main_window_set_add_relation_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowAddRelationCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Définit le callback de modification d'une relation. */
|
||||
void main_window_set_edit_relation_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowEditRelationCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
/** @brief Définit le callback de suppression d'une relation. */
|
||||
void main_window_set_delete_relation_callback(MainWindow *main_window,
|
||||
MainWindowDeleteRelationCallback callback, gpointer user_data);
|
||||
|
||||
/** @brief Définit le callback de sélection d'une relation. */
|
||||
void main_window_set_relation_selected_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowRelationSelectedCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Affiche les preuves associées à la relation sélectionnée. */
|
||||
void main_window_set_relation_evidences(
|
||||
MainWindow *main_window,
|
||||
const GPtrArray *evidence_records
|
||||
);
|
||||
|
||||
/** @brief Transmet les preuves associées à l'entité sélectionnée. */
|
||||
void main_window_set_person_evidences(MainWindow *main_window,
|
||||
const GPtrArray *records);
|
||||
void main_window_set_person_factual_relations(MainWindow *main_window,
|
||||
const GPtrArray *relations, const GPtrArray *evidence_records,const GHashTable *profile_fields);
|
||||
|
||||
|
||||
/** @brief Définit le callback de catégorisation d'une personne. */
|
||||
void main_window_set_person_role_callback(MainWindow *main_window,
|
||||
MainWindowPersonRoleCallback callback, gpointer user_data);
|
||||
/** @brief Définit le callback de confiance d'une personne. */
|
||||
void main_window_set_person_confidence_callback(MainWindow *main_window,
|
||||
MainWindowPersonConfidenceCallback callback, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de déclenchement des actions OSINT.
|
||||
*
|
||||
* MainWindow relaie uniquement la demande provenant du Workspace.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void main_window_set_osint_action_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowOsintActionCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
/** @brief Définit le callback de modification du nom affiché. */
|
||||
void main_window_set_person_name_callback(MainWindow *main_window,
|
||||
MainWindowPersonNameCallback callback, gpointer user_data);
|
||||
/** @brief Définit le callback de gestion des preuves d'une personne. */
|
||||
void main_window_set_person_evidence_callback(MainWindow *main_window,
|
||||
MainWindowPersonEvidenceCallback callback, gpointer user_data);
|
||||
/** @brief Définit le callback de sélection d'une entité. */
|
||||
void main_window_set_entity_selected_callback(MainWindow *main_window,
|
||||
MainWindowEntitySelectedCallback callback, gpointer user_data);
|
||||
/** @brief Affiche les preuves de la personne sélectionnée. */
|
||||
void main_window_set_person_evidences(MainWindow *main_window,
|
||||
const GPtrArray *evidence_records);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback du bouton « Nouvelle enquête ».
|
||||
*
|
||||
* La fenêtre ne crée pas directement l'enquête. Elle transmet uniquement
|
||||
* la demande au contrôleur.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param callback Fonction appelée lors du clic.
|
||||
* @param user_data Données transmises au callback.
|
||||
*/
|
||||
void main_window_set_new_investigation_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowNewInvestigationCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Crée une nouvelle fenêtre principale.
|
||||
*
|
||||
* MainWindow ne devient pas propriétaire de task_manager.
|
||||
*
|
||||
* @param application Application GTK.
|
||||
* @param task_manager Gestionnaire de tâches de l'application.
|
||||
*
|
||||
* @return Nouvelle fenêtre, ou NULL en cas d'échec.
|
||||
*/
|
||||
MainWindow *main_window_new(
|
||||
GtkApplication *application,
|
||||
TaskManager *task_manager
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche la fenêtre principale.
|
||||
*
|
||||
* @param main_window Fenêtre à afficher.
|
||||
*/
|
||||
void main_window_present(MainWindow *main_window);
|
||||
|
||||
/**
|
||||
* @brief Retourne la fenêtre GTK associée.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
*
|
||||
* @return Fenêtre GTK.
|
||||
*/
|
||||
GtkWindow *main_window_get_window(
|
||||
const MainWindow *main_window
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Transmet un modèle d'arborescence à la fenêtre principale.
|
||||
*
|
||||
* La fenêtre principale ne devient pas propriétaire du modèle.
|
||||
* Elle le transmet uniquement à la Sidebar.
|
||||
*
|
||||
* @param main_window Fenêtre principale à mettre à jour.
|
||||
* @param tree_model Modèle d'arborescence en lecture seule.
|
||||
*/
|
||||
void main_window_set_tree_model(
|
||||
MainWindow *main_window,
|
||||
const InvestigationTreeModel *tree_model
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Transmet le modèle des catégories de preuves à la Sidebar.
|
||||
*
|
||||
* MainWindow ne devient pas propriétaire du modèle.
|
||||
* Le modèle doit rester valide pendant son affichage.
|
||||
*
|
||||
* Passer NULL vide l'onglet Preuves.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param evidence_category_model Modèle de catégories, ou NULL.
|
||||
*/
|
||||
void main_window_set_evidence_model(
|
||||
MainWindow *main_window,
|
||||
EvidenceCategoryModel *evidence_category_model
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Met à jour l'identité de l'enquête affichée.
|
||||
*
|
||||
* La fonction met à jour :
|
||||
*
|
||||
* - le titre de la fenêtre ;
|
||||
* - le texte de la barre d'état.
|
||||
*
|
||||
* Les chaînes sont copiées par GTK et restent la propriété de l'appelant.
|
||||
*
|
||||
* Cette fonction accepte des chaînes NULL ou vides.
|
||||
*
|
||||
* @param main_window Fenêtre principale à mettre à jour.
|
||||
* @param investigation_name Nom persistant de l'enquête.
|
||||
* @param investigation_root_path Chemin racine de l'enquête.
|
||||
*/
|
||||
void main_window_set_investigation(
|
||||
MainWindow *main_window,
|
||||
const char *investigation_name,
|
||||
const char *investigation_root_path
|
||||
);
|
||||
|
||||
void main_window_set_tree_selection_callback(
|
||||
MainWindow *main_window,
|
||||
InvestigationTreeViewSelectionCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de sélection d'une preuve.
|
||||
*
|
||||
* MainWindow transmet uniquement le callback à la Sidebar.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void main_window_set_evidence_selection_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowEvidenceSelectionCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche dans le Workspace les informations du nœud sélectionné.
|
||||
*
|
||||
* La fenêtre principale ne devient pas propriétaire du nœud.
|
||||
*
|
||||
* @param main_window Fenêtre principale à mettre à jour.
|
||||
* @param node Nœud sélectionné en lecture seule, ou NULL.
|
||||
*/
|
||||
void main_window_set_selected_node(
|
||||
MainWindow *main_window,
|
||||
const InvestigationNode *node
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche la fiche de la preuve sélectionnée.
|
||||
*
|
||||
* MainWindow ne conserve pas le EvidenceRecord.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param evidence_record Preuve sélectionnée, ou NULL.
|
||||
*/
|
||||
void main_window_set_selected_evidence(
|
||||
MainWindow *main_window,
|
||||
const EvidenceRecord *evidence_record
|
||||
);
|
||||
void main_window_set_identity_ocr_runs(
|
||||
MainWindow *main_window, GPtrArray *owned_records);
|
||||
void main_window_set_evidence_observations(MainWindow *main_window,
|
||||
const GPtrArray *observations);
|
||||
void main_window_set_observation_remove_callback(MainWindow *main_window,
|
||||
void (*callback)(const char *, gpointer), gpointer user_data);
|
||||
void main_window_set_eml_analysis_available(
|
||||
MainWindow *main_window,
|
||||
gboolean available
|
||||
);
|
||||
|
||||
/** @brief Transmet au workspace un chemin absolu validé pour l'aperçu. */
|
||||
void main_window_set_evidence_preview(MainWindow *main_window,
|
||||
const char *file_path, const char *display_name);
|
||||
void main_window_show_evidence_preview(MainWindow *main_window,
|
||||
const char *investigation_root, const EvidenceRecord *record);
|
||||
|
||||
|
||||
/**
|
||||
* @brief Affiche l'état de chargement du graphe.
|
||||
*
|
||||
* MainWindow transmet uniquement la demande au Workspace.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
*/
|
||||
void main_window_set_graph_loading(
|
||||
MainWindow *main_window
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche le graphe chargé et sa disposition dans le Workspace.
|
||||
*
|
||||
* MainWindow et Workspace empruntent graph_model et graph_layout.
|
||||
*
|
||||
* Passer NULL pour graph_layout conserve le placement automatique.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param graph_model Graphe emprunté, ou NULL.
|
||||
* @param graph_layout Disposition empruntée, ou NULL.
|
||||
*/
|
||||
void main_window_set_graph(
|
||||
MainWindow *main_window,
|
||||
const InvestigationGraphModel *graph_model,
|
||||
const InvestigationGraphLayout *graph_layout
|
||||
);
|
||||
|
||||
gboolean main_window_get_graph_view_transform(
|
||||
MainWindow *main_window, double *zoom, double *offset_x, double *offset_y);
|
||||
void main_window_set_graph_view_transform(
|
||||
MainWindow *main_window, double zoom, double offset_x, double offset_y);
|
||||
|
||||
/** @brief Sélectionne une relation dans le graphe affiché. */
|
||||
gboolean main_window_select_graph_relation(
|
||||
MainWindow *main_window,
|
||||
const char *relation_identifier
|
||||
);
|
||||
/** @brief Sélectionne une entité dans le graphe affiché. */
|
||||
gboolean main_window_select_graph_entity(MainWindow *main_window,
|
||||
const char *entity_identifier);
|
||||
|
||||
/**
|
||||
* @brief Affiche une erreur de chargement du graphe.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param message Message d'erreur, ou NULL.
|
||||
*/
|
||||
void main_window_set_graph_error(
|
||||
MainWindow *main_window,
|
||||
const char *message
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Détache le graphe du Workspace.
|
||||
*
|
||||
* Aucun graphe n'est libéré par MainWindow.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
*/
|
||||
void main_window_clear_graph(
|
||||
MainWindow *main_window
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Reconstruit visuellement la disposition automatique du graphe.
|
||||
*
|
||||
* Cette fonction ne modifie pas SQLite.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
*/
|
||||
void main_window_reset_graph_layout(
|
||||
MainWindow *main_window
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Met à jour le texte de la barre d'état.
|
||||
*
|
||||
* Si status_text vaut NULL, le texte par défaut est affiché.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param status_text Nouveau texte de statut, ou NULL.
|
||||
*/
|
||||
void main_window_set_status(
|
||||
MainWindow *main_window,
|
||||
const char *status_text
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Transmet au workspace l'état d'un outil OSINT externe.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param tool_identifier Identifiant stable de l'outil.
|
||||
* @param state État traduit par l'application.
|
||||
* @param version Version détectée facultative.
|
||||
*/
|
||||
void main_window_set_osint_tool_state(
|
||||
MainWindow *main_window,
|
||||
const char *tool_identifier,
|
||||
OsintActionToolState state,
|
||||
const char *version
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsque l'utilisateur demande l'ouverture
|
||||
* d'une enquête existante.
|
||||
*
|
||||
* @param user_data Données utilisateur associées au callback.
|
||||
*/
|
||||
typedef void (*MainWindowOpenInvestigationCallback)(
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsque l'utilisateur demande à quitter.
|
||||
*
|
||||
* @param user_data Données utilisateur associées au callback.
|
||||
*/
|
||||
typedef void (*MainWindowQuitCallback)(
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback du bouton « Ouvrir une enquête ».
|
||||
*
|
||||
* La fenêtre transmet uniquement la demande au contrôleur.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param callback Fonction appelée lors du clic.
|
||||
* @param user_data Données transmises au callback.
|
||||
*/
|
||||
void main_window_set_open_investigation_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowOpenInvestigationCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback du bouton « Quitter ».
|
||||
*
|
||||
* MainWindow transmet uniquement la demande au contrôleur.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param callback Fonction appelée lors du clic.
|
||||
* @param user_data Données transmises au callback.
|
||||
*/
|
||||
void main_window_set_quit_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowQuitCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de l'action « Importer une preuve ».
|
||||
*
|
||||
* MainWindow transmet uniquement la demande au contrôleur.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param callback Fonction appelée lors du clic.
|
||||
* @param user_data Données transmises au callback.
|
||||
*/
|
||||
void main_window_set_import_evidence_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowImportEvidenceCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de l'action d'ajout d'un compte social.
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void main_window_set_add_social_account_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowAddSocialAccountCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
/** @brief Définit le callback d'ajout d'une personne. */
|
||||
void main_window_set_add_person_callback(MainWindow *main_window,
|
||||
MainWindowAddPersonCallback callback, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback du bouton « Revenir au graphe ».
|
||||
*
|
||||
* MainWindow transmet uniquement la demande au contrôleur.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param callback Fonction appelée lors du clic, ou NULL.
|
||||
* @param user_data Données transmises au callback.
|
||||
*/
|
||||
void main_window_set_show_graph_callback(
|
||||
MainWindow *main_window,
|
||||
MainWindowShowGraphCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Active ou désactive l'action d'import d'une preuve.
|
||||
*
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param enabled TRUE pour autoriser l'import.
|
||||
*/
|
||||
void main_window_set_import_evidence_enabled(
|
||||
MainWindow *main_window,
|
||||
gboolean enabled
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Active ou désactive l'ajout d'un compte social.
|
||||
* @param main_window Fenêtre principale.
|
||||
* @param enabled TRUE si une enquête est ouverte.
|
||||
*/
|
||||
void main_window_set_add_social_account_enabled(
|
||||
MainWindow *main_window,
|
||||
gboolean enabled
|
||||
);
|
||||
/** @brief Active ou désactive l'ajout d'une personne. */
|
||||
void main_window_set_add_person_enabled(MainWindow *main_window, gboolean enabled);
|
||||
|
||||
/**
|
||||
* @brief Libère les ressources de la fenêtre.
|
||||
*
|
||||
* @param main_window Fenêtre à détruire.
|
||||
*/
|
||||
void main_window_free(MainWindow *main_window);
|
||||
|
||||
#endif
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file manage_entity_evidence_dialog.h
|
||||
* @brief Dialogue de sélection des preuves associées à une entité.
|
||||
******************************************************************************/
|
||||
#ifndef LABFY_INVESTIGATION_MANAGE_ENTITY_EVIDENCE_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_MANAGE_ENTITY_EVIDENCE_DIALOG_H
|
||||
#include "models/evidence_record.h"
|
||||
#include "core/task_manager.h"
|
||||
#include <gtk/gtk.h>
|
||||
G_BEGIN_DECLS
|
||||
typedef void (*ManageEntityEvidenceDialogCallback)(
|
||||
GPtrArray *selected_identifiers, gpointer user_data);
|
||||
typedef void (*ManageEntityEvidenceImportCallback)(gpointer user_data);
|
||||
/**
|
||||
* @brief Présente la sélection multiple de preuves avec aperçu.
|
||||
* @param parent Fenêtre parente.
|
||||
* @param evidence_records Preuves disponibles empruntées.
|
||||
* @param selected_identifiers UUID déjà associés empruntés.
|
||||
* @param investigation_root_path Racine de l'enquête.
|
||||
* @param callback Callback recevant un tableau possédé, ou NULL à l'annulation.
|
||||
* @param user_data Données empruntées du callback.
|
||||
* @param error Destination facultative d'une erreur immédiate.
|
||||
* @return TRUE si le dialogue a été présenté.
|
||||
*/
|
||||
gboolean manage_entity_evidence_dialog_present(GtkWindow *parent,
|
||||
const GPtrArray *evidence_records, const GPtrArray *selected_identifiers,
|
||||
const char *investigation_root_path,
|
||||
TaskManager *task_manager,
|
||||
ManageEntityEvidenceDialogCallback callback, gpointer user_data,
|
||||
ManageEntityEvidenceImportCallback import_callback,
|
||||
GError **error);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file metadata_analysis_dialog.h
|
||||
* @brief Fenêtre de consultation des métadonnées extraites.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_METADATA_ANALYSIS_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_METADATA_ANALYSIS_DIALOG_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
/**
|
||||
* @brief Affiche une synthèse ExifTool dans une fenêtre défilable.
|
||||
* @param parent Fenêtre parente facultative.
|
||||
* @param summary Métadonnées lisibles empruntées.
|
||||
* @param json_path Chemin emprunté de la sortie JSON conservée.
|
||||
* @param version Version empruntée d'ExifTool.
|
||||
*/
|
||||
void metadata_analysis_dialog_present(GtkWindow *parent, const char *summary,
|
||||
const char *json_path, const char *version);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file osint_dns_review_dialog.h
|
||||
* @brief Dialogue de sélection des propositions issues d'une réponse DNS.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_OSINT_DNS_REVIEW_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_OSINT_DNS_REVIEW_DIALOG_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
/**
|
||||
* @brief Callback appelé après confirmation de la sélection DNS.
|
||||
*
|
||||
* Le tableau et les propositions sont empruntés et uniquement valides pendant
|
||||
* l'appel. Le tableau contient seulement les propositions compatibles cochées.
|
||||
*
|
||||
* @param selected_proposals Propositions sélectionnées.
|
||||
* @param user_data Données empruntées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*OsintDnsReviewDialogConfirmCallback)(
|
||||
GPtrArray *selected_proposals,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche le dialogue de sélection avant intégration.
|
||||
*
|
||||
* Le dialogue conserve une référence sur proposals jusqu'à sa fermeture.
|
||||
* Fermer ou annuler ne déclenche pas le callback.
|
||||
*
|
||||
* @param parent_window Fenêtre parente, ou NULL.
|
||||
* @param proposals Tableau de OsintDnsProposal à réviser.
|
||||
* @param callback Callback de confirmation facultatif.
|
||||
* @param user_data Données empruntées transmises au callback.
|
||||
*/
|
||||
void osint_dns_review_dialog_present(
|
||||
GtkWindow *parent_window,
|
||||
GPtrArray *proposals,
|
||||
OsintDnsReviewDialogConfirmCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file osint_execution_history_dialog.h
|
||||
* @brief Consultation en lecture seule de l'historique OSINT.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_OSINT_EXECUTION_HISTORY_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_OSINT_EXECUTION_HISTORY_DIALOG_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
/**
|
||||
* @brief Affiche les exécutions et leurs objets liés dans une fenêtre modale.
|
||||
*
|
||||
* La fonction conserve une référence sur les deux conteneurs jusqu'à la
|
||||
* fermeture. records contient des OsintExecutionRecord. linked_objects associe
|
||||
* chaque UUID d'exécution à un GPtrArray de chaînes.
|
||||
*
|
||||
* @param parent_window Fenêtre parente, ou NULL.
|
||||
* @param records Historique non vide, de la plus récente à l'ancienne.
|
||||
* @param linked_objects Table des objets liés par UUID d'exécution.
|
||||
*/
|
||||
void osint_execution_history_dialog_present(
|
||||
GtkWindow *parent_window,
|
||||
GPtrArray *records,
|
||||
GHashTable *linked_objects
|
||||
);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file pdf_password_dialog.h
|
||||
* @brief Paramétrage d'une récupération de mot de passe PDF.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_PDF_PASSWORD_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_PDF_PASSWORD_DIALOG_H
|
||||
|
||||
#include "core/pdf_password_recovery.h"
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
/** @brief Callback recevant la méthode et son paramètre validé. */
|
||||
typedef void (*PdfPasswordDialogCallback)(PdfPasswordRecoveryMethod method,
|
||||
const char *parameter, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Présente le formulaire de récupération ciblée.
|
||||
* @param parent Fenêtre parente facultative.
|
||||
* @param callback Callback appelé après validation.
|
||||
* @param user_data Données empruntées du callback.
|
||||
*/
|
||||
void pdf_password_dialog_present(GtkWindow *parent,
|
||||
PdfPasswordDialogCallback callback, gpointer user_data);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
#ifndef LABFY_PERSON_FACTUAL_RELATION_EDITOR_H
|
||||
#define LABFY_PERSON_FACTUAL_RELATION_EDITOR_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
#include "core/person_creation_coordinator.h"
|
||||
#include "models/identity_ocr.h"
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
typedef struct PersonFactualRelationEditor PersonFactualRelationEditor;
|
||||
|
||||
PersonFactualRelationEditor *person_factual_relation_editor_new(void);
|
||||
|
||||
GtkWidget *person_factual_relation_editor_get_widget(
|
||||
PersonFactualRelationEditor *editor);
|
||||
|
||||
void person_factual_relation_editor_set_available_evidence(
|
||||
PersonFactualRelationEditor *editor,
|
||||
GtkStringList *evidence_labels,
|
||||
GPtrArray *evidence_identifiers);
|
||||
|
||||
void person_factual_relation_editor_set_available_ocr_runs(
|
||||
PersonFactualRelationEditor *editor,
|
||||
GPtrArray *ocr_runs);
|
||||
|
||||
gboolean person_factual_relation_editor_collect_relations(
|
||||
PersonFactualRelationEditor *editor, GPtrArray **relations,
|
||||
GError **error);
|
||||
guint person_factual_relation_editor_get_count(
|
||||
const PersonFactualRelationEditor *editor);
|
||||
gboolean person_factual_relation_editor_validate(
|
||||
PersonFactualRelationEditor *editor, GError **error);
|
||||
void person_factual_relation_editor_append_summary(
|
||||
PersonFactualRelationEditor *editor, GString *summary);
|
||||
|
||||
void person_factual_relation_editor_free(PersonFactualRelationEditor *editor);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
#ifndef LABFY_PERSON_OCR_PROJECTION_EDITOR_H
|
||||
#define LABFY_PERSON_OCR_PROJECTION_EDITOR_H
|
||||
#include "models/identity_ocr.h"
|
||||
#include "models/person_ocr_projection.h"
|
||||
#include <gtk/gtk.h>
|
||||
G_BEGIN_DECLS
|
||||
typedef struct PersonOcrProjectionEditor PersonOcrProjectionEditor;
|
||||
PersonOcrProjectionEditor *person_ocr_projection_editor_new(void);
|
||||
GtkWidget *person_ocr_projection_editor_get_widget(PersonOcrProjectionEditor *editor);
|
||||
void person_ocr_projection_editor_set_runs(PersonOcrProjectionEditor *editor,const GPtrArray *runs);
|
||||
gboolean person_ocr_projection_editor_collect(PersonOcrProjectionEditor *editor,GPtrArray **projections,GError **error);
|
||||
void person_ocr_projection_editor_append_summary(
|
||||
PersonOcrProjectionEditor *editor, GString *summary);
|
||||
void person_ocr_projection_editor_free(PersonOcrProjectionEditor *editor);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
#ifndef LABFY_PERSON_VOCABULARY_ADAPTER_H
|
||||
#define LABFY_PERSON_VOCABULARY_ADAPTER_H
|
||||
#include "database/database.h"
|
||||
#include "models/identity_traceability.h"
|
||||
#include <gtk/gtk.h>
|
||||
G_BEGIN_DECLS
|
||||
typedef struct PersonVocabularyAdapter PersonVocabularyAdapter;
|
||||
PersonVocabularyAdapter *person_vocabulary_adapter_new(
|
||||
Database *database, GError **error);
|
||||
void person_vocabulary_adapter_free(PersonVocabularyAdapter *adapter);
|
||||
const GPtrArray *person_vocabulary_adapter_get_roles(
|
||||
const PersonVocabularyAdapter *adapter);
|
||||
const GPtrArray *person_vocabulary_adapter_get_statuses(
|
||||
const PersonVocabularyAdapter *adapter);
|
||||
GtkStringList *person_vocabulary_adapter_create_role_labels(
|
||||
const PersonVocabularyAdapter *adapter);
|
||||
GtkStringList *person_vocabulary_adapter_create_status_labels(
|
||||
const PersonVocabularyAdapter *adapter);
|
||||
GtkStringList *person_vocabulary_adapter_create_relation_labels(void);
|
||||
const char *person_vocabulary_adapter_relation_code(guint index);
|
||||
const char *person_vocabulary_adapter_relation_label(const char *code);
|
||||
const char *person_vocabulary_adapter_relation_description(const char *code);
|
||||
const char *person_vocabulary_adapter_status_code(
|
||||
const PersonVocabularyAdapter *adapter, guint index);
|
||||
const char *person_vocabulary_adapter_status_label(
|
||||
const PersonVocabularyAdapter *adapter, guint index);
|
||||
GPtrArray *person_vocabulary_adapter_selected_role_labels(
|
||||
const GPtrArray *buttons);
|
||||
GPtrArray *person_vocabulary_adapter_create_role_buttons(
|
||||
const PersonVocabularyAdapter *adapter, GtkBox *container);
|
||||
GPtrArray *person_vocabulary_adapter_build_role_assignments(
|
||||
const PersonVocabularyAdapter *adapter, const GPtrArray *buttons,
|
||||
const char *evidence_identifier);
|
||||
gboolean person_vocabulary_adapter_justification_valid(
|
||||
const PersonRoleVocabularyEntry *entry, const char *justification);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file rib_ocr_review_dialog.h
|
||||
* @brief Révision humaine des résultats OCR d'un RIB.
|
||||
******************************************************************************/
|
||||
#ifndef LABFY_INVESTIGATION_RIB_OCR_REVIEW_DIALOG_H
|
||||
#define LABFY_INVESTIGATION_RIB_OCR_REVIEW_DIALOG_H
|
||||
#include <gtk/gtk.h>
|
||||
G_BEGIN_DECLS
|
||||
typedef void (*RibOcrReviewCallback)(const char *iban, gpointer user_data);
|
||||
/** @brief Présente le texte OCR et un IBAN révisable. */
|
||||
void rib_ocr_review_dialog_present(GtkWindow *parent, const char *ocr_text,
|
||||
const char *suggested_iban, RibOcrReviewCallback callback,
|
||||
gpointer user_data);
|
||||
G_END_DECLS
|
||||
#endif
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @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 <gtk/gtk.h>
|
||||
|
||||
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 */
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file entity_details_panel.h
|
||||
* @brief Volet latéral affichant les détails d'une entité.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_ENTITY_DETAILS_PANEL_H
|
||||
#define LABFY_INVESTIGATION_ENTITY_DETAILS_PANEL_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
#include "models/entity_record.h"
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
/**
|
||||
* @brief Modèle opaque représentant une entité.
|
||||
*/
|
||||
typedef struct EntityRecord EntityRecord;
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque du volet de détails.
|
||||
*/
|
||||
typedef struct EntityDetailsPanel EntityDetailsPanel;
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsque l'utilisateur ferme le volet.
|
||||
*
|
||||
* Le callback n'est pas déclenché lors d'une fermeture programmatique avec
|
||||
* entity_details_panel_clear().
|
||||
*
|
||||
* @param user_data Données empruntées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*EntityDetailsPanelCloseCallback)(
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsque l'utilisateur demande une relation.
|
||||
*
|
||||
* L'identifiant est emprunté et uniquement valide pendant l'appel.
|
||||
*
|
||||
* @param source_entity_identifier UUID de l'entité source.
|
||||
* @param user_data Données empruntées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*EntityDetailsPanelAddRelationCallback)(
|
||||
const char *source_entity_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Callback appelé lors du changement de catégorie d'une personne. */
|
||||
typedef void (*EntityDetailsPanelPersonRoleCallback)(
|
||||
const char *entity_identifier,
|
||||
PersonRole role,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Callback appelé pour enregistrer la confiance d'une personne. */
|
||||
typedef void (*EntityDetailsPanelPersonConfidenceCallback)(
|
||||
const char *entity_identifier,
|
||||
gint confidence,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Callback appelé pour enregistrer le nom affiché d'une personne. */
|
||||
typedef void (*EntityDetailsPanelPersonNameCallback)(
|
||||
const char *entity_identifier,
|
||||
const char *display_name,
|
||||
gpointer user_data
|
||||
);
|
||||
/** @brief Callback appelé pour gérer les preuves d'une personne. */
|
||||
typedef void (*EntityDetailsPanelPersonEvidenceCallback)(
|
||||
const char *entity_identifier, gpointer user_data);
|
||||
/** @brief Callback appelé lorsqu'une preuve associée est activée. */
|
||||
typedef void (*EntityDetailsPanelEvidenceActivatedCallback)(
|
||||
const char *evidence_identifier, gpointer user_data);
|
||||
/** @brief Callback appelé pour ouvrir les recherches OSINT compatibles. */
|
||||
typedef void (*EntityDetailsPanelOsintCallback)(
|
||||
const char *entity_identifier, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Crée un volet de détails fermé.
|
||||
*
|
||||
* @return Nouveau volet, ou NULL en cas d'échec.
|
||||
*/
|
||||
EntityDetailsPanel *entity_details_panel_new(void);
|
||||
|
||||
/**
|
||||
* @brief Retourne le widget GTK racine du volet.
|
||||
*
|
||||
* Le widget appartient au module EntityDetailsPanel.
|
||||
*
|
||||
* @param details_panel Volet à consulter.
|
||||
*
|
||||
* @return Widget GTK racine, ou NULL.
|
||||
*/
|
||||
GtkWidget *entity_details_panel_get_widget(
|
||||
const EntityDetailsPanel *details_panel
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche une entité et ouvre le volet.
|
||||
*
|
||||
* Les textes sont immédiatement copiés dans les widgets GTK.
|
||||
* Le pointeur EntityRecord n'est jamais conservé.
|
||||
*
|
||||
* Passer NULL équivaut à entity_details_panel_clear().
|
||||
*
|
||||
* @param details_panel Volet à mettre à jour.
|
||||
* @param entity_record Entité empruntée, ou NULL.
|
||||
*/
|
||||
void entity_details_panel_set_entity(
|
||||
EntityDetailsPanel *details_panel,
|
||||
const EntityRecord *entity_record
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Vide et referme le volet sans appeler le callback de fermeture.
|
||||
*
|
||||
* @param details_panel Volet à vider.
|
||||
*/
|
||||
void entity_details_panel_clear(
|
||||
EntityDetailsPanel *details_panel
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback du bouton de fermeture.
|
||||
*
|
||||
* Le callback et user_data sont empruntés.
|
||||
*
|
||||
* @param details_panel Volet à configurer.
|
||||
* @param callback Callback à appeler, ou NULL.
|
||||
* @param user_data Données empruntées du callback.
|
||||
*/
|
||||
void entity_details_panel_set_close_callback(
|
||||
EntityDetailsPanel *details_panel,
|
||||
EntityDetailsPanelCloseCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback du bouton d'ajout d'une relation.
|
||||
*
|
||||
* Le volet transmet uniquement l'UUID de l'entité actuellement affichée.
|
||||
*
|
||||
* @param details_panel Volet à configurer.
|
||||
* @param callback Callback à appeler, ou NULL.
|
||||
* @param user_data Données empruntées du callback.
|
||||
*/
|
||||
void entity_details_panel_set_add_relation_callback(
|
||||
EntityDetailsPanel *details_panel,
|
||||
EntityDetailsPanelAddRelationCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Définit le callback de changement de catégorie d'une personne. */
|
||||
void entity_details_panel_set_person_role_callback(
|
||||
EntityDetailsPanel *details_panel,
|
||||
EntityDetailsPanelPersonRoleCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de modification de la confiance d'une personne.
|
||||
*
|
||||
* @param details_panel Volet à configurer.
|
||||
* @param callback Callback à appeler, ou NULL.
|
||||
* @param user_data Données empruntées du callback.
|
||||
*/
|
||||
void entity_details_panel_set_person_confidence_callback(
|
||||
EntityDetailsPanel *details_panel,
|
||||
EntityDetailsPanelPersonConfidenceCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Définit le callback de modification du nom affiché. */
|
||||
void entity_details_panel_set_person_name_callback(
|
||||
EntityDetailsPanel *details_panel,
|
||||
EntityDetailsPanelPersonNameCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
/** @brief Définit le callback de gestion des preuves d'une personne. */
|
||||
void entity_details_panel_set_person_evidence_callback(
|
||||
EntityDetailsPanel *details_panel,
|
||||
EntityDetailsPanelPersonEvidenceCallback callback, gpointer user_data);
|
||||
void entity_details_panel_set_evidence_activated_callback(
|
||||
EntityDetailsPanel *details_panel,
|
||||
EntityDetailsPanelEvidenceActivatedCallback callback,
|
||||
gpointer user_data);
|
||||
/** @brief Définit le callback du bouton de recherche OSINT. */
|
||||
void entity_details_panel_set_osint_callback(EntityDetailsPanel *details_panel,
|
||||
EntityDetailsPanelOsintCallback callback, gpointer user_data);
|
||||
/**
|
||||
* @brief Affiche les preuves associées à la personne courante.
|
||||
* @param details_panel Volet à mettre à jour.
|
||||
* @param evidence_records Tableau emprunté de EvidenceRecord, ou NULL.
|
||||
*/
|
||||
void entity_details_panel_set_person_evidences(
|
||||
EntityDetailsPanel *details_panel, const GPtrArray *evidence_records);
|
||||
void entity_details_panel_set_person_factual_relations(
|
||||
EntityDetailsPanel *details_panel, const GPtrArray *relations, const GPtrArray *evidence_records);
|
||||
void entity_details_panel_set_person_profile_fields(EntityDetailsPanel *details_panel,const GHashTable *fields);
|
||||
|
||||
/**
|
||||
* @brief Indique si le volet est actuellement ouvert.
|
||||
*
|
||||
* @param details_panel Volet à consulter.
|
||||
*
|
||||
* @return TRUE si le volet est ouvert, sinon FALSE.
|
||||
*/
|
||||
gboolean entity_details_panel_is_open(
|
||||
const EntityDetailsPanel *details_panel
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Libère la structure d'encapsulation du volet.
|
||||
*
|
||||
* Cette fonction accepte NULL.
|
||||
*
|
||||
* @param details_panel Volet à libérer.
|
||||
*/
|
||||
void entity_details_panel_free(
|
||||
EntityDetailsPanel *details_panel
|
||||
);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
#ifndef LABFY_INVESTIGATION_EVIDENCE_PREVIEW_WIDGET_H
|
||||
#define LABFY_INVESTIGATION_EVIDENCE_PREVIEW_WIDGET_H
|
||||
|
||||
#include "core/evidence_preview.h"
|
||||
#include "core/task_manager.h"
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
typedef struct EvidencePreviewWidget EvidencePreviewWidget;
|
||||
typedef gboolean (*EvidencePreviewWidgetSessionCheck)(gpointer user_data);
|
||||
|
||||
EvidencePreviewWidget *evidence_preview_widget_new(
|
||||
TaskManager *task_manager,
|
||||
EvidencePreviewWidgetSessionCheck session_check,
|
||||
gpointer session_data);
|
||||
GtkWidget *evidence_preview_widget_get_widget(
|
||||
const EvidencePreviewWidget *widget);
|
||||
void evidence_preview_widget_show(EvidencePreviewWidget *widget,
|
||||
const EvidencePreviewRequest *request, const char *metadata);
|
||||
void evidence_preview_widget_clear(EvidencePreviewWidget *widget);
|
||||
void evidence_preview_widget_cancel(EvidencePreviewWidget *widget);
|
||||
void evidence_preview_widget_free(EvidencePreviewWidget *widget);
|
||||
const char *evidence_preview_widget_get_state(
|
||||
const EvidencePreviewWidget *widget);
|
||||
char *evidence_preview_widget_dup_text(
|
||||
const EvidencePreviewWidget *widget);
|
||||
void evidence_preview_widget_zoom_in(EvidencePreviewWidget *widget);
|
||||
void evidence_preview_widget_zoom_out(EvidencePreviewWidget *widget);
|
||||
void evidence_preview_widget_fit(EvidencePreviewWidget *widget);
|
||||
double evidence_preview_widget_get_zoom(
|
||||
const EvidencePreviewWidget *widget);
|
||||
gboolean evidence_preview_widget_is_fit(
|
||||
const EvidencePreviewWidget *widget);
|
||||
gboolean evidence_preview_widget_set_pdf_page(
|
||||
EvidencePreviewWidget *widget, guint page);
|
||||
guint evidence_preview_widget_get_pdf_page(
|
||||
const EvidencePreviewWidget *widget);
|
||||
guint evidence_preview_widget_get_pdf_page_count(
|
||||
const EvidencePreviewWidget *widget);
|
||||
|
||||
G_END_DECLS
|
||||
#endif
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file evidence_tree_view.h
|
||||
* @brief Affichage GTK hiérarchique des catégories et preuves importées.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_EVIDENCE_TREE_VIEW_H
|
||||
#define LABFY_INVESTIGATION_EVIDENCE_TREE_VIEW_H
|
||||
|
||||
#include "widgets/evidence_category_model.h"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
/**
|
||||
* @brief Vue opaque affichant les preuves regroupées par catégorie.
|
||||
*/
|
||||
typedef struct EvidenceTreeView EvidenceTreeView;
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsqu'une preuve est sélectionnée.
|
||||
*
|
||||
* L'identifiant est emprunté et uniquement valide pendant l'appel.
|
||||
*
|
||||
* Lorsque la sélection est vide ou correspond à une catégorie,
|
||||
* evidence_identifier vaut NULL.
|
||||
*
|
||||
* @param evidence_identifier UUID de la preuve sélectionnée, ou NULL.
|
||||
* @param user_data Données privées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*EvidenceTreeViewSelectionCallback)(
|
||||
const char *evidence_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Crée une vue hiérarchique vide.
|
||||
*
|
||||
* @return Nouvelle vue, ou NULL.
|
||||
*/
|
||||
EvidenceTreeView *evidence_tree_view_new(void);
|
||||
|
||||
/**
|
||||
* @brief Retourne le widget GTK racine.
|
||||
*
|
||||
* Le widget retourné est emprunté.
|
||||
*
|
||||
* @param evidence_tree_view Vue à consulter.
|
||||
*
|
||||
* @return GtkScrolledWindow racine, ou NULL.
|
||||
*/
|
||||
GtkWidget *evidence_tree_view_get_widget(
|
||||
const EvidenceTreeView *evidence_tree_view
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Installe un modèle de catégories.
|
||||
*
|
||||
* La vue ne devient pas propriétaire de EvidenceCategoryModel.
|
||||
* Le modèle doit rester valide pendant son utilisation.
|
||||
*
|
||||
* Passer NULL vide la vue.
|
||||
*
|
||||
* @param evidence_tree_view Vue à actualiser.
|
||||
* @param evidence_category_model Modèle de catégories, ou NULL.
|
||||
*/
|
||||
void evidence_tree_view_set_model(
|
||||
EvidenceTreeView *evidence_tree_view,
|
||||
EvidenceCategoryModel *evidence_category_model
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de sélection.
|
||||
*
|
||||
* @param evidence_tree_view Vue à configurer.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void evidence_tree_view_set_selection_callback(
|
||||
EvidenceTreeView *evidence_tree_view,
|
||||
EvidenceTreeViewSelectionCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Libère la structure d'encapsulation.
|
||||
*
|
||||
* Les widgets GTK intégrés dans la fenêtre restent gérés par l'arbre GTK.
|
||||
* Cette fonction accepte NULL.
|
||||
*
|
||||
* @param evidence_tree_view Vue à libérer.
|
||||
*/
|
||||
void evidence_tree_view_free(
|
||||
EvidenceTreeView *evidence_tree_view
|
||||
);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file investigation_graph_view.h
|
||||
* @brief Vue GTK en lecture seule du graphe d'enquête.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_INVESTIGATION_GRAPH_VIEW_H
|
||||
#define LABFY_INVESTIGATION_INVESTIGATION_GRAPH_VIEW_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
G_BEGIN_DECLS
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque d'une entité métier.
|
||||
*/
|
||||
typedef struct EntityRecord EntityRecord;
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque d'une relation métier.
|
||||
*/
|
||||
typedef struct RelationRecord RelationRecord;
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque du graphe métier d'une enquête.
|
||||
*/
|
||||
typedef struct InvestigationGraphModel InvestigationGraphModel;
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque de la disposition persistée du graphe.
|
||||
*/
|
||||
typedef struct InvestigationGraphLayout InvestigationGraphLayout;
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque de la vue graphique.
|
||||
*/
|
||||
typedef struct InvestigationGraphView InvestigationGraphView;
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lors d'un changement de sélection.
|
||||
*
|
||||
* Les enregistrements sont empruntés au graphe et restent valides uniquement
|
||||
* pendant l'appel. Un seul des deux pointeurs est non NULL. Deux pointeurs
|
||||
* NULL représentent une désélection.
|
||||
*
|
||||
* @param entity_record Entité sélectionnée empruntée, ou NULL.
|
||||
* @param relation_record Relation sélectionnée empruntée, ou NULL.
|
||||
* @param user_data Données empruntées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*InvestigationGraphViewSelectionCallback)(
|
||||
const EntityRecord *entity_record,
|
||||
const RelationRecord *relation_record,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé après le déplacement effectif d'un nœud.
|
||||
*
|
||||
* Les coordonnées sont exprimées dans l'espace logique du graphe.
|
||||
* node_identifier est emprunté au modèle et reste valable uniquement
|
||||
* pendant l'appel.
|
||||
*
|
||||
* @param node_identifier UUID du nœud déplacé (entité ou relation).
|
||||
* @param x Coordonnée horizontale logique du coin supérieur gauche.
|
||||
* @param y Coordonnée verticale logique du coin supérieur gauche.
|
||||
* @param user_data Données empruntées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*InvestigationGraphViewNodeMovedCallback)(
|
||||
const char *node_identifier,
|
||||
double x,
|
||||
double y,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
typedef void (*InvestigationGraphViewTransformChangedCallback)(
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Callback appelé lorsqu'une extraction est déposée sur le graphe. */
|
||||
typedef void (*InvestigationGraphViewExtractionDropCallback)(
|
||||
const char *file_path,
|
||||
const char *target_entity_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Crée une nouvelle vue graphique vide.
|
||||
*
|
||||
* La vue ne possède jamais le graphe qui lui est transmis.
|
||||
*
|
||||
* @return Nouvelle vue graphique, ou NULL en cas d'échec.
|
||||
*/
|
||||
InvestigationGraphView *investigation_graph_view_new(void);
|
||||
|
||||
/**
|
||||
* @brief Retourne le widget GTK racine de la vue.
|
||||
*
|
||||
* Le widget appartient à InvestigationGraphView.
|
||||
*
|
||||
* @param graph_view Vue graphique à consulter.
|
||||
*
|
||||
* @return Widget GTK racine, ou NULL.
|
||||
*/
|
||||
GtkWidget *investigation_graph_view_get_widget(
|
||||
const InvestigationGraphView *graph_view
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Associe un graphe métier à la vue.
|
||||
*
|
||||
* Le graphe est emprunté et doit rester valide pendant son affichage.
|
||||
* Passer NULL équivaut à investigation_graph_view_clear().
|
||||
*
|
||||
* @param graph_view Vue graphique à mettre à jour.
|
||||
* @param graph_model Graphe emprunté, ou NULL.
|
||||
*/
|
||||
void investigation_graph_view_set_graph(
|
||||
InvestigationGraphView *graph_view,
|
||||
const InvestigationGraphModel *graph_model
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Associe une disposition persistée à la vue.
|
||||
*
|
||||
* La disposition est empruntée et doit rester valide pendant son utilisation.
|
||||
*
|
||||
* Lorsqu'un graphe est déjà affiché, la disposition privée de ses nœuds est
|
||||
* reconstruite. Une position persistée remplace la position automatique
|
||||
* uniquement lorsqu'elle existe pour l'entité concernée.
|
||||
*
|
||||
* Passer NULL rétablit le placement automatique.
|
||||
*
|
||||
* @param graph_view Vue graphique à mettre à jour.
|
||||
* @param graph_layout Disposition persistée empruntée, ou NULL.
|
||||
*/
|
||||
void investigation_graph_view_set_layout(
|
||||
InvestigationGraphView *graph_view,
|
||||
const InvestigationGraphLayout *graph_layout
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Détache le graphe actuellement affiché.
|
||||
*
|
||||
* Le graphe précédemment emprunté n'est pas libéré.
|
||||
*
|
||||
* @param graph_view Vue graphique à vider.
|
||||
*/
|
||||
void investigation_graph_view_clear(
|
||||
InvestigationGraphView *graph_view
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de changement de sélection.
|
||||
*
|
||||
* Le callback et user_data sont empruntés par la vue.
|
||||
*
|
||||
* @param graph_view Vue graphique.
|
||||
* @param callback Callback à appeler, ou NULL.
|
||||
* @param user_data Données empruntées du callback.
|
||||
*/
|
||||
void investigation_graph_view_set_selection_callback(
|
||||
InvestigationGraphView *graph_view,
|
||||
InvestigationGraphViewSelectionCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de fin de déplacement d'un nœud.
|
||||
*
|
||||
* Le callback et user_data sont empruntés par la vue.
|
||||
*
|
||||
* Le callback n'est appelé ni pendant le déplacement du canvas, ni pour
|
||||
* un simple clic, ni pendant une reconstruction de la disposition.
|
||||
*
|
||||
* @param graph_view Vue graphique.
|
||||
* @param callback Callback à appeler, ou NULL.
|
||||
* @param user_data Données empruntées du callback.
|
||||
*/
|
||||
void investigation_graph_view_set_node_moved_callback(
|
||||
InvestigationGraphView *graph_view,
|
||||
InvestigationGraphViewNodeMovedCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
void investigation_graph_view_set_transform_changed_callback(
|
||||
InvestigationGraphView *graph_view,
|
||||
InvestigationGraphViewTransformChangedCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
void investigation_graph_view_set_extraction_drop_callback(
|
||||
InvestigationGraphView *graph_view,
|
||||
InvestigationGraphViewExtractionDropCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne l'entité actuellement sélectionnée.
|
||||
*
|
||||
* Le pointeur retourné est emprunté au graphe.
|
||||
*
|
||||
* @param graph_view Vue graphique à consulter.
|
||||
*
|
||||
* @return Entité sélectionnée empruntée, ou NULL.
|
||||
*/
|
||||
const EntityRecord *investigation_graph_view_get_selected_entity(
|
||||
const InvestigationGraphView *graph_view
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Sélectionne une entité du graphe par son UUID.
|
||||
*
|
||||
* Le nœud trouvé est placé au centre de la zone visible. La vue reste
|
||||
* inchangée si l'identifiant est absent du graphe.
|
||||
*
|
||||
* @param graph_view Vue graphique à modifier.
|
||||
* @param entity_identifier UUID de l'entité.
|
||||
*
|
||||
* @return TRUE lorsque l'entité est trouvée et sélectionnée.
|
||||
*/
|
||||
gboolean investigation_graph_view_select_entity(
|
||||
InvestigationGraphView *graph_view,
|
||||
const char *entity_identifier
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Sélectionne une relation du graphe par son UUID.
|
||||
*
|
||||
* @param graph_view Vue graphique à modifier.
|
||||
* @param relation_identifier UUID de la relation.
|
||||
*
|
||||
* @return TRUE lorsque la relation est trouvée et sélectionnée.
|
||||
*/
|
||||
gboolean investigation_graph_view_select_relation(
|
||||
InvestigationGraphView *graph_view,
|
||||
const char *relation_identifier
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Supprime la sélection courante.
|
||||
*
|
||||
* Le callback reçoit NULL seulement si une sélection existait.
|
||||
*
|
||||
* @param graph_view Vue graphique.
|
||||
*/
|
||||
void investigation_graph_view_clear_selection(
|
||||
InvestigationGraphView *graph_view
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Réinitialise le zoom et la position du canvas.
|
||||
*
|
||||
* Le graphe emprunté reste associé à la vue.
|
||||
*
|
||||
* @param graph_view Vue graphique à recentrer.
|
||||
*/
|
||||
void investigation_graph_view_reset_view(
|
||||
InvestigationGraphView *graph_view
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Lit le zoom et le déplacement courants du canvas.
|
||||
*
|
||||
* @return TRUE lorsqu'un graphe est actuellement présenté.
|
||||
*/
|
||||
gboolean investigation_graph_view_get_view_transform(
|
||||
const InvestigationGraphView *graph_view,
|
||||
double *zoom,
|
||||
double *offset_x,
|
||||
double *offset_y
|
||||
);
|
||||
|
||||
/** @brief Restaure un zoom et un déplacement précédemment lus. */
|
||||
void investigation_graph_view_set_view_transform(
|
||||
InvestigationGraphView *graph_view,
|
||||
double zoom,
|
||||
double offset_x,
|
||||
double offset_y
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Restaure la disposition déterministe initiale des nœuds.
|
||||
*
|
||||
* Le zoom et le déplacement global du canvas sont conservés.
|
||||
*
|
||||
* @param graph_view Vue graphique à réorganiser.
|
||||
*/
|
||||
void investigation_graph_view_reset_layout(
|
||||
InvestigationGraphView *graph_view
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Libère la structure d'encapsulation de la vue.
|
||||
*
|
||||
* Cette fonction accepte NULL et ne libère jamais le graphe emprunté.
|
||||
*
|
||||
* @param graph_view Vue graphique à libérer.
|
||||
*/
|
||||
void investigation_graph_view_free(
|
||||
InvestigationGraphView *graph_view
|
||||
);
|
||||
|
||||
G_END_DECLS
|
||||
|
||||
#endif
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file investigation_tree_view.h
|
||||
* @brief Interface publique de la vue arborescente d'une enquête.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_INVESTIGATION_TREE_VIEW_H
|
||||
#define LABFY_INVESTIGATION_INVESTIGATION_TREE_VIEW_H
|
||||
|
||||
#include "core/investigation_tree_model.h"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque de la vue arborescente d'une enquête.
|
||||
*
|
||||
* La structure réelle est définie dans investigation_tree_view.c.
|
||||
* Les autres modules manipulent uniquement un pointeur vers
|
||||
* InvestigationTreeView.
|
||||
*/
|
||||
typedef struct InvestigationTreeView InvestigationTreeView;
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsqu'un nœud est sélectionné.
|
||||
*
|
||||
* @param node Nœud sélectionné, ou NULL si aucune sélection n'est active.
|
||||
* @param user_data Données privées fournies lors de l'enregistrement
|
||||
* du callback.
|
||||
*/
|
||||
typedef void (*InvestigationTreeViewSelectionCallback)(
|
||||
const InvestigationNode *node,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de sélection.
|
||||
*
|
||||
* Le callback est appelé à chaque changement de sélection.
|
||||
*
|
||||
* Le composant ne devient pas propriétaire du callback ni de user_data.
|
||||
*
|
||||
* @param tree_view Vue arborescente.
|
||||
* @param callback Fonction appelée lors d'une sélection.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void investigation_tree_view_set_selection_callback(
|
||||
InvestigationTreeView *tree_view,
|
||||
InvestigationTreeViewSelectionCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Crée une nouvelle vue arborescente vide.
|
||||
*
|
||||
* Aucun modèle métier n'est associé au composant lors de sa création.
|
||||
*
|
||||
* @return Une nouvelle vue, ou NULL si sa création échoue.
|
||||
*/
|
||||
InvestigationTreeView *investigation_tree_view_new(void);
|
||||
|
||||
/**
|
||||
* @brief Retourne le widget GTK racine de la vue arborescente.
|
||||
*
|
||||
* Le widget retourné appartient au module InvestigationTreeView.
|
||||
* Le code appelant ne doit ni le détruire ni en libérer la référence.
|
||||
*
|
||||
* @param tree_view Vue arborescente à consulter.
|
||||
*
|
||||
* @return Le widget racine, ou NULL si tree_view vaut NULL.
|
||||
*/
|
||||
GtkWidget *investigation_tree_view_get_widget(
|
||||
const InvestigationTreeView *tree_view
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Associe un modèle métier à la vue arborescente.
|
||||
*
|
||||
* La vue ne devient pas propriétaire du modèle. Le modèle doit rester valide
|
||||
* pendant toute la durée de son affichage.
|
||||
*
|
||||
* Passer NULL retire le modèle actuel et vide la vue.
|
||||
*
|
||||
* @param tree_view Vue arborescente à mettre à jour.
|
||||
* @param tree_model Modèle métier en lecture seule, ou NULL.
|
||||
*/
|
||||
void investigation_tree_view_set_model(
|
||||
InvestigationTreeView *tree_view,
|
||||
const InvestigationTreeModel *tree_model
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Libère la structure d'encapsulation de la vue.
|
||||
*
|
||||
* Cette fonction accepte NULL.
|
||||
*
|
||||
* Les objets GTK intégrés à l'arbre de widgets restent gérés par GTK.
|
||||
*
|
||||
* @param tree_view Vue arborescente à libérer.
|
||||
*/
|
||||
void investigation_tree_view_free(
|
||||
InvestigationTreeView *tree_view
|
||||
);
|
||||
|
||||
#endif
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
#ifndef LABFY_OCR_PROVENANCE_OVERLAY_H
|
||||
#define LABFY_OCR_PROVENANCE_OVERLAY_H
|
||||
|
||||
#include "models/identity_ocr.h"
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
typedef struct OcrProvenanceOverlay OcrProvenanceOverlay;
|
||||
|
||||
OcrProvenanceOverlay *ocr_provenance_overlay_new(void);
|
||||
void ocr_provenance_overlay_free(OcrProvenanceOverlay *overlay);
|
||||
GtkWidget *ocr_provenance_overlay_get_widget(OcrProvenanceOverlay *overlay);
|
||||
void ocr_provenance_overlay_set_image(OcrProvenanceOverlay *overlay,
|
||||
GBytes *png_bytes, guint64 generation);
|
||||
void ocr_provenance_overlay_set_field(OcrProvenanceOverlay *overlay,
|
||||
const IdentityFieldObservation *field, guint64 generation);
|
||||
void ocr_provenance_overlay_clear(OcrProvenanceOverlay *overlay,
|
||||
guint64 generation);
|
||||
void ocr_provenance_overlay_set_page(
|
||||
OcrProvenanceOverlay *overlay, guint page, guint64 generation);
|
||||
void ocr_provenance_overlay_zoom_in(OcrProvenanceOverlay *overlay);
|
||||
void ocr_provenance_overlay_zoom_out(OcrProvenanceOverlay *overlay);
|
||||
void ocr_provenance_overlay_fit(OcrProvenanceOverlay *overlay);
|
||||
double ocr_provenance_overlay_get_zoom(
|
||||
const OcrProvenanceOverlay *overlay);
|
||||
gboolean ocr_provenance_overlay_has_region(
|
||||
const OcrProvenanceOverlay *overlay);
|
||||
|
||||
#endif
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file sidebar.h
|
||||
* @brief Interface publique du panneau de navigation latéral.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_SIDEBAR_H
|
||||
#define LABFY_INVESTIGATION_SIDEBAR_H
|
||||
|
||||
#include "core/investigation_tree_model.h"
|
||||
#include "widgets/investigation_tree_view.h"
|
||||
#include "widgets/evidence_category_model.h"
|
||||
#include "models/investigation_graph_model.h"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque du panneau latéral.
|
||||
*
|
||||
* La structure réelle est définie dans sidebar.c.
|
||||
* Les autres modules manipulent uniquement un pointeur vers Sidebar.
|
||||
*/
|
||||
typedef struct Sidebar Sidebar;
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsqu'une preuve est sélectionnée.
|
||||
*
|
||||
* L'identifiant est emprunté et uniquement valide pendant l'appel.
|
||||
* Une sélection vide ou une catégorie transmet NULL.
|
||||
*
|
||||
* @param evidence_identifier UUID de la preuve, ou NULL.
|
||||
* @param user_data Données privées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*SidebarEvidenceSelectionCallback)(
|
||||
const char *evidence_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsqu'une entité est sélectionnée.
|
||||
*
|
||||
* L'identifiant est emprunté et valide uniquement pendant l'appel.
|
||||
* Une sélection vide transmet NULL.
|
||||
*
|
||||
* @param entity_identifier UUID de l'entité, ou NULL.
|
||||
* @param user_data Données privées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*SidebarEntitySelectionCallback)(
|
||||
const char *entity_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lorsqu'une relation est sélectionnée.
|
||||
*
|
||||
* @param relation_identifier UUID emprunté de la relation, ou NULL.
|
||||
* @param user_data Données privées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*SidebarRelationSelectionCallback)(
|
||||
const char *relation_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Crée un nouveau panneau de navigation latéral.
|
||||
*
|
||||
* @return Un nouveau panneau latéral, ou NULL en cas d'échec.
|
||||
*/
|
||||
Sidebar *sidebar_new(void);
|
||||
|
||||
/**
|
||||
* @brief Retourne le widget GTK racine du panneau latéral.
|
||||
*
|
||||
* Le widget retourné appartient au module Sidebar et ne doit pas être libéré
|
||||
* directement par le code appelant.
|
||||
*
|
||||
* @param sidebar Panneau latéral à consulter.
|
||||
*
|
||||
* @return Le widget GTK racine, ou NULL si sidebar est NULL.
|
||||
*/
|
||||
GtkWidget *sidebar_get_widget(
|
||||
const Sidebar *sidebar
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Associe un modèle d'arborescence à la barre latérale.
|
||||
*
|
||||
* La barre latérale ne devient pas propriétaire du modèle.
|
||||
* Le modèle doit rester valide pendant toute la durée de son utilisation.
|
||||
*
|
||||
* Pour ce ticket, seul le nom du nœud racine est affiché dans le titre.
|
||||
*
|
||||
* @param sidebar Barre latérale à mettre à jour.
|
||||
* @param tree_model Modèle d'arborescence en lecture seule.
|
||||
*/
|
||||
void sidebar_set_tree_model(
|
||||
Sidebar *sidebar,
|
||||
const InvestigationTreeModel *tree_model
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback appelé lors de la sélection d'un nœud.
|
||||
*
|
||||
* La Sidebar transmet simplement ce callback à InvestigationTreeView.
|
||||
*
|
||||
* @param sidebar Barre latérale à configurer.
|
||||
* @param callback Fonction appelée lors d'un changement de sélection.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void sidebar_set_selection_callback(
|
||||
Sidebar *sidebar,
|
||||
InvestigationTreeViewSelectionCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Installe le modèle des catégories de preuves.
|
||||
*
|
||||
* La Sidebar ne devient pas propriétaire du modèle.
|
||||
* Passer NULL rétablit l'état vide.
|
||||
*
|
||||
* @param sidebar Barre latérale à actualiser.
|
||||
* @param evidence_category_model Modèle de catégories, ou NULL.
|
||||
*/
|
||||
void sidebar_set_evidence_model(
|
||||
Sidebar *sidebar,
|
||||
EvidenceCategoryModel *evidence_category_model
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de sélection d'une preuve.
|
||||
*
|
||||
* @param sidebar Barre latérale à configurer.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void sidebar_set_evidence_selection_callback(
|
||||
Sidebar *sidebar,
|
||||
SidebarEvidenceSelectionCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Installe les entités et relations du graphe dans la barre latérale.
|
||||
*
|
||||
* La Sidebar emprunte graph_model. Passer NULL vide la liste.
|
||||
*
|
||||
* @param sidebar Barre latérale à actualiser.
|
||||
* @param graph_model Graphe emprunté, ou NULL.
|
||||
*/
|
||||
void sidebar_set_graph_model(
|
||||
Sidebar *sidebar,
|
||||
const InvestigationGraphModel *graph_model
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de sélection d'une entité.
|
||||
*
|
||||
* @param sidebar Barre latérale à configurer.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void sidebar_set_entity_selection_callback(
|
||||
Sidebar *sidebar,
|
||||
SidebarEntitySelectionCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de sélection d'une relation.
|
||||
*
|
||||
* @param sidebar Barre latérale à configurer.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données privées transmises au callback.
|
||||
*/
|
||||
void sidebar_set_relation_selection_callback(
|
||||
Sidebar *sidebar,
|
||||
SidebarRelationSelectionCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Libère la structure d'encapsulation du panneau latéral.
|
||||
*
|
||||
* Cette fonction accepte NULL.
|
||||
*
|
||||
* @param sidebar Panneau latéral à libérer.
|
||||
*/
|
||||
void sidebar_free(Sidebar *sidebar);
|
||||
|
||||
#endif
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file task_panel.h
|
||||
* @brief Panneau GTK affichant les tâches suivies par TaskManager.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_TASK_PANEL_H
|
||||
#define LABFY_INVESTIGATION_TASK_PANEL_H
|
||||
|
||||
#include "core/task_manager.h"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque du panneau d’activité.
|
||||
*/
|
||||
typedef struct TaskPanel TaskPanel;
|
||||
|
||||
/**
|
||||
* @brief Crée un panneau affichant les tâches d’un gestionnaire.
|
||||
*
|
||||
* Le panneau ne devient pas propriétaire de task_manager.
|
||||
* Le gestionnaire doit rester valide pendant toute la durée de vie
|
||||
* du panneau.
|
||||
*
|
||||
* @param task_manager Gestionnaire de tâches observé.
|
||||
*
|
||||
* @return Nouveau panneau, ou NULL si le gestionnaire est invalide.
|
||||
*/
|
||||
TaskPanel *task_panel_new(
|
||||
TaskManager *task_manager
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Retourne le widget racine du panneau.
|
||||
*
|
||||
* Le pointeur retourné est emprunté.
|
||||
*
|
||||
* @param task_panel Panneau concerné.
|
||||
*
|
||||
* @return Widget racine, ou NULL.
|
||||
*/
|
||||
GtkWidget *task_panel_get_widget(
|
||||
const TaskPanel *task_panel
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Reconstruit l’affichage à partir du gestionnaire.
|
||||
*
|
||||
* @param task_panel Panneau à actualiser.
|
||||
*/
|
||||
void task_panel_refresh(
|
||||
TaskPanel *task_panel
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Libère le panneau et arrête son rafraîchissement périodique.
|
||||
*
|
||||
* Le gestionnaire associé n’est pas libéré.
|
||||
*
|
||||
* @param task_panel Panneau à libérer.
|
||||
*/
|
||||
void task_panel_free(
|
||||
TaskPanel *task_panel
|
||||
);
|
||||
|
||||
#endif
|
||||
|
|
@ -1,552 +0,0 @@
|
|||
/******************************************************************************
|
||||
* @file workspace.h
|
||||
* @brief Interface publique de la zone principale de travail.
|
||||
******************************************************************************/
|
||||
|
||||
#ifndef LABFY_INVESTIGATION_WORKSPACE_H
|
||||
#define LABFY_INVESTIGATION_WORKSPACE_H
|
||||
|
||||
#include "core/investigation_node.h"
|
||||
#include "models/evidence_record.h"
|
||||
#include "models/evidence_observation.h"
|
||||
#include "models/entity_record.h"
|
||||
#include "models/osint_action_catalog.h"
|
||||
#include "models/identity_ocr.h"
|
||||
#include "core/task_manager.h"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque du graphe d'enquête.
|
||||
*/
|
||||
typedef struct InvestigationGraphModel InvestigationGraphModel;
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque de la disposition du graphe.
|
||||
*/
|
||||
typedef struct InvestigationGraphLayout InvestigationGraphLayout;
|
||||
|
||||
/**
|
||||
* @brief Représentation opaque de la zone de travail.
|
||||
*/
|
||||
typedef struct Workspace Workspace;
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lors d'une demande de vérification d'une preuve.
|
||||
*
|
||||
* @param evidence_identifier UUID de la preuve affichée.
|
||||
* @param user_data Données empruntées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*WorkspaceVerifyEvidenceCallback)(
|
||||
const char *evidence_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé après le déplacement effectif d'un nœud.
|
||||
*
|
||||
* Les coordonnées sont exprimées dans l'espace logique du graphe.
|
||||
*
|
||||
* @param node_identifier UUID du nœud déplacé.
|
||||
* @param x Coordonnée horizontale logique.
|
||||
* @param y Coordonnée verticale logique.
|
||||
* @param user_data Données empruntées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*WorkspaceGraphNodeMovedCallback)(
|
||||
const char *node_identifier,
|
||||
double x,
|
||||
double y,
|
||||
gpointer user_data
|
||||
);
|
||||
typedef void (*WorkspaceGraphTransformChangedCallback)(gpointer user_data);
|
||||
|
||||
typedef void (*WorkspaceExtractionDropCallback)(
|
||||
const char *file_path,
|
||||
const char *target_entity_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lors d'une demande de réinitialisation du graphe.
|
||||
*
|
||||
* Le Workspace ne modifie ni SQLite ni le modèle de disposition. Il relaie
|
||||
* uniquement la demande vers la couche applicative.
|
||||
*
|
||||
* @param user_data Données empruntées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*WorkspaceResetGraphLayoutCallback)(
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lors d'une demande d'ajout d'une relation.
|
||||
*
|
||||
* @param source_entity_identifier UUID de l'entité source.
|
||||
* @param user_data Données empruntées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*WorkspaceAddRelationCallback)(
|
||||
const char *source_entity_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Callback appelé pour modifier la relation sélectionnée. */
|
||||
typedef void (*WorkspaceEditRelationCallback)(
|
||||
const char *relation_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
/** @brief Callback appelé pour supprimer la relation sélectionnée. */
|
||||
typedef void (*WorkspaceDeleteRelationCallback)(
|
||||
const char *relation_identifier, gpointer user_data);
|
||||
|
||||
/** @brief Callback appelé quand une relation devient sélectionnée. */
|
||||
typedef void (*WorkspaceRelationSelectedCallback)(
|
||||
const char *relation_identifier,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Callback appelé pour catégoriser une personne. */
|
||||
typedef void (*WorkspacePersonRoleCallback)(const char *entity_identifier,
|
||||
PersonRole role, gpointer user_data);
|
||||
/** @brief Callback appelé pour modifier la confiance d'une personne. */
|
||||
typedef void (*WorkspacePersonConfidenceCallback)(
|
||||
const char *entity_identifier, gint confidence, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Callback appelé lors du déclenchement d'une action OSINT.
|
||||
*
|
||||
* Les chaînes sont empruntées et uniquement valides pendant l'appel.
|
||||
*
|
||||
* @param action_identifier Identifiant stable de l'action.
|
||||
* @param target_identifier UUID de la sélection ciblée.
|
||||
* @param target_value Valeur métier ciblée par l'action.
|
||||
* @param user_data Données empruntées fournies par l'appelant.
|
||||
*/
|
||||
typedef void (*WorkspaceOsintActionCallback)(
|
||||
const char *action_identifier,
|
||||
const char *target_identifier,
|
||||
const char *target_value,
|
||||
gpointer user_data
|
||||
);
|
||||
/** @brief Callback appelé pour modifier le nom affiché d'une personne. */
|
||||
typedef void (*WorkspacePersonNameCallback)(const char *entity_identifier,
|
||||
const char *display_name, gpointer user_data);
|
||||
/** @brief Callback appelé pour gérer les preuves d'une personne. */
|
||||
typedef void (*WorkspacePersonEvidenceCallback)(
|
||||
const char *entity_identifier, gpointer user_data);
|
||||
typedef void (*WorkspaceEvidenceActivatedCallback)(
|
||||
const char *evidence_identifier, gpointer user_data);
|
||||
/** @brief Callback appelé quand une entité devient sélectionnée. */
|
||||
typedef void (*WorkspaceEntitySelectedCallback)(
|
||||
const char *entity_identifier, 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 Callback appelé pour analyser une preuve EML. */
|
||||
typedef void (*WorkspaceAnalyzeEmlCallback)(const char *evidence_identifier,
|
||||
gpointer user_data);
|
||||
/** @brief Callback appelé pour analyser un RIB par OCR. */
|
||||
typedef void (*WorkspaceAnalyzeRibCallback)(const char *evidence_identifier,
|
||||
gpointer user_data);
|
||||
/** @brief Callback appelé pour extraire les métadonnées d'une preuve. */
|
||||
typedef void (*WorkspaceExtractMetadataCallback)(const char *evidence_identifier,
|
||||
gpointer user_data);
|
||||
/** @brief Callback appelé pour récupérer le mot de passe d'un PDF. */
|
||||
typedef void (*WorkspaceRecoverPdfPasswordCallback)(
|
||||
const char *evidence_identifier, gpointer user_data);
|
||||
typedef void (*WorkspaceObservationRemoveCallback)(
|
||||
const char *observation_identifier, gpointer user_data);
|
||||
typedef void (*WorkspaceIdentityOcrCallback)(
|
||||
const char *evidence_identifier, gboolean revise_existing,
|
||||
const char *ocr_run_identifier, gpointer user_data);
|
||||
|
||||
typedef struct {
|
||||
IdentityOcrRun *run;
|
||||
char *person_identifier;
|
||||
char *executed_at;
|
||||
char *text_relative_path;
|
||||
char *text_sha256;
|
||||
char *tsv_relative_path;
|
||||
char *tsv_sha256;
|
||||
} WorkspaceIdentityOcrRecord;
|
||||
|
||||
WorkspaceIdentityOcrRecord *workspace_identity_ocr_record_new(
|
||||
IdentityOcrRun *owned_run, const char *person_identifier,
|
||||
const char *executed_at, const char *text_relative_path,
|
||||
const char *text_sha256, const char *tsv_relative_path,
|
||||
const char *tsv_sha256);
|
||||
void workspace_identity_ocr_record_free(WorkspaceIdentityOcrRecord *record);
|
||||
|
||||
/**
|
||||
* @brief Crée une nouvelle zone de travail.
|
||||
*
|
||||
* @return Une nouvelle zone de travail, ou NULL en cas d'échec.
|
||||
*/
|
||||
Workspace *workspace_new(TaskManager *task_manager);
|
||||
|
||||
/**
|
||||
* @brief Retourne le widget GTK racine de la zone de travail.
|
||||
*
|
||||
* Le widget retourné appartient au module Workspace.
|
||||
*
|
||||
* @param workspace Zone de travail à consulter.
|
||||
*
|
||||
* @return Widget racine, ou NULL.
|
||||
*/
|
||||
GtkWidget *workspace_get_widget(
|
||||
const Workspace *workspace
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche les informations d'un nœud sélectionné.
|
||||
*
|
||||
* Le Workspace emprunte le nœud.
|
||||
* Passer NULL restaure l'état principal courant.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param node Nœud emprunté, ou NULL.
|
||||
*/
|
||||
void workspace_set_selected_node(
|
||||
Workspace *workspace,
|
||||
const InvestigationNode *node
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche la fiche détaillée d'une preuve.
|
||||
*
|
||||
* Les textes sont copiés dans les widgets. Le Workspace ne conserve pas le
|
||||
* pointeur EvidenceRecord.
|
||||
*
|
||||
* Passer NULL restaure l'état principal courant.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param evidence_record Preuve empruntée, ou NULL.
|
||||
*/
|
||||
void workspace_set_selected_evidence(
|
||||
Workspace *workspace,
|
||||
const EvidenceRecord *evidence_record
|
||||
);
|
||||
void workspace_set_identity_ocr_runs(
|
||||
Workspace *workspace, GPtrArray *owned_records);
|
||||
void workspace_set_identity_ocr_callback(
|
||||
Workspace *workspace, WorkspaceIdentityOcrCallback callback,
|
||||
gpointer user_data);
|
||||
void workspace_set_evidence_observations(Workspace *workspace,
|
||||
const GPtrArray *observations);
|
||||
void workspace_set_observation_remove_callback(Workspace *workspace,
|
||||
WorkspaceObservationRemoveCallback callback, gpointer user_data);
|
||||
void workspace_set_eml_analysis_available(
|
||||
Workspace *workspace,
|
||||
gboolean available
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche l'aperçu local d'un fichier image ou vidéo.
|
||||
*
|
||||
* file_path doit être un chemin absolu déjà validé par l'application.
|
||||
* Passer NULL libère l'aperçu courant.
|
||||
*/
|
||||
void workspace_set_evidence_preview(Workspace *workspace,
|
||||
const char *file_path, const char *display_name);
|
||||
void workspace_show_evidence_preview(Workspace *workspace,
|
||||
const char *investigation_root, const EvidenceRecord *record);
|
||||
|
||||
/**
|
||||
* @brief Sélectionne une entité dans le graphe affiché.
|
||||
*
|
||||
* La sélection actualise également le volet de détails et le contexte OSINT.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param entity_identifier UUID de l'entité à sélectionner.
|
||||
*
|
||||
* @return TRUE lorsque l'entité est trouvée et sélectionnée.
|
||||
*/
|
||||
gboolean workspace_select_graph_entity(
|
||||
Workspace *workspace,
|
||||
const char *entity_identifier
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Sélectionne une relation dans le graphe affiché.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param relation_identifier UUID de la relation à sélectionner.
|
||||
*
|
||||
* @return TRUE lorsque la relation est trouvée et sélectionnée.
|
||||
*/
|
||||
gboolean workspace_select_graph_relation(
|
||||
Workspace *workspace,
|
||||
const char *relation_identifier
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Actualise l'état d'un outil requis par les actions OSINT.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param tool_identifier Identifiant stable de l'outil.
|
||||
* @param state État traduit par la couche applicative.
|
||||
* @param version Version détectée facultative.
|
||||
*/
|
||||
void workspace_set_osint_tool_state(
|
||||
Workspace *workspace,
|
||||
const char *tool_identifier,
|
||||
OsintActionToolState state,
|
||||
const char *version
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de déclenchement des actions OSINT.
|
||||
*
|
||||
* Le Workspace ne lance aucun outil externe. Il relaie uniquement l'action
|
||||
* et la valeur du contexte courant vers la couche applicative.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données empruntées transmises au callback.
|
||||
*/
|
||||
void workspace_set_osint_action_callback(
|
||||
Workspace *workspace,
|
||||
WorkspaceOsintActionCallback 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 d'analyse locale d'une preuve EML. */
|
||||
void workspace_set_analyze_eml_callback(Workspace *workspace,
|
||||
WorkspaceAnalyzeEmlCallback callback, gpointer user_data);
|
||||
/** @brief Définit le callback d'analyse OCR d'un RIB. */
|
||||
void workspace_set_analyze_rib_callback(Workspace *workspace,
|
||||
WorkspaceAnalyzeRibCallback callback, gpointer user_data);
|
||||
/** @brief Définit le callback d'extraction locale des métadonnées. */
|
||||
void workspace_set_extract_metadata_callback(Workspace *workspace,
|
||||
WorkspaceExtractMetadataCallback callback, gpointer user_data);
|
||||
/** @brief Définit le callback de récupération d'un mot de passe PDF. */
|
||||
void workspace_set_recover_pdf_password_callback(Workspace *workspace,
|
||||
WorkspaceRecoverPdfPasswordCallback callback, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de vérification de la preuve affichée.
|
||||
*
|
||||
* Le Workspace emprunte callback et user_data.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param callback Callback à utiliser, ou NULL.
|
||||
* @param user_data Données empruntées du callback.
|
||||
*/
|
||||
void workspace_set_verify_evidence_callback(
|
||||
Workspace *workspace,
|
||||
WorkspaceVerifyEvidenceCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de fin de déplacement d'un nœud.
|
||||
*
|
||||
* Le Workspace relaie uniquement l'événement provenant de la vue graphique.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données empruntées transmises au callback.
|
||||
*/
|
||||
void workspace_set_graph_node_moved_callback(
|
||||
Workspace *workspace,
|
||||
WorkspaceGraphNodeMovedCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
void workspace_set_graph_transform_changed_callback(
|
||||
Workspace *workspace,
|
||||
WorkspaceGraphTransformChangedCallback callback,
|
||||
gpointer user_data);
|
||||
|
||||
void workspace_set_extraction_drop_callback(
|
||||
Workspace *workspace,
|
||||
WorkspaceExtractionDropCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de demande de réinitialisation du graphe.
|
||||
*
|
||||
* Le Workspace emprunte callback et user_data.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données empruntées transmises au callback.
|
||||
*/
|
||||
void workspace_set_reset_graph_layout_callback(
|
||||
Workspace *workspace,
|
||||
WorkspaceResetGraphLayoutCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de demande d'ajout d'une relation.
|
||||
*
|
||||
* Le Workspace relaie uniquement l'événement du volet de détails.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données empruntées transmises au callback.
|
||||
*/
|
||||
void workspace_set_add_relation_callback(
|
||||
Workspace *workspace,
|
||||
WorkspaceAddRelationCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/** @brief Définit le callback de modification d'une relation. */
|
||||
void workspace_set_edit_relation_callback(
|
||||
Workspace *workspace,
|
||||
WorkspaceEditRelationCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
/** @brief Définit le callback de suppression d'une relation. */
|
||||
void workspace_set_delete_relation_callback(Workspace *workspace,
|
||||
WorkspaceDeleteRelationCallback callback, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Définit le callback de sélection d'une relation.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param callback Callback facultatif.
|
||||
* @param user_data Données empruntées transmises au callback.
|
||||
*/
|
||||
void workspace_set_relation_selected_callback(
|
||||
Workspace *workspace,
|
||||
WorkspaceRelationSelectedCallback callback,
|
||||
gpointer user_data
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Affiche les preuves associées à la relation sélectionnée.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param evidence_records Tableau emprunté de EvidenceRecord, ou NULL.
|
||||
*/
|
||||
void workspace_set_relation_evidences(
|
||||
Workspace *workspace,
|
||||
const GPtrArray *evidence_records
|
||||
);
|
||||
|
||||
/** @brief Définit le callback de catégorisation d'une personne. */
|
||||
void workspace_set_person_role_callback(Workspace *workspace,
|
||||
WorkspacePersonRoleCallback callback, gpointer user_data);
|
||||
/** @brief Définit le callback de confiance d'une personne. */
|
||||
void workspace_set_person_confidence_callback(Workspace *workspace,
|
||||
WorkspacePersonConfidenceCallback callback, gpointer user_data);
|
||||
|
||||
/**
|
||||
* @brief Affiche l'état de chargement du graphe.
|
||||
*
|
||||
* L'ancien graphe emprunté est détaché.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
*/
|
||||
void workspace_set_graph_loading(
|
||||
Workspace *workspace
|
||||
);
|
||||
/** @brief Définit le callback de modification du nom affiché. */
|
||||
void workspace_set_person_name_callback(Workspace *workspace,
|
||||
WorkspacePersonNameCallback callback, gpointer user_data);
|
||||
/** @brief Définit le callback de gestion des preuves d'une personne. */
|
||||
void workspace_set_person_evidence_callback(Workspace *workspace,
|
||||
WorkspacePersonEvidenceCallback callback, gpointer user_data);
|
||||
void workspace_set_evidence_activated_callback(
|
||||
Workspace *workspace,
|
||||
WorkspaceEvidenceActivatedCallback callback,
|
||||
gpointer user_data);
|
||||
/** @brief Définit le callback de sélection d'une entité. */
|
||||
void workspace_set_entity_selected_callback(Workspace *workspace,
|
||||
WorkspaceEntitySelectedCallback callback, gpointer user_data);
|
||||
/** @brief Affiche les preuves de la personne sélectionnée. */
|
||||
void workspace_set_person_evidences(Workspace *workspace,
|
||||
const GPtrArray *records);
|
||||
void workspace_set_person_factual_relations(Workspace *workspace,
|
||||
const GPtrArray *relations, const GPtrArray *evidence_records,const GHashTable *profile_fields);
|
||||
|
||||
/**
|
||||
* @brief Affiche le graphe chargé avec sa disposition persistée.
|
||||
*
|
||||
* Le Workspace emprunte graph_model et graph_layout et ne les libère jamais.
|
||||
*
|
||||
* Passer NULL pour graph_model équivaut à workspace_clear_graph().
|
||||
* Passer NULL pour graph_layout conserve le placement automatique.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param graph_model Graphe emprunté.
|
||||
* @param graph_layout Disposition empruntée, ou NULL.
|
||||
*/
|
||||
void workspace_set_graph(
|
||||
Workspace *workspace,
|
||||
const InvestigationGraphModel *graph_model,
|
||||
const InvestigationGraphLayout *graph_layout
|
||||
);
|
||||
|
||||
gboolean workspace_get_graph_view_transform(
|
||||
Workspace *workspace, double *zoom, double *offset_x, double *offset_y);
|
||||
void workspace_set_graph_view_transform(
|
||||
Workspace *workspace, double zoom, double offset_x, double offset_y);
|
||||
|
||||
/**
|
||||
* @brief Affiche une erreur de chargement du graphe.
|
||||
*
|
||||
* Le texte du message est immédiatement copié par GTK.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
* @param message Détail de l'erreur, ou NULL.
|
||||
*/
|
||||
void workspace_set_graph_error(
|
||||
Workspace *workspace,
|
||||
const char *message
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Détache le graphe et restaure l'état sans enquête.
|
||||
*
|
||||
* Le graphe précédemment emprunté n'est jamais libéré par le Workspace.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
*/
|
||||
void workspace_clear_graph(
|
||||
Workspace *workspace
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Reconstruit visuellement la disposition automatique du graphe.
|
||||
*
|
||||
* Cette fonction ne touche ni SQLite ni InvestigationGraphLayout. Elle doit
|
||||
* être appelée par la couche applicative uniquement après la suppression
|
||||
* réussie des positions persistées.
|
||||
*
|
||||
* Le zoom et le déplacement global du canvas sont conservés.
|
||||
*
|
||||
* @param workspace Zone de travail.
|
||||
*/
|
||||
void workspace_reset_graph_layout(
|
||||
Workspace *workspace
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Libère la structure Workspace.
|
||||
*
|
||||
* Cette fonction accepte NULL. Elle ne libère jamais le graphe emprunté.
|
||||
*
|
||||
* @param workspace Zone de travail à libérer.
|
||||
*/
|
||||
void workspace_free(
|
||||
Workspace *workspace
|
||||
);
|
||||
|
||||
#endif
|
||||
|
|
@ -137,6 +137,29 @@ capabilities par objet, provenance multiple, deux reconnexions SSE, conservation
|
|||
du contexte, doublon, trou et rattrapage snapshot. Les captures sont des preuves
|
||||
visuelles complémentaires ; les assertions d'interaction font foi.
|
||||
|
||||
### Relevé du polish visuel Web V1 du 27 septembre 2026
|
||||
|
||||
La coque Web actuelle est organisée autour de **Agent | Graphe | Activité** et
|
||||
d’un tiroir transversal. Le graphe reste la surface dominante. Les panneaux
|
||||
latéraux et le tiroir sont repliables ; sur desktop, leurs dimensions sont
|
||||
redimensionnables et conservées comme préférences locales par origine. Cette
|
||||
préférence ne contient ni données d’enquête, ni token, ni état métier.
|
||||
|
||||
L’Agent affiche un flux SPECIMEN typé, une demande d’autorisation explicitant
|
||||
cible, provider, données sortantes, action, exposition, budget et raison. Il
|
||||
indique clairement qu’aucun modèle local n’est connecté : cette surface prépare
|
||||
un raccordement futur, elle ne simule aucun appel IA. Activité est un terminal
|
||||
structuré filtrable ; son mode expert ne révèle que des paramètres SPECIMEN non
|
||||
secrets et une provenance bornée.
|
||||
|
||||
test_visual_polish_browser.mjs, inclus dans npm run test:browser, exerce le
|
||||
resize et sa restauration, les panneaux repliés, le prompt Ctrl+Entrée, le
|
||||
filtre/inspecteur d’activité, le drag/pin, le mode graphe prioritaire, les
|
||||
tailles 1440×900, 1280×800, 1024×768 et 700×900, ainsi qu’un diagnostic
|
||||
d’erreur de contrat. Il produit des captures inspectables sous
|
||||
/tmp/labfy-visual-polish-*.png, toutes sur workspace et serveur loopback
|
||||
temporaires SPECIMEN.
|
||||
|
||||
### Relevé du lot J3 local du 26 septembre 2026
|
||||
|
||||
| Validation | Résultat observé |
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/test_graph_state.mjs",
|
||||
"test:browser": "node tests/test_browser.mjs && node tests/test_core_browser.mjs && node tests/test_eml_browser.mjs && node tests/test_local_toolkit_browser.mjs && node tests/test_workspace_control_browser.mjs && node tests/test_j8_browser.mjs && node tests/test_j8_runtime_browser.mjs && node tests/test_j9_browser.mjs && node tests/test_workbench_browser.mjs && node tests/test_workbench_populated_browser.mjs && node tests/test_local_workspace_import_browser.mjs"
|
||||
"test:browser": "node tests/run_browser_suite.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"puppeteer-core": "24.28.0"
|
||||
|
|
|
|||
873
prototypes/web-graph/public/app.js
vendored
873
prototypes/web-graph/public/app.js
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -7,14 +7,28 @@
|
|||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<section id="workspace-create" class="modal-card" hidden aria-labelledby="create-title">
|
||||
<h1 id="create-title">Créer l’enquête</h1>
|
||||
<p>La destination locale a été fixée au lancement. Aucun exemple ne sera ajouté.</p>
|
||||
<form id="workspace-create-form"><label>Titre
|
||||
<input id="workspace-title" maxlength="160" required /></label>
|
||||
<button type="submit">Créer l’enquête vide</button></form>
|
||||
<p id="workspace-create-status" role="alert"></p>
|
||||
</section>
|
||||
<main id="library-home" class="library-home" aria-labelledby="library-title" hidden>
|
||||
<header class="library-header">
|
||||
<div><span class="eyebrow">Poste local</span><h1 id="library-title" tabindex="-1">Bibliothèque d’enquêtes</h1></div>
|
||||
<span id="library-connection" role="status">Chargement…</span>
|
||||
</header>
|
||||
<section id="workspace-create" class="library-create" aria-labelledby="create-title">
|
||||
<h2 id="create-title">Nouvelle enquête</h2>
|
||||
<p>Crée un espace local vide. Aucun contenu d’exemple n’est ajouté.</p>
|
||||
<form id="workspace-create-form">
|
||||
<label for="workspace-title">Titre de l’enquête</label>
|
||||
<div class="library-create-row"><input id="workspace-title" maxlength="160" required autocomplete="off" />
|
||||
<button type="submit">Créer</button></div>
|
||||
</form>
|
||||
<p id="workspace-create-status" role="alert"></p>
|
||||
</section>
|
||||
<section id="library-list-section" aria-labelledby="workspace-list-title">
|
||||
<h2 id="workspace-list-title">Enquêtes disponibles</h2>
|
||||
<p id="library-empty" class="empty-state" hidden>Aucune enquête. Créez votre premier espace local pour commencer.</p>
|
||||
<ul id="workspace-list" class="workspace-list" aria-live="polite"></ul>
|
||||
</section>
|
||||
</main>
|
||||
<div id="workbench-shell" hidden>
|
||||
<dialog id="import-dialog" aria-labelledby="import-title">
|
||||
<form method="dialog"><button class="dialog-close" aria-label="Fermer">×</button></form>
|
||||
<h2 id="import-title">Ajouter des preuves</h2>
|
||||
|
|
@ -31,7 +45,7 @@
|
|||
<div class="brand">
|
||||
<strong>LABFY</strong><span id="investigation-title">Enquête locale</span
|
||||
><span id="mode-badge" class="badge"
|
||||
>Prototype — données synthétiques — lecture seule</span
|
||||
>Espace local · contrôles locaux</span
|
||||
>
|
||||
</div>
|
||||
<label class="global-search"
|
||||
|
|
@ -42,7 +56,7 @@
|
|||
placeholder="Rechercher un objet…"
|
||||
/><button id="search-button">Recentrer</button></label
|
||||
>
|
||||
<div id="connection" role="status">Chargement…</div>
|
||||
<div class="topbar-status"><span id="connection" role="status">Chargement…</span><button id="graph-fullscreen" class="icon-button" type="button" aria-pressed="false" title="Agrandir le graphe">⛶<span class="sr-only">Agrandir le graphe</span></button></div>
|
||||
</header>
|
||||
<nav class="projection-bar" aria-label="Projections et contrôles du graphe">
|
||||
<div class="projection-tabs" role="group">
|
||||
|
|
@ -54,6 +68,8 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="graph-tools">
|
||||
<button id="home-button">Accueil</button
|
||||
>
|
||||
<button id="open-import">Ajouter des preuves</button
|
||||
>
|
||||
<button id="back" disabled>← Retour</button
|
||||
|
|
@ -75,7 +91,26 @@
|
|||
</div>
|
||||
</nav>
|
||||
<main class="workbench">
|
||||
<aside id="agent-panel" class="agent-panel" aria-label="Agent IA local">
|
||||
<div class="panel-heading"><div><span class="eyebrow">Agent IA · préparation</span><h2>Mission</h2></div><button class="pane-toggle" data-pane="agent-panel" aria-expanded="true">Réduire</button></div>
|
||||
<label class="agent-prompt"><span>Prompt à l’agent</span><textarea id="agent-prompt" maxlength="2000" placeholder="Décrivez l’intention d’enquête…" aria-describedby="agent-prompt-hint"></textarea><small id="agent-prompt-hint">Ctrl+Entrée pour préparer la demande · aucune action n’est lancée.</small><div class="agent-prompt-actions"><button type="button" id="agent-attachment" disabled title="Pièces jointes indisponibles dans cette tranche">Joindre</button><button type="button" id="agent-send">Préparer une demande</button></div></label>
|
||||
<div class="agent-scroll">
|
||||
<div class="agent-status"><span aria-hidden="true">◌</span><span>IA locale non connectée</span><small>Surface prête pour un agent futur</small></div>
|
||||
<p class="agent-mission">Cartographier les relations autour de <strong>example.test</strong> en respectant les autorisations explicites.</p>
|
||||
<ol class="agent-conversation" aria-label="Conversation SPECIMEN">
|
||||
<li class="agent-card user"><b>USER</b><p>Montre les pivots passifs disponibles.</p></li>
|
||||
<li class="agent-card agent"><b>AGENT</b><p>Je prépare un plan explicable. Aucune action réseau ne part sans capability et autorisation.</p></li>
|
||||
<li class="agent-card plan"><b>PLAN</b><p>1. Lire les observations · 2. Proposer les capabilities · 3. Attendre l’accord.</p></li>
|
||||
<li class="agent-card hypothesis"><b>HYPOTHESIS</b><p>Le domaine et l’adresse contact peuvent partager une provenance, sans confirmer une identité.</p></li>
|
||||
<li class="agent-card authorization"><b>AUTHORIZATION</b><p><strong>rdap.lookup_domain</strong></p><dl><dt>Cible</dt><dd>example.test</dd><dt>Provider</dt><dd>SPECIMEN loopback</dd><dt>Données sortantes</dt><dd>Nom de domaine</dd><dt>Action</dt><dd>Lookup passif</dd><dt>Exposition</dt><dd>Passive</dd><dt>Budget</dt><dd>1 requête</dd><dt>Raison</dt><dd>Vérifier une provenance déclarée</dd></dl><button type="button">Examiner l’autorisation</button></li>
|
||||
<li class="agent-card result"><b>RESULT</b><p>7 observations reçues ; aucune relation n’est promue automatiquement.</p></li>
|
||||
<li class="agent-card warning"><b>WARNING</b><p>Les hypothèses restent distinctes des observations et des décisions humaines.</p></li>
|
||||
</ol>
|
||||
</div>
|
||||
</aside>
|
||||
<div id="agent-resizer" class="pane-resizer pane-resizer-vertical" role="separator" aria-label="Redimensionner le panneau Agent" aria-orientation="vertical" tabindex="0"></div>
|
||||
<section id="graph-panel" aria-label="Graphe interactif">
|
||||
<div class="graph-toolbar" aria-label="Commandes rapides du graphe"><button id="graph-search-focus" type="button" title="Rechercher dans le graphe">⌕<span class="sr-only">Rechercher</span></button><button id="graph-home" type="button" title="Recentrer le graphe">⌂<span class="sr-only">Recentrer</span></button><button id="graph-reset-layout" type="button" title="Réinitialiser les positions épinglées">↺<span class="sr-only">Réinitialiser les positions</span></button><span id="graph-zoom" aria-live="polite">100 %</span></div>
|
||||
<div id="graph-state" role="alert"></div>
|
||||
<div class="graph-legend" aria-label="Légende">
|
||||
<span><i class="shape round"></i>Entité</span
|
||||
|
|
@ -89,7 +124,7 @@
|
|||
<svg
|
||||
id="graph"
|
||||
tabindex="0"
|
||||
aria-label="Réseau d’investigation synthétique"
|
||||
aria-label="Réseau d’investigation"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
|
|
@ -131,10 +166,18 @@
|
|||
<aside id="inspector" aria-label="Inspecteur contextuel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="eyebrow">Objet sélectionné</span>
|
||||
<h2>Inspecteur</h2>
|
||||
<span class="eyebrow">Activité · outils · jobs</span>
|
||||
<h2>Activité</h2>
|
||||
</div>
|
||||
<button class="pane-toggle" data-pane="inspector" aria-expanded="true">Réduire</button>
|
||||
</div>
|
||||
<div class="activity-controls"><label>Filtrer <select id="activity-filter"><option value="">Tous les événements</option><option>PLAN</option><option>POLICY</option><option>TOOL</option><option>JOB</option><option>NETWORK</option><option>RESULT</option><option>ERROR</option><option>RESOURCE</option><option>AGENT</option></select></label><button id="activity-expert-toggle" type="button" aria-pressed="false">Mode expert</button></div>
|
||||
<section class="activity-stream" aria-label="Timeline technique SPECIMEN">
|
||||
<details class="activity-entry" data-activity-type="TOOL"><summary><time>15:42:12</time><b>TOOL</b><span>rdap.lookup_domain<br/><code>target = example.test</code></span></summary><pre>provider: specimen-loopback\nlatence: 34 ms\nsecret: [non affiché]</pre></details>
|
||||
<details class="activity-entry policy" data-activity-type="POLICY"><summary><time>15:42:12</time><b>POLICY</b><span>Autorisé · budget restant 18/20</span></summary><pre>risk_class: PASSIVE\nauthorization: accord explicite</pre></details>
|
||||
<details class="activity-entry result" data-activity-type="RESULT"><summary><time>15:42:13</time><b>RESULT</b><span>1 artefact · 7 observations</span></summary><pre>provenance: tool-result/specimen\nstdout: borné</pre></details>
|
||||
</section>
|
||||
<details class="system-card"><summary>Système · ressources réelles</summary><dl><dt>CPU</dt><dd id="system-cpu">Disponible via backend</dd><dt>RAM</dt><dd id="system-ram">Disponible via backend</dd><dt>Jobs actifs</dt><dd id="system-jobs">Lecture JobStore</dd><dt>Instance Web</dt><dd id="system-instance">Loopback</dd><dt>Providers</dt><dd id="system-providers">Selon capabilities</dd></dl><p class="future-slots">Prévu : modèle · quantification · GPU AMD · KV cache · réserve RAM · swap · scratch IA.</p></details>
|
||||
<div class="tablist" role="tablist">
|
||||
<button role="tab" data-panel="details-panel" aria-selected="true">
|
||||
Détails</button
|
||||
|
|
@ -174,25 +217,46 @@
|
|||
</table>
|
||||
</section>
|
||||
</aside>
|
||||
<div id="inspector-resizer" class="pane-resizer pane-resizer-vertical" role="separator" aria-label="Redimensionner le panneau Activité" aria-orientation="vertical" tabindex="0"></div>
|
||||
<section id="work-panel" class="work-panel" aria-label="Travail local">
|
||||
<div class="work-panel-header">
|
||||
<div class="tablist" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
data-work-panel="tasks-panel"
|
||||
aria-selected="true"
|
||||
>
|
||||
Tâches</button
|
||||
<button role="tab" data-work-panel="details-drawer-panel">Détails</button
|
||||
><button role="tab" data-work-panel="provenance-drawer-panel">Provenance</button
|
||||
><button role="tab" data-work-panel="timeline-drawer-panel">Timeline</button
|
||||
><button role="tab" data-work-panel="research-panel">Recherches</button
|
||||
><button role="tab" data-work-panel="observations-drawer-panel">Observations</button
|
||||
><button role="tab" data-work-panel="finance-drawer-panel">Finance</button
|
||||
><button role="tab" data-work-panel="reports-panel">Rapports</button
|
||||
><button role="tab" data-work-panel="tasks-panel" aria-selected="true">Tâches</button
|
||||
><button role="tab" data-work-panel="next-panel">
|
||||
Prochaines actions</button
|
||||
><button role="tab" data-work-panel="plans-panel">Plans</button
|
||||
><button role="tab" data-work-panel="reports-panel">
|
||||
Rapports
|
||||
</button>
|
||||
</div>
|
||||
<button id="work-panel-toggle" aria-expanded="true">Réduire</button>
|
||||
</div>
|
||||
<div class="work-panel-body">
|
||||
<section id="details-drawer-panel" class="work-tab-panel" hidden>
|
||||
<h3>Détails de la sélection</h3>
|
||||
<p id="drawer-details-summary">Sélectionnez un objet du graphe pour conserver son contexte ici.</p>
|
||||
</section>
|
||||
<section id="provenance-drawer-panel" class="work-tab-panel" hidden>
|
||||
<h3>Chaîne de provenance</h3>
|
||||
<p id="drawer-provenance-summary">Aucune source sélectionnée.</p>
|
||||
<p class="empty-state">Les dérivés et transformations sont affichés sans confondre observation et hypothèse.</p>
|
||||
</section>
|
||||
<section id="timeline-drawer-panel" class="work-tab-panel" hidden>
|
||||
<h3>Timeline</h3>
|
||||
<p id="drawer-timeline-summary">La chronologie pertinente apparaît avec l’objet sélectionné.</p>
|
||||
</section>
|
||||
<section id="observations-drawer-panel" class="work-tab-panel" hidden>
|
||||
<h3>Observations</h3>
|
||||
<p id="drawer-observations-summary">Aucune observation à promouvoir automatiquement.</p>
|
||||
</section>
|
||||
<section id="finance-drawer-panel" class="work-tab-panel" hidden>
|
||||
<h3>Finance</h3>
|
||||
<p class="empty-state">Aucune donnée financière n’est simulée dans cette vue SPECIMEN.</p>
|
||||
</section>
|
||||
<section id="tasks-panel" class="work-tab-panel">
|
||||
<h3>Jobs locaux</h3>
|
||||
<p id="jobs-note">Chargement…</p>
|
||||
|
|
@ -221,6 +285,25 @@
|
|||
<h3>Plans approuvés</h3>
|
||||
<ol id="plans-list"></ol>
|
||||
</section>
|
||||
<section id="research-panel" class="work-tab-panel" hidden>
|
||||
<h3>Recherches assistées</h3>
|
||||
<p id="research-note" role="status">
|
||||
Sélectionnez une preuve ou une entité dans le graphe.
|
||||
</p>
|
||||
<form id="research-form" class="research-form">
|
||||
<label>Question
|
||||
<textarea id="research-question" maxlength="512" required></textarea>
|
||||
</label>
|
||||
<label>Exclusions explicites
|
||||
<input id="research-exclusions" maxlength="512"
|
||||
placeholder="Un sujet exact par ligne" />
|
||||
</label>
|
||||
<button id="research-prepare" type="submit">Préparer le plan</button>
|
||||
</form>
|
||||
<div id="research-plan" aria-live="polite"></div>
|
||||
<button id="research-launch" disabled>Autoriser et lancer la sélection</button>
|
||||
<button id="research-revoke" disabled>Révoquer l’autorisation</button>
|
||||
</section>
|
||||
<section id="reports-panel" class="work-tab-panel" hidden>
|
||||
<div id="report-panel">
|
||||
<h3 id="report-title">Préparer le rapport</h3>
|
||||
|
|
@ -231,7 +314,7 @@
|
|||
<input
|
||||
id="report-name"
|
||||
maxlength="160"
|
||||
value="Rapport SPECIMEN" /></label
|
||||
value="Rapport d’enquête" /></label
|
||||
><label
|
||||
>Commentaire humain
|
||||
<textarea id="report-comment" maxlength="1000"></textarea>
|
||||
|
|
@ -274,6 +357,7 @@
|
|||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<div id="drawer-resizer" class="pane-resizer pane-resizer-horizontal" role="separator" aria-label="Redimensionner le tiroir inférieur" aria-orientation="horizontal" tabindex="0"></div>
|
||||
</main>
|
||||
<dialog id="evidence-dialog" aria-labelledby="evidence-title">
|
||||
<form method="dialog" class="dialog-close"><button>Fermer</button></form>
|
||||
|
|
@ -293,6 +377,7 @@
|
|||
<ol id="observation-list"></ol>
|
||||
</section>
|
||||
</dialog>
|
||||
</div>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Labfy — session locale</title><link rel="stylesheet" href="styles.css"></head>
|
||||
<body class="login"><main><section class="login-card"><h1>Labfy · poste local</h1>
|
||||
<p>Entrez le code éphémère affiché par le lanceur. Il n’est ni placé dans l’URL ni conservé dans le navigateur.</p>
|
||||
<form id="login-form"><label>Code de session <input id="bootstrap-code" type="password" autocomplete="off" required></label>
|
||||
<button type="submit">Ouvrir la session</button></form><p id="login-status" role="alert"></p>
|
||||
</section></main><script type="module" src="login.js"></script></body></html>
|
||||
13
prototypes/web-graph/public/login.js
vendored
13
prototypes/web-graph/public/login.js
vendored
|
|
@ -1,13 +0,0 @@
|
|||
const form = document.getElementById("login-form");
|
||||
const status = document.getElementById("login-status");
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const bootstrapCode = document.getElementById("bootstrap-code").value;
|
||||
const response = await fetch("/api/v1/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bootstrap_code: bootstrapCode }),
|
||||
});
|
||||
if (response.ok) location.replace("/");
|
||||
else status.textContent = "Code refusé ou session indisponible.";
|
||||
});
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
:root {
|
||||
--base: #1e1e2e;
|
||||
--mantle: #181825;
|
||||
--crust: #11111b;
|
||||
--panel: #181825;
|
||||
--surface0: #313244;
|
||||
--surface1: #45475a;
|
||||
--raised: #313244;
|
||||
--border: #45475a;
|
||||
--accent: #b4befe;
|
||||
|
|
@ -12,7 +16,7 @@
|
|||
--yellow: #f9e2af;
|
||||
--top: 3.6rem;
|
||||
--nav: 3.3rem;
|
||||
--drawer: 15rem;
|
||||
--drawer: 240px;
|
||||
}
|
||||
|
||||
.modal-card { position: fixed; inset: 50% auto auto 50%; transform: translate(-50%,-50%);
|
||||
|
|
@ -76,6 +80,77 @@ textarea:focus-visible,
|
|||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
#workbench-shell {
|
||||
height: 100%;
|
||||
}
|
||||
.library-home {
|
||||
width: min(72rem, 100%);
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: clamp(1rem, 4vw, 3rem);
|
||||
overflow: auto;
|
||||
}
|
||||
.library-header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.library-header h1 {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
#library-connection {
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
}
|
||||
.library-create {
|
||||
margin: 1.5rem 0 2rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.library-create h2,
|
||||
.library-create p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.library-create-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.library-create-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.workspace-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr));
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.workspace-card,
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.workspace-card h3 {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.workspace-card p,
|
||||
.login-privacy {
|
||||
color: var(--muted);
|
||||
}
|
||||
.workspace-card button {
|
||||
width: 100%;
|
||||
}
|
||||
.topbar {
|
||||
height: var(--top);
|
||||
display: grid;
|
||||
|
|
@ -394,6 +469,7 @@ aside h4,
|
|||
}
|
||||
.work-panel.collapsed {
|
||||
grid-template-rows: auto 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.work-panel-header {
|
||||
display: flex;
|
||||
|
|
@ -457,6 +533,10 @@ body.login .workbench {
|
|||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.login-card form {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
:root {
|
||||
--top: 6.2rem;
|
||||
|
|
@ -506,6 +586,25 @@ body.login .workbench {
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.library-header,
|
||||
.library-create-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
#library-connection {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
#graph:focus-visible {
|
||||
outline-color: Highlight;
|
||||
}
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.badge {
|
||||
display: none;
|
||||
|
|
@ -562,7 +661,7 @@ body.login .workbench {
|
|||
display: none;
|
||||
}
|
||||
.workbench.drawer-collapsed {
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
grid-template-rows: minmax(0, 1fr) 2.7rem;
|
||||
}
|
||||
|
||||
#evidence-dialog {
|
||||
|
|
@ -605,8 +704,74 @@ body.login .workbench {
|
|||
.observation-actions input {
|
||||
min-width: 12rem;
|
||||
}
|
||||
.research-form {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.research-form label,
|
||||
.research-card {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.research-card {
|
||||
padding: 0.8rem;
|
||||
margin: 0.7rem 0;
|
||||
border: 1px solid var(--surface1);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--mantle);
|
||||
}
|
||||
.research-card h5,
|
||||
.research-card p {
|
||||
margin: 0;
|
||||
}
|
||||
.research-card fieldset {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
border-color: var(--surface1);
|
||||
}
|
||||
.research-contacted {
|
||||
color: var(--green);
|
||||
}
|
||||
.research-uncontacted {
|
||||
color: var(--yellow);
|
||||
}
|
||||
/* CONTRACT: la coque finale garde le graphe dominant ; les trois surfaces
|
||||
* sont des vues de la même enquête, jamais des stores concurrents. */
|
||||
.topbar-status { display: flex; align-items: center; justify-content: end; gap: .45rem; min-width: 0; }
|
||||
.icon-button, .graph-toolbar button { display: inline-grid; place-items: center; min-width: 2.1rem; min-height: 2.1rem; padding: .25rem; }
|
||||
.workbench { position: relative; --agent-pane: 320px; --inspector-pane: 368px; grid-template-columns: minmax(13rem, var(--agent-pane)) minmax(0, 1fr) minmax(16rem, var(--inspector-pane)); grid-template-areas: "agent graph inspector" "work work work"; }
|
||||
.agent-panel { grid-area: agent; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); background: linear-gradient(180deg, var(--panel), var(--crust)); border-right: 1px solid var(--border); }
|
||||
.panel-heading { display: flex; align-items: start; justify-content: space-between; gap: .5rem; padding: .75rem .75rem 0; }
|
||||
.agent-scroll { min-height: 0; overflow: auto; padding: .75rem; }
|
||||
.agent-status { display: grid; grid-template-columns: auto 1fr; gap: .15rem .45rem; align-items: center; padding: .5rem .6rem; margin-bottom: .7rem; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); border-radius: .65rem; color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--crust)); }
|
||||
.agent-status small { grid-column: 2; color: var(--muted); }
|
||||
.agent-mission { margin: 0 0 .8rem; color: var(--muted); line-height: 1.45; }
|
||||
.agent-conversation { display: grid; gap: .55rem; margin: 0; padding: 0; list-style: none; }
|
||||
.agent-card { border: 1px solid var(--border); border-left: 3px solid var(--blue); border-radius: .65rem; padding: .6rem .7rem; background: color-mix(in srgb, var(--base) 88%, var(--surface0)); box-shadow: 0 .3rem .8rem rgb(0 0 0 / 12%); }
|
||||
.agent-card b { font-size: .68rem; letter-spacing: .09em; color: var(--muted); }
|
||||
.agent-card p { margin: .3rem 0 0; line-height: 1.4; }
|
||||
.agent-card.agent { border-left-color: var(--blue); }.agent-card.plan { border-left-color: var(--accent); }.agent-card.hypothesis { border-left-color: var(--yellow); }.agent-card.authorization { border-left-color: #cba6f7; }.agent-card.result { border-left-color: var(--green); }.agent-card.warning { border-left-color: var(--red); }
|
||||
.agent-card.authorization dl { display: grid; grid-template-columns: minmax(5.5rem, auto) 1fr; gap: .2rem .5rem; margin: .55rem 0; font-size: .78rem; }.agent-card.authorization dt { color: var(--muted); }.agent-card.authorization dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.agent-prompt { z-index: 1; display: grid; gap: .4rem; margin: .5rem .75rem .35rem; padding: .55rem; background: var(--mantle); border: 1px solid var(--border); border-radius: .65rem; }.agent-prompt textarea { min-height: 5.5rem; max-height: 9rem; resize: vertical; }.agent-prompt small { color: var(--muted); }.agent-prompt-actions { display: flex; justify-content: space-between; gap: .4rem; }.pane-toggle { padding: .28rem .48rem; font-size: .78rem; }
|
||||
.graph-toolbar { position: absolute; z-index: 4; left: .65rem; bottom: 2.35rem; display: flex; align-items: center; gap: .3rem; padding: .3rem; border: 1px solid var(--border); border-radius: .65rem; background: rgb(17 17 27 / 88%); box-shadow: 0 .5rem 1.5rem rgb(0 0 0 / 22%); }.graph-toolbar button { background: var(--surface0); }.graph-toolbar span { min-width: 3.6rem; color: var(--muted); font-size: .75rem; text-align: center; }
|
||||
.activity-controls { display: flex; align-items: center; justify-content: space-between; gap: .45rem; margin: 0 0 .55rem; font-size: .78rem; }.activity-controls label { display: flex; align-items: center; gap: .3rem; min-width: 0; }.activity-controls select { min-width: 0; max-width: 10.6rem; padding: .28rem; }.activity-controls button { padding: .3rem .42rem; white-space: nowrap; }
|
||||
.activity-stream { display: grid; gap: .35rem; margin-bottom: .75rem; padding-bottom: .75rem; border-bottom: 1px solid var(--border); }.activity-entry { padding: .42rem; border: 1px solid transparent; border-radius: .48rem; background: var(--crust); font-size: .78rem; }.activity-entry summary { display: grid; grid-template-columns: 4.4rem 4.2rem minmax(0, 1fr); gap: .3rem; cursor: pointer; list-style: none; }.activity-entry summary::-webkit-details-marker { display: none; }.activity-entry time { color: var(--muted); }.activity-entry b { color: var(--blue); font-size: .7rem; }.activity-entry.policy b { color: var(--yellow); }.activity-entry.result b { color: var(--green); }.activity-entry code { color: var(--muted); overflow-wrap: anywhere; }.activity-entry pre { display: none; margin: .5rem 0 0; padding: .45rem; overflow: auto; color: var(--muted); background: var(--mantle); border-radius: .35rem; white-space: pre-wrap; }.expert-mode .activity-entry[open] { border-color: var(--surface1); }.expert-mode .activity-entry[open] pre { display: block; }
|
||||
.system-card { margin-bottom: .75rem; padding: .55rem; border: 1px solid var(--border); border-radius: .55rem; background: var(--crust); }.system-card summary { color: var(--accent); cursor: pointer; }.system-card dl { display: grid; grid-template-columns: 1fr auto; gap: .3rem .6rem; margin: .7rem 0; font-size: .8rem; }.system-card dd { margin: 0; color: var(--muted); text-align: right; }.future-slots { margin: 0; color: var(--muted); font-size: .75rem; line-height: 1.35; }
|
||||
.agent-panel.pane-collapsed { grid-template-rows: auto; min-width: 5.5rem; }.agent-panel.pane-collapsed .agent-scroll, .agent-panel.pane-collapsed .agent-prompt, #inspector.pane-collapsed > :not(.panel-heading) { display: none; }.agent-panel.pane-collapsed h2, #inspector.pane-collapsed h2 { display: none; }#inspector.pane-collapsed { min-width: 5.5rem; }
|
||||
.pane-resizer { position: absolute; z-index: 7; border: 0; background: transparent; }.pane-resizer::after { content: ""; position: absolute; background: var(--accent); opacity: 0; transition: opacity .15s; }.pane-resizer:hover::after, .pane-resizer:focus-visible::after { opacity: .78; }.pane-resizer-vertical { top: 0; bottom: var(--drawer); width: .75rem; cursor: col-resize; }.pane-resizer-vertical::after { inset: 18% calc(50% - 1px); width: 2px; border-radius: 2px; }.pane-resizer-horizontal { left: 0; right: 0; bottom: calc(var(--drawer) - .375rem); height: .75rem; cursor: row-resize; }.pane-resizer-horizontal::after { left: 38%; right: 38%; top: calc(50% - 1px); height: 2px; border-radius: 2px; }#agent-resizer { left: calc(var(--agent-pane) - .375rem); }#inspector-resizer { right: calc(var(--inspector-pane) - .375rem); }.workbench.drawer-collapsed .pane-resizer-horizontal { display: none; }.workbench.drawer-collapsed .pane-resizer-vertical { bottom: 2.6rem; }
|
||||
@media (max-width: 900px) {
|
||||
.workbench { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(18rem,1fr) auto var(--drawer); grid-template-areas:"agent" "graph" "inspector" "work"; }.agent-panel { max-height:19rem; border-right:0; border-bottom:1px solid var(--border); }
|
||||
.pane-resizer { display: none; }
|
||||
.workbench.drawer-collapsed {
|
||||
grid-template-rows: minmax(18rem, 1fr) auto auto;
|
||||
grid-template-rows: auto minmax(18rem, 1fr) auto auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.topbar-status { max-width: 42vw; }.topbar-status #connection { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.activity-controls { align-items: stretch; flex-direction: column; }.activity-controls select { max-width: none; width: 100%; }
|
||||
.agent-panel { max-height: 17rem; }.graph-toolbar { bottom: .7rem; }.work-panel-header .tablist { overflow-x: auto; }
|
||||
}
|
||||
.workbench.graph-priority { grid-template-columns: 0 minmax(0, 1fr) 0; grid-template-rows: minmax(0, 1fr) 0; }.workbench.graph-priority #agent-panel, .workbench.graph-priority #inspector, .workbench.graph-priority #work-panel, .workbench.graph-priority .pane-resizer { display: none; }.workbench.graph-priority #graph-panel { grid-column: 1 / -1; grid-row: 1; }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } }
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import argparse
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from workspace_server import main as serve_main
|
||||
from web_app import main as web_app_main
|
||||
|
||||
|
||||
def prepare(workspace: Path, bridge: Path):
|
||||
|
|
@ -27,12 +27,11 @@ def prepare(workspace: Path, bridge: Path):
|
|||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument("--port", type=int, default=8081)
|
||||
args = parser.parse_args()
|
||||
repository = Path(__file__).resolve().parents[2]
|
||||
bridge = repository / "tools" / "local-jobs"
|
||||
prepare(args.workspace.resolve(), bridge)
|
||||
import sys
|
||||
sys.argv = [sys.argv[0], "--workspace", str(args.workspace.resolve()),
|
||||
"--bridge", str(bridge), "--port", str(args.port)]
|
||||
serve_main()
|
||||
raise SystemExit(web_app_main(["serve", "--workspace",
|
||||
str(args.workspace.resolve()), "--bridge", str(bridge),
|
||||
"--port", str(args.port)]))
|
||||
|
|
|
|||
97
prototypes/web-graph/tests/browser_automatic_session.mjs
Normal file
97
prototypes/web-graph/tests/browser_automatic_session.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import assert from "node:assert/strict";
|
||||
|
||||
// CONTRACT: les scénarios Firefox utilisent le parcours produit complet. Ce
|
||||
// helper ne connaît ni code de bootstrap ni cookie synthétique : le serveur
|
||||
// établit le cookie HttpOnly lors de GET / et l'application lit le CSRF.
|
||||
export async function openAutomaticSession(page, origin, label = "session") {
|
||||
await page.goto(origin, { waitUntil: "domcontentloaded" });
|
||||
return assertAutomaticSessionReady(page, new URL(origin).origin, label);
|
||||
}
|
||||
|
||||
// CONTRACT: Firefox BiDi peut ne jamais résoudre page.goto() lorsqu'un serveur
|
||||
// possédé redémarre sur la même origine. Cette primitive conserve la navigation
|
||||
// réelle (GET / et ses redirections) sans faire de cette promesse BiDi la preuve
|
||||
// de disponibilité. Le même onglet est indispensable aux brouillons
|
||||
// sessionStorage du scénario A/B.
|
||||
export async function resumeAutomaticSessionAfterOwnedRestart(page, origin, label = "restart") {
|
||||
const expectedOrigin = new URL(origin).origin;
|
||||
assert.equal(page.url(), "about:blank", `${label}: page détachée attendue`);
|
||||
// WHY: le document racine peut être mis en cache par Firefox après l'arrêt
|
||||
// de l'instance possédée. Désactiver ce cache pour cette reprise impose le
|
||||
// GET / réel qui crée le nouveau cookie HttpOnly via la redirection 303.
|
||||
await page.setCacheEnabled(false);
|
||||
await replaceLocation(page, origin);
|
||||
await waitForResumedDocument(page, expectedOrigin);
|
||||
try {
|
||||
return await assertAutomaticSessionReady(page, expectedOrigin, label);
|
||||
} catch (error) {
|
||||
if (!new Set(["LABFY_WORKBENCH_UNAVAILABLE", "LABFY_SESSION_UNAVAILABLE"])
|
||||
.has(error?.code)) throw error;
|
||||
// INVARIANT: cette seule relance ne s'applique qu'après une navigation
|
||||
// racine valide et une session API 200 ; elle ne peut pas masquer un 401,
|
||||
// un CSRF absent, une mauvaise origine ou une page login/bootstrap.
|
||||
if (error.code === "LABFY_WORKBENCH_UNAVAILABLE") await assertSessionApi(page, label);
|
||||
await replaceLocation(page, origin);
|
||||
await waitForResumedDocument(page, expectedOrigin);
|
||||
return assertAutomaticSessionReady(page, expectedOrigin, label);
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceLocation(page, origin) {
|
||||
await page.evaluate((nextOrigin) => {
|
||||
// INVARIANT: le timer laisse l'évaluation se terminer avant le changement
|
||||
// de document, qui détruirait autrement son ExecutionContext.
|
||||
window.setTimeout(() => window.location.replace(nextOrigin), 0);
|
||||
}, origin);
|
||||
}
|
||||
|
||||
async function waitForResumedDocument(page, expectedOrigin) {
|
||||
await page.waitForFunction((expectedOrigin) =>
|
||||
location.origin === expectedOrigin &&
|
||||
(document.readyState === "interactive" || document.readyState === "complete"),
|
||||
{}, expectedOrigin);
|
||||
}
|
||||
|
||||
async function assertAutomaticSessionReady(page, origin, label) {
|
||||
assert.equal(await page.evaluate(() => location.origin), origin,
|
||||
`${label}: origine de reprise incorrecte`);
|
||||
try {
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector("#library-home:not([hidden]), #workbench-shell:not([hidden])") !== null,
|
||||
{ timeout: 15000 });
|
||||
} catch (error) {
|
||||
const diagnostic = await page.evaluate(() => ({
|
||||
href: location.href,
|
||||
readyState: document.readyState,
|
||||
connection: document.querySelector("#library-connection")?.textContent ?? "",
|
||||
state: window.__LABFY_TEST__?.getState?.() ?? null,
|
||||
body: document.body?.textContent?.slice(0, 500) ?? "",
|
||||
})).catch((diagnosticError) => ({ evaluationError: String(diagnosticError) }));
|
||||
const unavailable = new Error(
|
||||
`${label}: workbench indisponible après reprise ${JSON.stringify(diagnostic)}`,
|
||||
{ cause: error });
|
||||
unavailable.code = "LABFY_WORKBENCH_UNAVAILABLE";
|
||||
throw unavailable;
|
||||
}
|
||||
assert.equal(await page.$("#bootstrap-code"), null);
|
||||
assert.equal(await page.$("#login-form"), null);
|
||||
assert.doesNotMatch(await page.$eval("body", (body) => body.textContent),
|
||||
/Code de session éphémère/);
|
||||
const session = await assertSessionApi(page, label);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function assertSessionApi(page, label) {
|
||||
const session = await page.evaluate(async () => {
|
||||
const response = await fetch("/api/v1/session", { cache: "no-store" });
|
||||
return { status: response.status, body: await response.json() };
|
||||
});
|
||||
if (session.status !== 200) {
|
||||
const unavailable = new Error(`${label}: session API indisponible (${session.status})`);
|
||||
unavailable.code = "LABFY_SESSION_UNAVAILABLE";
|
||||
throw unavailable;
|
||||
}
|
||||
assert.equal(typeof session.body.csrf, "string", `${label}: CSRF absent`);
|
||||
assert.ok(session.body.csrf.length > 0, `${label}: CSRF vide`);
|
||||
return session.body;
|
||||
}
|
||||
36
prototypes/web-graph/tests/owned_process_host.mjs
Normal file
36
prototypes/web-graph/tests/owned_process_host.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { spawn } from "node:child_process";
|
||||
|
||||
function report(message) {
|
||||
if (typeof process.send !== "function") {
|
||||
process.exit(70);
|
||||
return;
|
||||
}
|
||||
process.send(message, () => process.exit(0));
|
||||
}
|
||||
|
||||
let launch;
|
||||
try {
|
||||
launch = JSON.parse(Buffer.from(process.argv[2] ?? "", "base64url").toString("utf8"));
|
||||
} catch (error) {
|
||||
report({ type: "host_error", message: error.message });
|
||||
}
|
||||
|
||||
if (launch) {
|
||||
let settled = false;
|
||||
const child = spawn(launch.command, launch.args, {
|
||||
cwd: launch.cwd,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
});
|
||||
|
||||
child.once("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
report({ type: "launch_error", message: error.message, code: error.code ?? null });
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
report({ type: "result", code, signal });
|
||||
});
|
||||
}
|
||||
232
prototypes/web-graph/tests/owned_process_supervisor.mjs
Normal file
232
prototypes/web-graph/tests/owned_process_supervisor.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const host_path = fileURLToPath(new URL("owned_process_host.mjs", import.meta.url));
|
||||
|
||||
export const PROCESS_STATUSES = Object.freeze([
|
||||
"MISSING", "LAUNCH_ERROR", "EXIT_CODE", "SIGNALED", "TIMEOUT", "INTERRUPTED", "SUCCESS",
|
||||
]);
|
||||
|
||||
function positive_integer(value, name) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new TypeError(`${name} doit être un entier positif.`);
|
||||
}
|
||||
}
|
||||
|
||||
function read_linux_identity(pid) {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
const suffix = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
|
||||
return {
|
||||
pid,
|
||||
process_group_id: Number.parseInt(suffix[2], 10),
|
||||
session_id: Number.parseInt(suffix[3], 10),
|
||||
start_time: suffix[19],
|
||||
};
|
||||
}
|
||||
|
||||
function validate_owned_session(child) {
|
||||
const identity = read_linux_identity(child.pid);
|
||||
if (identity.process_group_id !== child.pid || identity.session_id !== child.pid) {
|
||||
throw new Error(`session détachée invalide pour le PID ${child.pid}`);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
function signal_owned_group(ownership, signal) {
|
||||
if (!ownership.active) return false;
|
||||
|
||||
// INVARIANT: Linux cannot reuse a PGID/SID while a member of this owned
|
||||
// session remains. ESRCH closes ownership permanently, so later PID reuse
|
||||
// can never become a signal target of this supervisor.
|
||||
try {
|
||||
process.kill(-ownership.identity.process_group_id, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === "ESRCH") {
|
||||
ownership.active = false;
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function group_exists(ownership) {
|
||||
return signal_owned_group(ownership, 0);
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function stop_owned_group(ownership, term_grace_ms, kill_grace_ms) {
|
||||
if (!ownership || !group_exists(ownership)) return;
|
||||
signal_owned_group(ownership, "SIGTERM");
|
||||
await delay(term_grace_ms);
|
||||
if (!group_exists(ownership)) return;
|
||||
signal_owned_group(ownership, "SIGKILL");
|
||||
|
||||
const deadline = Date.now() + kill_grace_ms;
|
||||
while (Date.now() < deadline && group_exists(ownership)) {
|
||||
await delay(Math.min(10, Math.max(1, deadline - Date.now())));
|
||||
}
|
||||
// WHY: a non-reaped zombie can keep kill(0) true. The verdict remains
|
||||
// bounded and ownership is retired instead of ever targeting this PGID again.
|
||||
if (group_exists(ownership)) ownership.active = false;
|
||||
}
|
||||
|
||||
function destroy_pipes(child) {
|
||||
child.stdout?.unpipe();
|
||||
child.stderr?.unpipe();
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
if (child.connected) child.disconnect();
|
||||
}
|
||||
|
||||
function status_from_exit(code, signal) {
|
||||
if (signal !== null) return "SIGNALED";
|
||||
if (code !== 0) return "EXIT_CODE";
|
||||
return "SUCCESS";
|
||||
}
|
||||
|
||||
export function create_interruption_controller(signals = ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
const controller = new AbortController();
|
||||
let interrupted_signal = null;
|
||||
const handlers = new Map();
|
||||
|
||||
for (const signal of signals) {
|
||||
const handler = () => {
|
||||
if (controller.signal.aborted) return;
|
||||
interrupted_signal = signal;
|
||||
controller.abort(signal);
|
||||
};
|
||||
handlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
get interrupted_signal() { return interrupted_signal; },
|
||||
dispose() {
|
||||
for (const [signal, handler] of handlers) process.off(signal, handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function supervise_process({
|
||||
command,
|
||||
args = [],
|
||||
cwd = process.cwd(),
|
||||
timeout_ms,
|
||||
term_grace_ms = 1000,
|
||||
kill_grace_ms = 1000,
|
||||
abort_signal,
|
||||
stdout = process.stdout,
|
||||
stderr = process.stderr,
|
||||
}) {
|
||||
positive_integer(timeout_ms, "timeout_ms");
|
||||
positive_integer(term_grace_ms, "term_grace_ms");
|
||||
positive_integer(kill_grace_ms, "kill_grace_ms");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let ownership = null;
|
||||
let target_result = null;
|
||||
let stopping_status = null;
|
||||
let settled = false;
|
||||
let cleanup_started = false;
|
||||
let child;
|
||||
let timeout;
|
||||
|
||||
const payload = Buffer.from(JSON.stringify({ command, args, cwd }), "utf8").toString("base64url");
|
||||
|
||||
const finish = async (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
abort_signal?.removeEventListener("abort", on_abort);
|
||||
destroy_pipes(child);
|
||||
await stop_owned_group(ownership, term_grace_ms, kill_grace_ms);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const begin_cleanup = (result) => {
|
||||
if (cleanup_started) return;
|
||||
cleanup_started = true;
|
||||
void finish(result);
|
||||
};
|
||||
|
||||
const request_stop = (status, interrupted_signal = null) => {
|
||||
if (stopping_status) return;
|
||||
stopping_status = status;
|
||||
begin_cleanup({
|
||||
status,
|
||||
code: target_result?.code ?? null,
|
||||
signal: target_result?.signal ?? interrupted_signal,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
const on_abort = () => request_stop("INTERRUPTED", abort_signal.reason ?? null);
|
||||
|
||||
try {
|
||||
child = spawn(process.execPath, [host_path, payload], {
|
||||
cwd,
|
||||
env: process.env,
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
});
|
||||
} catch (error) {
|
||||
resolve({ status: "LAUNCH_ERROR", code: null, signal: null, error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
// CONTRACT: spawn() returns only after the detached child has performed
|
||||
// setsid(2). Capture and validate that fresh identity synchronously, before
|
||||
// a pre-aborted signal or a very short timeout can request cleanup.
|
||||
try {
|
||||
ownership = { identity: validate_owned_session(child), active: true };
|
||||
} catch (error) {
|
||||
child.kill("SIGKILL");
|
||||
destroy_pipes(child);
|
||||
resolve({ status: "LAUNCH_ERROR", code: null, signal: null, error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
child.stdout.pipe(stdout, { end: false });
|
||||
child.stderr.pipe(stderr, { end: false });
|
||||
|
||||
child.on("message", (message) => {
|
||||
if (settled || !message || typeof message !== "object") return;
|
||||
if (message.type === "launch_error" || message.type === "host_error") {
|
||||
begin_cleanup({
|
||||
status: "LAUNCH_ERROR", code: null, signal: null,
|
||||
error: message.message ?? "échec de lancement",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === "result") {
|
||||
target_result = { code: message.code ?? null, signal: message.signal ?? null };
|
||||
begin_cleanup({
|
||||
status: status_from_exit(target_result.code, target_result.signal),
|
||||
...target_result,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.once("error", (error) => {
|
||||
begin_cleanup({ status: "LAUNCH_ERROR", code: null, signal: null, error: error.message });
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
if (settled || cleanup_started || target_result) return;
|
||||
setImmediate(() => {
|
||||
if (settled || cleanup_started || target_result) return;
|
||||
begin_cleanup({ status: status_from_exit(code, signal), code, signal, error: null });
|
||||
});
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => request_stop("TIMEOUT"), timeout_ms);
|
||||
abort_signal?.addEventListener("abort", on_abort, { once: true });
|
||||
if (abort_signal?.aborted) on_abort();
|
||||
});
|
||||
}
|
||||
146
prototypes/web-graph/tests/run_browser_suite.mjs
Normal file
146
prototypes/web-graph/tests/run_browser_suite.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { access } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
PROCESS_STATUSES,
|
||||
create_interruption_controller,
|
||||
supervise_process,
|
||||
} from "./owned_process_supervisor.mjs";
|
||||
|
||||
// CONTRACT: this ordered list is the complete browser-validation suite.
|
||||
export const scenarios = Object.freeze([
|
||||
"test_browser.mjs",
|
||||
// CONTRACT: le point d'entrée utilisateur ouvre directement le workbench,
|
||||
// établit le cookie local et refuse toute origine de mutation étrangère.
|
||||
"test_direct_session_browser.mjs",
|
||||
"test_core_browser.mjs",
|
||||
"test_eml_browser.mjs",
|
||||
"test_local_toolkit_browser.mjs",
|
||||
"test_workspace_control_browser.mjs",
|
||||
"test_j8_browser.mjs",
|
||||
"test_j8_runtime_browser.mjs",
|
||||
"test_j9_browser.mjs",
|
||||
"test_workbench_browser.mjs",
|
||||
"test_workbench_populated_browser.mjs",
|
||||
// CONTRACT: cette vérification Firefox inspecte les tailles cibles, le
|
||||
// tiroir, les panneaux persistants et la surface Agent uniquement sur fixture.
|
||||
"test_visual_polish_browser.mjs",
|
||||
"test_local_workspace_import_browser.mjs",
|
||||
// Le scénario import/rapport précédent et ce cycle du vrai launcher forment
|
||||
// la preuve E2E : données SPECIMEN, rapport, bibliothèque, redémarrages et A/B.
|
||||
"test_web_app_browser.mjs",
|
||||
// CONTRACT: ce parcours Firefox exerce le vrai formulaire et une expiration
|
||||
// de harness isolée ; il démontre l'arrêt du polling et la reconnexion sans
|
||||
// rejouer une mutation dont l'admission n'est pas connue.
|
||||
"test_session_lifetime_browser.mjs",
|
||||
// CONTRACT: deux vagues de recherche SPECIMEN approuvées sont visibles ; une
|
||||
// branche refusée demeure hors transport et ne peut donc produire de résultat.
|
||||
"test_research_browser.mjs",
|
||||
]);
|
||||
|
||||
const tests_directory = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
function print_result(scenario, result, started_ms, output) {
|
||||
output.log(
|
||||
`SCENARIO fin ${scenario} ${new Date().toISOString()} résultat=${result.status} ` +
|
||||
`code=${result.code ?? "null"} signal=${result.signal ?? "null"} ` +
|
||||
`durée_ms=${Date.now() - started_ms}`,
|
||||
);
|
||||
if (result.error) output.error(`SCENARIO erreur ${scenario}: ${result.error}`);
|
||||
}
|
||||
|
||||
export function summarize_browser_results(results, expected_count) {
|
||||
const counts = Object.fromEntries(PROCESS_STATUSES.map((status) => [status, 0]));
|
||||
for (const result of results) counts[result.status] += 1;
|
||||
const failures = results.filter(({ status }) => status !== "SUCCESS");
|
||||
const executed = results.filter(({ status }) => status !== "MISSING").length;
|
||||
return {
|
||||
counts,
|
||||
failures,
|
||||
executed,
|
||||
exit_code: failures.length === 0 && executed === expected_count ? 0 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export async function run_browser_suite({
|
||||
scenario_names = scenarios,
|
||||
directory = tests_directory,
|
||||
timeout_ms,
|
||||
term_grace_ms = 1000,
|
||||
kill_grace_ms = 1000,
|
||||
interruption = create_interruption_controller(),
|
||||
output = console,
|
||||
} = {}) {
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
for (const scenario of scenario_names) {
|
||||
if (interruption.signal.aborted) break;
|
||||
|
||||
const started_ms = Date.now();
|
||||
output.log(`SCENARIO début ${scenario} ${new Date().toISOString()}`);
|
||||
const scenario_path = resolve(directory, scenario);
|
||||
|
||||
try {
|
||||
await access(scenario_path);
|
||||
} catch {
|
||||
const missing = { status: "MISSING", code: null, signal: null, error: null };
|
||||
print_result(scenario, missing, started_ms, output);
|
||||
results.push({ scenario, ...missing });
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await supervise_process({
|
||||
command: process.execPath,
|
||||
args: [scenario_path],
|
||||
cwd: directory,
|
||||
timeout_ms,
|
||||
term_grace_ms,
|
||||
kill_grace_ms,
|
||||
abort_signal: interruption.signal,
|
||||
});
|
||||
print_result(scenario, result, started_ms, output);
|
||||
results.push({ scenario, ...result });
|
||||
|
||||
// CONTRACT: an operator interruption cancels the current owned unit and
|
||||
// prevents every later scenario from being launched.
|
||||
if (result.status === "INTERRUPTED") break;
|
||||
}
|
||||
|
||||
if (interruption.signal.aborted && !results.some(({ status }) => status === "INTERRUPTED")) {
|
||||
results.push({
|
||||
scenario: "<suite>", status: "INTERRUPTED", code: null,
|
||||
signal: interruption.interrupted_signal, error: null,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
interruption.dispose();
|
||||
}
|
||||
|
||||
const summary = summarize_browser_results(results, scenario_names.length);
|
||||
output.log(
|
||||
`SUITE navigateur scénarios_exécutés=${summary.executed}/${scenario_names.length} ` +
|
||||
`échecs=${summary.failures.length} ` +
|
||||
`résultats=${PROCESS_STATUSES.map((status) => `${status}:${summary.counts[status]}`).join(",")}`,
|
||||
);
|
||||
return {
|
||||
results,
|
||||
counts: summary.counts,
|
||||
exit_code: summary.exit_code,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const timeout_ms = Number.parseInt(process.env.LABFY_BROWSER_SCENARIO_TIMEOUT_MS ?? "300000", 10);
|
||||
if (!Number.isSafeInteger(timeout_ms) || timeout_ms <= 0) {
|
||||
console.error("LABFY_BROWSER_SCENARIO_TIMEOUT_MS doit être un entier positif.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const summary = await run_browser_suite({ timeout_ms });
|
||||
process.exitCode = summary.exit_code;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await main();
|
||||
}
|
||||
59
prototypes/web-graph/tests/test_automatic_session.py
Normal file
59
prototypes/web-graph/tests/test_automatic_session.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Régressions de session sans code visible du lanceur Web-only."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.client import HTTPConnection
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from workspace_server import Handler, WorkspaceServer
|
||||
|
||||
|
||||
class AutomaticSessionTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="labfy-auto-SPECIMEN-")
|
||||
root = Path(self.temporary.name)
|
||||
self.server = WorkspaceServer(("127.0.0.1", 0), Handler,
|
||||
workspace=root, bridge=root / "missing-bridge", bootstrap="unused",
|
||||
automatic_session=True)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.authority = f"127.0.0.1:{self.server.server_port}"
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown(); self.server.server_close(); self.thread.join()
|
||||
self.temporary.cleanup()
|
||||
|
||||
def request(self, method, path, headers=None, body=None):
|
||||
connection = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3)
|
||||
connection.request(method, path, body=body, headers=headers or {"Host": self.authority})
|
||||
response = connection.getresponse(); value = response.read()
|
||||
answer = response.status, dict(response.getheaders()), value
|
||||
connection.close(); return answer
|
||||
|
||||
def test_root_establishes_http_only_session_without_code_or_url_token(self):
|
||||
status, headers, _ = self.request("GET", "/")
|
||||
self.assertEqual(status, 303)
|
||||
self.assertEqual(headers["Location"], "/")
|
||||
self.assertIn("HttpOnly", headers["Set-Cookie"])
|
||||
self.assertIn("SameSite=Strict", headers["Set-Cookie"])
|
||||
self.assertNotIn("unused", headers["Set-Cookie"])
|
||||
cookie = headers["Set-Cookie"].split(";", 1)[0]
|
||||
status, _, body = self.request("GET", "/api/v1/session", {"Host": self.authority, "Cookie": cookie})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("csrf", json.loads(body))
|
||||
|
||||
def test_third_party_origin_cannot_mutate_auto_session(self):
|
||||
_, headers, _ = self.request("GET", "/")
|
||||
cookie = headers["Set-Cookie"].split(";", 1)[0]
|
||||
status, _, _ = self.request("POST", "/api/v1/queue/pause", {
|
||||
"Host": self.authority, "Cookie": cookie, "Origin": "https://evil.test",
|
||||
"Content-Type": "application/json", "Content-Length": "2"}, "{}")
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -239,7 +239,7 @@ async function testProjectionKeyboardAndResets(page) {
|
|||
await page.setViewport({ width: 700, height: 900 });
|
||||
const narrowLayout = await page.evaluate(() => ({
|
||||
columns: getComputedStyle(document.querySelector("main")).gridTemplateColumns,
|
||||
asideBorderTop: getComputedStyle(document.querySelector("aside")).borderTopStyle,
|
||||
asideBorderTop: getComputedStyle(document.querySelector("#inspector")).borderTopStyle,
|
||||
}));
|
||||
assert.equal(narrowLayout.columns.split(" ").length, 1);
|
||||
assert.equal(narrowLayout.asideBorderTop, "solid");
|
||||
|
|
|
|||
|
|
@ -39,6 +39,18 @@ async function clickNode(page, id) {
|
|||
});
|
||||
}
|
||||
|
||||
async function dragNode(page, id, deltaX, deltaY) {
|
||||
const node = await page.$(`[data-id="${id}"]`);
|
||||
const box = await node.boundingBox();
|
||||
assert.ok(box, `boîte du nœud ${id}`);
|
||||
const x = box.x + box.width / 2, y = box.y + box.height / 2;
|
||||
await page.mouse.move(x, y); await page.mouse.down();
|
||||
// Firefox livre les PointerEvents intermédiaires de manière fiable ; un seul
|
||||
// saut ne prouve pas le contrat de position épinglée du renderer SVG.
|
||||
await page.mouse.move(x + deltaX / 2, y + deltaY / 2, { steps: 3 });
|
||||
await page.mouse.move(x + deltaX, y + deltaY, { steps: 3 }); await page.mouse.up();
|
||||
}
|
||||
|
||||
async function inspectCoreMode(page) {
|
||||
await page.waitForSelector(`[data-id="${PERSON}"]`);
|
||||
const initial = await page.evaluate(() => window.__LABFY_TEST__.getState());
|
||||
|
|
@ -91,13 +103,7 @@ async function inspectCoreMode(page) {
|
|||
});
|
||||
assert.equal((await page.evaluate(() => window.__LABFY_TEST__.getState())).visibleNodeIds.length, 0);
|
||||
await page.$eval("#reset", (item) => item.click());
|
||||
|
||||
const person = await page.$(`[data-id="${PERSON}"]`);
|
||||
const box = await person.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 70, box.y + box.height / 2 + 45, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
await dragNode(page, PERSON, 70, 45);
|
||||
assert.ok((await page.evaluate(() => window.__LABFY_TEST__.getState())).pinned[PERSON]);
|
||||
|
||||
await page.setViewport({ width: 700, height: 900 });
|
||||
|
|
|
|||
89
prototypes/web-graph/tests/test_direct_session_browser.mjs
Normal file
89
prototypes/web-graph/tests/test_direct_session_browser.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url);
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
||||
function startLauncher(library, runtime, state) {
|
||||
const result = spawnSync("./tools/labfy", ["--library", library, "--port", "0"], {
|
||||
cwd: REPOSITORY,
|
||||
env: { ...process.env, XDG_RUNTIME_DIR: runtime, XDG_STATE_HOME: state },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const match = result.stdout.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
|
||||
assert.ok(match, `origine du lanceur absente: ${result.stdout}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function stopLauncher(runtime, state) {
|
||||
const result = spawnSync("python3", ["web_app.py", "stop"], {
|
||||
cwd: PROTOTYPE,
|
||||
env: { ...process.env, XDG_RUNTIME_DIR: runtime, XDG_STATE_HOME: state },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
|
||||
function requestEvilOrigin(origin, cookie, csrf) {
|
||||
const body = JSON.stringify({ title: "Refusé SPECIMEN",
|
||||
idempotency_key: "87000000-0000-4000-8000-000000000002" });
|
||||
return new Promise((resolve, reject) => {
|
||||
const target = new URL("/api/v1/library/workspaces", origin);
|
||||
const request = http.request(target, { method: "POST", headers: {
|
||||
Cookie: `${cookie.name}=${cookie.value}`, Origin: "http://evil.example",
|
||||
"Content-Type": "application/json", "Content-Length": Buffer.byteLength(body),
|
||||
"X-Labfy-CSRF": csrf } }, (response) => {
|
||||
response.resume();
|
||||
response.once("end", () => resolve(response.statusCode));
|
||||
});
|
||||
request.once("error", reject);
|
||||
request.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
const library = await mkdtemp(join(tmpdir(), "labfy-direct-library-SPECIMEN-"));
|
||||
const profile = await mkdtemp(join(tmpdir(), "labfy-direct-firefox-SPECIMEN-"));
|
||||
const runtime = await mkdtemp(join(tmpdir(), "labfy-direct-runtime-SPECIMEN-"));
|
||||
const state = await mkdtemp(join(tmpdir(), "labfy-direct-state-SPECIMEN-"));
|
||||
let browser;
|
||||
let started = false;
|
||||
try {
|
||||
const origin = startLauncher(library, runtime, state);
|
||||
started = true;
|
||||
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
|
||||
headless: true, userDataDir: profile, args: ["--no-remote"] });
|
||||
const page = await browser.newPage();
|
||||
const session = await openAutomaticSession(page, origin);
|
||||
assert.equal(await page.$("#login-form"), null);
|
||||
assert.equal(await page.$("#bootstrap-code"), null);
|
||||
assert.equal((await page.goto(`${origin}/login.html`)).status(), 404);
|
||||
assert.equal((await page.goto(`${origin}/login.js`)).status(), 404);
|
||||
await page.goto(origin, { waitUntil: "domcontentloaded" });
|
||||
const valid = await page.evaluate(async ({ csrf }) => {
|
||||
const valid = await fetch("/api/v1/library/workspaces", {
|
||||
method: "POST", headers: { "Content-Type": "application/json",
|
||||
"X-Labfy-CSRF": csrf },
|
||||
body: JSON.stringify({ title: "Direct SPECIMEN",
|
||||
idempotency_key: "87000000-0000-4000-8000-000000000001" }),
|
||||
});
|
||||
return valid.status;
|
||||
}, session);
|
||||
assert.equal(valid, 201);
|
||||
const cookie = (await page.cookies()).find((item) => item.name.startsWith("labfy_session_"));
|
||||
assert.ok(cookie);
|
||||
assert.equal(await requestEvilOrigin(origin, cookie, session.csrf), 403);
|
||||
console.log("PASS session directe Firefox : lanceur, workbench, CSRF, Origin et arrêt");
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (started) stopLauncher(runtime, state);
|
||||
await Promise.all([library, profile, runtime, state].map((path) =>
|
||||
rm(path, { recursive: true, force: true })));
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
const REPOSITORY = new URL("../../..", import.meta.url),
|
||||
PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX_PATH = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
|
@ -16,11 +17,10 @@ function ready(server) {
|
|||
);
|
||||
server.stdout.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//),
|
||||
c = text.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (u && c) {
|
||||
const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
|
||||
if (u) {
|
||||
clearTimeout(timer);
|
||||
resolve({ url: u[0], code: c[1] });
|
||||
resolve({ url: u[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) =>
|
||||
|
|
@ -51,14 +51,6 @@ async function stop(server) {
|
|||
server.kill("SIGINT");
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
async function login(page, session) {
|
||||
await page.goto(session.url, { waitUntil: "domcontentloaded" });
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button"),
|
||||
]);
|
||||
}
|
||||
const workspace = await mkdtemp(join(tmpdir(), "labfy-j8-browser-")),
|
||||
profile = await mkdtemp(join(tmpdir(), "labfy-j8-firefox-"));
|
||||
let browser, server;
|
||||
|
|
@ -97,7 +89,7 @@ try {
|
|||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await login(page, session);
|
||||
await openAutomaticSession(page, session.url);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelectorAll("#planner-list input:not(:disabled)").length >=
|
||||
|
|
@ -182,7 +174,7 @@ try {
|
|||
server = null;
|
||||
server = start(workspace);
|
||||
const restarted = await ready(server);
|
||||
await login(page, restarted);
|
||||
await openAutomaticSession(page, restarted.url);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ import {tmpdir} from "node:os";
|
|||
import {join} from "node:path";
|
||||
import {spawn, spawnSync} from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY=new URL("../../..",import.meta.url),PROTOTYPE=new URL("..",import.meta.url);
|
||||
const FIREFOX_PATH=process.env.FIREFOX_PATH??"/usr/bin/firefox";
|
||||
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
function ready(server){return new Promise((resolve,reject)=>{let output="";const timer=setTimeout(()=>reject(new Error("démarrage runtime J8 expiré")),8000);server.stdout.on("data",chunk=>{output+=chunk;const url=output.match(/http:\/\/127\.0\.0\.1:(\d+)\//),code=output.match(/Code de session éphémère : ([^\s]+)/);if(url&&code){clearTimeout(timer);resolve({url:url[0],code:code[1]});}});});}
|
||||
function ready(server){return new Promise((resolve,reject)=>{let output="";const timer=setTimeout(()=>reject(new Error("démarrage runtime J8 expiré")),8000);server.stdout.on("data",chunk=>{output+=chunk;const url=output.match(/http:\/\/127\.0\.0\.1:(\d+)\//);if(url){clearTimeout(timer);resolve({url:url[0]});}});});}
|
||||
async function waitFile(path){for(let i=0;i<200;i++){try{await access(path);return;}catch{}await delay(10);}throw new Error(`barrière absente: ${path}`);}
|
||||
async function login(page,session){await page.goto(session.url,{waitUntil:"domcontentloaded"});await page.type("#bootstrap-code",session.code);await Promise.all([page.waitForNavigation({waitUntil:"domcontentloaded"}),page.click("#login-form button")]);}
|
||||
async function post(page,path,body){return page.evaluate(async({path,body})=>{const session=await fetch("/api/v1/session").then(r=>r.json());const response=await fetch(path,{method:"POST",headers:{"Content-Type":"application/json","X-Labfy-CSRF":session.csrf},body:JSON.stringify(body)});return {status:response.status,body:await response.json()};},{path,body});}
|
||||
async function scenario(kind){const workspace=await mkdtemp(join(tmpdir(),`labfy-j8-${kind}-`)),profile=await mkdtemp(join(tmpdir(),`labfy-j8-${kind}-firefox-`));let browser,server;try{
|
||||
assert.equal(spawnSync("./tools/local-jobs",["init-j7-specimen","--workspace",workspace],{cwd:REPOSITORY,encoding:"utf8"}).status,0);spawnSync("./tools/local-jobs",["export","--workspace",workspace],{cwd:REPOSITORY});
|
||||
const maximum=kind==="limit"?"100":"5000";server=spawn("python3",["workspace_server.py","--workspace",workspace,"--bridge","../../tools/local-jobs-test","--port","0"],{cwd:PROTOTYPE,env:{...process.env,PYTHONUNBUFFERED:"1",LABFY_TEST_PLAN_ACTIVE_MS:maximum,LABFY_TEST_TOOL_DELAY_MS:"2000"},stdio:["ignore","pipe","pipe"]});const session=await ready(server);
|
||||
browser=await puppeteer.launch({browser:"firefox",executablePath:FIREFOX_PATH,headless:true,userDataDir:profile,args:["--no-remote"]});const page=await browser.newPage();await login(page,session);const planner=JSON.parse(await readFile(join(workspace,"planner-snapshot.json"))),ids=planner.recommendations.filter(x=>x.kind==="ANALYSIS"&&x.available).slice(0,kind==="limit"?2:1).map(x=>x.id);const response=await post(page,"/api/v1/plans",{recommendation_ids:ids,input_revision:planner.input_revision,profile_id:"SPECIMEN_SMALL",idempotency_key:kind==="limit"?"89100000-0000-4000-8000-000000000001":"89200000-0000-4000-8000-000000000001"});assert.equal(response.status,202);const marker=join(workspace,".labfy/runtime/test-tool-started");await waitFile(marker);
|
||||
browser=await puppeteer.launch({browser:"firefox",executablePath:FIREFOX_PATH,headless:true,userDataDir:profile,args:["--no-remote"]});const page=await browser.newPage();await openAutomaticSession(page,session.url);const planner=JSON.parse(await readFile(join(workspace,"planner-snapshot.json"))),ids=planner.recommendations.filter(x=>x.kind==="ANALYSIS"&&x.available).slice(0,kind==="limit"?2:1).map(x=>x.id);const response=await post(page,"/api/v1/plans",{recommendation_ids:ids,input_revision:planner.input_revision,profile_id:"SPECIMEN_SMALL",idempotency_key:kind==="limit"?"89100000-0000-4000-8000-000000000001":"89200000-0000-4000-8000-000000000001"});assert.equal(response.status,202);const marker=join(workspace,".labfy/runtime/test-tool-started");await waitFile(marker);
|
||||
if(kind==="cancel"){const jobId=spawnSync("sqlite3",[join(workspace,".labfy/runtime/jobs.sqlite"),"SELECT job_id FROM jobs WHERE state='RUNNING';"],{encoding:"utf8"}).stdout.trim();assert.ok(jobId);const pid=Number((await readFile(marker,"utf8")).trim());assert.equal((await post(page,`/api/v1/jobs/${jobId}/cancel`,{})).status,200);await page.waitForFunction(()=>[...document.querySelectorAll("#jobs-list li")].some(x=>x.dataset.jobState==="CANCELLED"),{timeout:10000});assert.throws(()=>process.kill(pid,0));}
|
||||
else {await page.waitForFunction(()=>document.querySelector("#plans-list")?.textContent.includes("LIMIT_REACHED"),{timeout:10000});const snapshot=JSON.parse(await readFile(join(workspace,"jobs-snapshot.json")));assert.equal(snapshot.jobs.filter(x=>x.state==="BLOCKED").length,1);assert.equal(snapshot.jobs.filter(x=>x.state==="QUEUED").length,1);assert.equal(snapshot.plans[0].remaining_active_ms,0);}
|
||||
}finally{if(browser)await browser.close();if(server){server.kill("SIGINT");await new Promise(resolve=>server.once("exit",resolve));}await rm(profile,{recursive:true,force:true});await rm(workspace,{recursive:true,force:true});}}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
const REPOSITORY = new URL("../../..", import.meta.url),
|
||||
PROTOTYPE = new URL("..", import.meta.url),
|
||||
FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
|
@ -17,11 +18,10 @@ function ready(server) {
|
|||
);
|
||||
server.stdout.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//),
|
||||
c = text.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (u && c) {
|
||||
const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
|
||||
if (u) {
|
||||
clearTimeout(timer);
|
||||
resolve({ url: u[0], code: c[1] });
|
||||
resolve({ url: u[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) =>
|
||||
|
|
@ -52,14 +52,6 @@ async function stop(server) {
|
|||
server.kill("SIGINT");
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
async function login(page, session) {
|
||||
await page.goto(session.url, { waitUntil: "domcontentloaded" });
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button"),
|
||||
]);
|
||||
}
|
||||
const workspace = await mkdtemp(join(tmpdir(), "labfy-j9-browser-")),
|
||||
profile = await mkdtemp(join(tmpdir(), "labfy-j9-firefox-"));
|
||||
let browser, server;
|
||||
|
|
@ -96,7 +88,7 @@ try {
|
|||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await login(page, session);
|
||||
await openAutomaticSession(page, session.url);
|
||||
await page.waitForSelector("#view-evidence");
|
||||
await page.click("#view-evidence");
|
||||
await page.click("#view-timeline");
|
||||
|
|
|
|||
|
|
@ -29,8 +29,7 @@ class LocalWorkspaceImportTest(unittest.TestCase):
|
|||
self.workspace = Path(self.temporary.name) / "workspace"
|
||||
self.workspace.mkdir(mode=0o700)
|
||||
self.server = WorkspaceServer(("127.0.0.1", 0), Handler,
|
||||
workspace=self.workspace, bridge=REPOSITORY / "tools" / "local-jobs",
|
||||
bootstrap="SPECIMEN-bootstrap")
|
||||
workspace=self.workspace, bridge=REPOSITORY / "tools" / "local-jobs")
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.cookie = None
|
||||
|
|
@ -55,9 +54,8 @@ class LocalWorkspaceImportTest(unittest.TestCase):
|
|||
return response.status, json.loads(data) if data else None
|
||||
|
||||
def login_create(self):
|
||||
status, _ = self.request("POST", "/api/v1/session",
|
||||
{"bootstrap_code":"SPECIMEN-bootstrap"}, csrf=False)
|
||||
self.assertEqual(status, 200)
|
||||
status, _ = self.request("GET", "/", csrf=False)
|
||||
self.assertEqual(status, 303)
|
||||
status, value = self.request("POST", "/api/v1/workspace",
|
||||
{"title":"Dossier synthétique Ω"})
|
||||
self.assertEqual(status, 201); return value
|
||||
|
|
@ -67,20 +65,19 @@ class LocalWorkspaceImportTest(unittest.TestCase):
|
|||
"init-specimen", "--workspace", str(self.workspace)],
|
||||
cwd=REPOSITORY, text=True, capture_output=True, check=False)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
status, _ = self.request("POST", "/api/v1/session",
|
||||
{"bootstrap_code":"SPECIMEN-bootstrap"}, csrf=False)
|
||||
self.assertEqual(status, 200)
|
||||
status, _ = self.request("GET", "/", csrf=False)
|
||||
self.assertEqual(status, 303)
|
||||
return json.loads((self.workspace / ".labfy/runtime/specimen.json").read_text())
|
||||
|
||||
def restart_server(self):
|
||||
self.server.shutdown(); self.server.server_close(); self.thread.join(3)
|
||||
self.server = WorkspaceServer(("127.0.0.1", 0), Handler,
|
||||
workspace=self.workspace, bridge=REPOSITORY / "tools" / "local-jobs",
|
||||
bootstrap="SPECIMEN-bootstrap")
|
||||
workspace=self.workspace, bridge=REPOSITORY / "tools" / "local-jobs")
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start(); self.cookie = None
|
||||
status, _ = self.request("POST", "/api/v1/session",
|
||||
{"bootstrap_code":"SPECIMEN-bootstrap"}, csrf=False)
|
||||
status, _ = self.request("GET", "/", csrf=False)
|
||||
self.assertEqual(status, 303)
|
||||
status, _ = self.request("GET", "/api/v1/session", csrf=False)
|
||||
self.assertEqual(status, 200)
|
||||
|
||||
def prepare(self, name, data, declared="application/octet-stream"):
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const PROTOTYPE=new URL("..",import.meta.url),FIREFOX=process.env.FIREFOX_PATH??"/usr/bin/firefox";
|
||||
function ready(server){return new Promise((resolve,reject)=>{let out="";const timer=setTimeout(()=>reject(new Error("démarrage import expiré")),10000);server.stdout.on("data",chunk=>{out+=chunk;const u=out.match(/http:\/\/127\.0\.0\.1:\d+\//),c=out.match(/Code de session éphémère : ([^\s]+)/);if(u&&c){clearTimeout(timer);resolve({url:u[0],code:c[1]});}});server.once("exit",code=>reject(new Error(`serveur import arrêté (${code})`)));});}
|
||||
async function login(page,session){await page.goto(session.url,{waitUntil:"domcontentloaded"});await page.type("#bootstrap-code",session.code);await Promise.all([page.waitForNavigation({waitUntil:"domcontentloaded"}),page.click("#login-form button")]);}
|
||||
function ready(server){return new Promise((resolve,reject)=>{let out="";const timer=setTimeout(()=>reject(new Error("démarrage import expiré")),10000);server.stdout.on("data",chunk=>{out+=chunk;const u=out.match(/http:\/\/127\.0\.0\.1:\d+\//);if(u){clearTimeout(timer);resolve({url:u[0]});}});server.once("exit",code=>reject(new Error(`serveur import arrêté (${code})`)));});}
|
||||
async function waitForDownload(path){let size=-1,stable=0;for(let attempt=0;attempt<120&&stable<3;attempt++){await new Promise(resolve=>setTimeout(resolve,50));try{const current=(await stat(path)).size;stable=current>0&¤t===size?stable+1:0;size=current;}catch{stable=0;}}assert.ok(stable>=3,`téléchargement Firefox terminé ${path}`);return size;}
|
||||
|
||||
const root=await mkdtemp(join(tmpdir(),"labfy-local-import-browser-")),workspace=join(root,"workspace"),fixtures=join(root,"SPECIMEN_INPUT"),profile=join(root,"firefox"),downloads=join(root,"downloads");
|
||||
|
|
@ -23,7 +23,7 @@ let server,browser;
|
|||
try{
|
||||
server=spawn("python3",["workspace_server.py","--workspace",workspace,"--bridge","../../tools/local-jobs","--port","0"],{cwd:PROTOTYPE,env:{...process.env,PYTHONUNBUFFERED:"1"},stdio:["ignore","pipe","pipe"]});
|
||||
const session=await ready(server);browser=await puppeteer.launch({browser:"firefox",executablePath:FIREFOX,headless:true,userDataDir:profile,args:["--no-remote"],extraPrefsFirefox:{"browser.download.folderList":2,"browser.download.dir":downloads,"browser.download.useDownloadDir":true,"browser.helperApps.neverAsk.saveToDisk":"text/html,application/json,application/pdf,text/plain"}});
|
||||
const page=await browser.newPage(),errors=[];page.on("pageerror",e=>errors.push(String(e)));await page.setViewport({width:1440,height:900});await login(page,session);
|
||||
const page=await browser.newPage(),errors=[];page.on("pageerror",e=>errors.push(String(e)));await page.setViewport({width:1440,height:900});await openAutomaticSession(page,session.url);
|
||||
await page.waitForSelector("#workspace-create:not([hidden])");await page.type("#workspace-title","Enquête Firefox locale Ω");await page.click("#workspace-create-form button");await page.waitForSelector("#workspace-create[hidden]");
|
||||
await page.click("#open-import");const input=await page.$("#import-files");await input.uploadFile(eml,eml2,copy,png,jpg);
|
||||
await page.waitForFunction(()=>document.querySelectorAll("#import-list li").length===5&&[...document.querySelectorAll("#import-list li")].every(x=>x.textContent.includes("préparé")),{timeout:20000});
|
||||
|
|
|
|||
118
prototypes/web-graph/tests/test_research_browser.mjs
Normal file
118
prototypes/web-graph/tests/test_research_browser.mjs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url);
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
||||
function ready(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let text = "";
|
||||
const timer = setTimeout(() => reject(new Error("démarrage recherche expiré")), 8000);
|
||||
server.stdout.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
|
||||
if (url) {
|
||||
clearTimeout(timer);
|
||||
resolve({ url: url[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`)));
|
||||
});
|
||||
}
|
||||
|
||||
function start(workspace, authority) {
|
||||
return spawn("python3", ["workspace_server.py", "--workspace", workspace,
|
||||
"--bridge", "../../tools/local-jobs", "--port", "0"], {
|
||||
cwd: PROTOTYPE,
|
||||
env: { ...process.env, PYTHONUNBUFFERED: "1",
|
||||
LABFY_RESEARCH_FIXTURE_AUTHORITY: authority },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
async function stop(server) {
|
||||
server.kill("SIGINT");
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
|
||||
|
||||
function init(workspace) {
|
||||
const result = spawnSync("./tools/local-jobs",
|
||||
["init-j7-specimen", "--workspace", workspace],
|
||||
{ cwd: REPOSITORY, encoding: "utf8", timeout: 30000 });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(spawnSync("./tools/local-jobs",
|
||||
["export", "--workspace", workspace],
|
||||
{ cwd: REPOSITORY, timeout: 15000 }).status, 0);
|
||||
}
|
||||
|
||||
const contacts = [];
|
||||
const provider = http.createServer((request, response) => {
|
||||
contacts.push(request.url);
|
||||
const body = JSON.stringify({ contract: "labfy.fixture.provider.v1",
|
||||
path: request.url });
|
||||
response.writeHead(200, { "Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(body) });
|
||||
response.end(body);
|
||||
});
|
||||
await new Promise((resolve) => provider.listen(0, "127.0.0.1", resolve));
|
||||
const authority = `127.0.0.1:${provider.address().port}`;
|
||||
const workspaceA = await mkdtemp(join(tmpdir(), "labfy-research-A-"));
|
||||
const workspaceB = await mkdtemp(join(tmpdir(), "labfy-research-B-"));
|
||||
const profile = await mkdtemp(join(tmpdir(), "labfy-research-firefox-"));
|
||||
let browser, server;
|
||||
try {
|
||||
init(workspaceA); init(workspaceB);
|
||||
server = start(workspaceA, authority); const sessionA = await ready(server);
|
||||
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
|
||||
headless: true, userDataDir: profile, args: ["--no-remote"] });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 900 });
|
||||
await openAutomaticSession(page, sessionA.url);
|
||||
await page.waitForSelector("#object-list button");
|
||||
await page.click("#object-list button");
|
||||
await page.click('[data-work-panel="research-panel"]');
|
||||
await page.type("#research-question", "Que confirme cette source SPECIMEN ?");
|
||||
await page.click("#research-prepare");
|
||||
await page.waitForSelector(".research-card input[value=authorize]");
|
||||
const cards = await page.$$(".research-card");
|
||||
assert.equal(cards.length, 3);
|
||||
await (await cards[0].$("input[value=authorize]")).click();
|
||||
await (await cards[2].$("input[value=refuse]")).click();
|
||||
await page.click("#research-launch");
|
||||
await page.waitForFunction(() => document.querySelector("#research-plan")
|
||||
?.textContent.includes("Résultat : NEW"));
|
||||
assert.deepEqual(contacts, ["/specimen/wave-1"]);
|
||||
assert.match(await page.$eval("#research-plan", (node) => node.textContent),
|
||||
/Nouvelle action non contactée/);
|
||||
const secondWave = (await page.$$(".research-card"))[1];
|
||||
await (await secondWave.$("input[value=authorize]")).click();
|
||||
await page.click("#research-launch");
|
||||
await page.waitForFunction(() => document.querySelector("#research-plan")
|
||||
?.textContent.includes("Résultat : CONTRADICTION"));
|
||||
assert.deepEqual(contacts, ["/specimen/wave-1", "/specimen/wave-2"]);
|
||||
assert.doesNotMatch(contacts.join(" "), /refused/);
|
||||
|
||||
await stop(server); server = start(workspaceB, authority);
|
||||
const sessionB = await ready(server); const pageB = await browser.newPage();
|
||||
await openAutomaticSession(pageB, sessionB.url);
|
||||
await pageB.click('[data-work-panel="research-panel"]');
|
||||
assert.equal(await pageB.$$eval(".research-card", (nodes) => nodes.length), 0);
|
||||
assert.match(await pageB.$eval("#research-note", (node) => node.textContent),
|
||||
/Sélectionnez/);
|
||||
console.log("PASS recherche Firefox : deux vagues A, refus sans contact, B isolé");
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server?.exitCode === null) await stop(server);
|
||||
await new Promise((resolve) => provider.close(resolve));
|
||||
await rm(workspaceA, { recursive: true, force: true });
|
||||
await rm(workspaceB, { recursive: true, force: true });
|
||||
await rm(profile, { recursive: true, force: true });
|
||||
}
|
||||
344
prototypes/web-graph/tests/test_research_web.py
Normal file
344
prototypes/web-graph/tests/test_research_web.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from http.client import HTTPConnection
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from workspace_server import Handler, WorkspaceServer
|
||||
|
||||
|
||||
class Provider(BaseHTTPRequestHandler):
|
||||
requests = []
|
||||
bodies = {}
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def do_GET(self):
|
||||
type(self).requests.append((self.path, self.headers.get("X-Labfy-Action")))
|
||||
body = type(self).bodies.get(self.path)
|
||||
if body is None:
|
||||
body = json.dumps({"contract": "labfy.fixture.provider.v1",
|
||||
"path": self.path}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
|
||||
class ResearchWebTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.repository = Path(__file__).resolve().parents[3]
|
||||
cls.bridge = cls.repository / "tools/local-jobs"
|
||||
cls.temporary = tempfile.TemporaryDirectory(prefix="labfy-research-web-")
|
||||
cls.provider = ThreadingHTTPServer(("127.0.0.1", 0), Provider)
|
||||
cls.provider_thread = threading.Thread(
|
||||
target=cls.provider.serve_forever, daemon=True)
|
||||
cls.provider_thread.start()
|
||||
cls.instances = []
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
for server, thread in cls.instances:
|
||||
server.shutdown(); server.server_close(); thread.join()
|
||||
cls.provider.shutdown(); cls.provider.server_close()
|
||||
cls.provider_thread.join(); cls.temporary.cleanup()
|
||||
|
||||
@classmethod
|
||||
def start_workspace(cls, name):
|
||||
workspace = Path(cls.temporary.name) / name
|
||||
subprocess.run([str(cls.bridge), "init-j7-specimen", "--workspace",
|
||||
str(workspace)], cwd=cls.repository, check=True,
|
||||
capture_output=True, timeout=30)
|
||||
subprocess.run([str(cls.bridge), "export", "--workspace", str(workspace)],
|
||||
cwd=cls.repository, check=True, timeout=15)
|
||||
server = WorkspaceServer(("127.0.0.1", 0), Handler,
|
||||
workspace=workspace, bridge=cls.bridge,
|
||||
research_fixture_authority=f"127.0.0.1:{cls.provider.server_port}")
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start(); cls.instances.append((server, thread))
|
||||
return workspace, server
|
||||
|
||||
@staticmethod
|
||||
def request(server, method, path, value=None, *, cookie=None, csrf=None,
|
||||
origin=None, host=None):
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=10)
|
||||
headers = {"Host": host or server.authority}
|
||||
if cookie: headers["Cookie"] = cookie
|
||||
if csrf: headers["X-Labfy-CSRF"] = csrf
|
||||
if origin: headers["Origin"] = origin
|
||||
body = None
|
||||
if value is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
body = json.dumps(value)
|
||||
connection.request(method, path, body=body, headers=headers)
|
||||
response = connection.getresponse(); data = response.read()
|
||||
result = response.status, dict(response.getheaders()), data
|
||||
connection.close(); return result
|
||||
|
||||
def open_automatic_session(self, server):
|
||||
status, headers, _ = self.request(server, "GET", "/")
|
||||
self.assertEqual(status, 303)
|
||||
cookie = headers["Set-Cookie"].split(";", 1)[0]
|
||||
status, _, body = self.request(server, "GET", "/api/v1/session",
|
||||
cookie=cookie)
|
||||
self.assertEqual(status, 200)
|
||||
return cookie, json.loads(body)["csrf"]
|
||||
|
||||
def mutate(self, server, cookie, csrf, path, value):
|
||||
time.sleep(0.04)
|
||||
return self.request(server, "POST", path, value, cookie=cookie,
|
||||
csrf=csrf, origin=server.origin)
|
||||
|
||||
def test_two_waves_refusal_never_contacts_and_workspace_b_isolated(self):
|
||||
Provider.requests.clear()
|
||||
workspace_a, server_a = self.start_workspace("A")
|
||||
cookie_a, csrf_a = self.open_automatic_session(server_a)
|
||||
graph = json.loads((workspace_a / "core-snapshot.json").read_text())
|
||||
seed = graph["nodes"][0]["id"]
|
||||
prepared = {"selection_ids": [seed],
|
||||
"question": "Que confirme cette source SPECIMEN ?",
|
||||
"exclusions": [],
|
||||
"idempotency_key": "95000000-0000-4000-8000-000000000001"}
|
||||
status, _, body = self.mutate(server_a, cookie_a, csrf_a,
|
||||
"/api/v1/research/prepare", prepared)
|
||||
self.assertEqual(status, 200, body); snapshot = json.loads(body)
|
||||
first, second, refused = snapshot["actions"]
|
||||
|
||||
def campaign(action, suffix):
|
||||
decisions = [{"action_id": item["action_id"],
|
||||
"decision": ("REFUSE" if item == refused else
|
||||
"AUTHORIZE" if item == first or item == action
|
||||
else "DEFER")}
|
||||
for item in snapshot["actions"]]
|
||||
status, _, grant_body = self.mutate(server_a, cookie_a, csrf_a,
|
||||
"/api/v1/research/grants", {"plan_id": snapshot["plan_id"],
|
||||
"input_revision": snapshot["input_revision"],
|
||||
"selected_action_ids": [action["action_id"]], "exclusions": [],
|
||||
"decisions": decisions,
|
||||
"idempotency_key": f"95000000-0000-4000-8000-0000000000{suffix}"})
|
||||
self.assertEqual(status, 200, grant_body); grant = json.loads(grant_body)
|
||||
grant_id = grant["grants"][0]["grant_id"]
|
||||
status, _, result_body = self.mutate(server_a, cookie_a, csrf_a,
|
||||
"/api/v1/research/campaigns", {"grant_id": grant_id,
|
||||
"input_revision": snapshot["input_revision"],
|
||||
"action_ids": [action["action_id"]],
|
||||
"idempotency_key": f"96000000-0000-4000-8000-0000000000{suffix}"})
|
||||
self.assertEqual(status, 200, result_body)
|
||||
return json.loads(result_body)
|
||||
|
||||
premature = [{"action_id": item["action_id"],
|
||||
"decision": "AUTHORIZE" if item == second else "DEFER"}
|
||||
for item in snapshot["actions"]]
|
||||
status, _, body = self.mutate(server_a, cookie_a, csrf_a,
|
||||
"/api/v1/research/grants", {"plan_id": snapshot["plan_id"],
|
||||
"input_revision": snapshot["input_revision"],
|
||||
"selected_action_ids": [second["action_id"]], "exclusions": [],
|
||||
"decisions": premature,
|
||||
"idempotency_key": "95000000-0000-4000-8000-000000000009"})
|
||||
self.assertEqual(status, 409, body)
|
||||
|
||||
previous_proxy = os.environ.get("http_proxy")
|
||||
os.environ["http_proxy"] = "http://127.0.0.1:1"
|
||||
try:
|
||||
after_first = campaign(first, "11")
|
||||
finally:
|
||||
if previous_proxy is None: os.environ.pop("http_proxy", None)
|
||||
else: os.environ["http_proxy"] = previous_proxy
|
||||
self.assertEqual([item[0] for item in Provider.requests],
|
||||
["/specimen/wave-1"])
|
||||
second_view = next(item for item in after_first["actions"]
|
||||
if item["action_id"] == second["action_id"])
|
||||
self.assertFalse(second_view["contacted"])
|
||||
self.assertEqual(next(item for item in after_first["actions"]
|
||||
if item["action_id"] == first["action_id"])["result_status"], "NEW")
|
||||
|
||||
retry_refused = [{"action_id": refused["action_id"],
|
||||
"decision": "AUTHORIZE"}]
|
||||
status, _, body = self.mutate(server_a, cookie_a, csrf_a,
|
||||
"/api/v1/research/grants", {"plan_id": snapshot["plan_id"],
|
||||
"input_revision": snapshot["input_revision"],
|
||||
"selected_action_ids": [refused["action_id"]], "exclusions": [],
|
||||
"decisions": retry_refused,
|
||||
"idempotency_key": "95000000-0000-4000-8000-000000000019"})
|
||||
self.assertEqual(status, 409, body)
|
||||
|
||||
after_second = campaign(second, "12")
|
||||
self.assertEqual([item[0] for item in Provider.requests],
|
||||
["/specimen/wave-1", "/specimen/wave-2"])
|
||||
self.assertEqual(next(item for item in after_second["actions"]
|
||||
if item["action_id"] == second["action_id"])["result_status"],
|
||||
"CONTRADICTION")
|
||||
self.assertFalse(next(item for item in after_second["actions"]
|
||||
if item["action_id"] == refused["action_id"])["contacted"])
|
||||
with sqlite3.connect(workspace_a / ".labfy/runtime/jobs.sqlite") as connection:
|
||||
self.assertEqual(connection.execute(
|
||||
"SELECT count(*) FROM research_results").fetchone()[0], 2)
|
||||
self.assertEqual(connection.execute(
|
||||
"SELECT count(*) FROM research_receipts").fetchone()[0], 2)
|
||||
self.assertEqual(len(list((workspace_a / ".labfy/research").glob("*.txt"))), 2)
|
||||
|
||||
_, server_b = self.start_workspace("B")
|
||||
cookie_b, _ = self.open_automatic_session(server_b)
|
||||
status, _, body = self.request(server_b, "GET", "/api/v1/research",
|
||||
cookie=cookie_b)
|
||||
self.assertEqual(status, 200); self.assertEqual(json.loads(body)["state"], "EMPTY")
|
||||
self.assertNotEqual(server_a.context()["investigation_id"],
|
||||
server_b.context()["investigation_id"])
|
||||
self.assertNotIn("/specimen/refused", [item[0] for item in Provider.requests])
|
||||
|
||||
def test_result_failure_compensates_final_artifact(self):
|
||||
Provider.requests.clear()
|
||||
workspace, server = self.start_workspace("artifact-failure")
|
||||
cookie, csrf = self.open_automatic_session(server)
|
||||
graph = json.loads((workspace / "core-snapshot.json").read_text())
|
||||
prepared = {"selection_ids": [graph["nodes"][0]["id"]],
|
||||
"question": "Faute SQLite SPECIMEN ?", "exclusions": [],
|
||||
"idempotency_key": "98000000-0000-4000-8000-000000000001"}
|
||||
status, _, body = self.mutate(server, cookie, csrf,
|
||||
"/api/v1/research/prepare", prepared)
|
||||
self.assertEqual(status, 200, body); snapshot = json.loads(body)
|
||||
first = snapshot["actions"][0]
|
||||
decisions = [{"action_id": item["action_id"],
|
||||
"decision": "AUTHORIZE" if item == first else "DEFER"}
|
||||
for item in snapshot["actions"]]
|
||||
status, _, body = self.mutate(server, cookie, csrf,
|
||||
"/api/v1/research/grants", {"plan_id": snapshot["plan_id"],
|
||||
"input_revision": snapshot["input_revision"],
|
||||
"selected_action_ids": [first["action_id"]], "decisions": decisions,
|
||||
"exclusions": [],
|
||||
"idempotency_key": "98000000-0000-4000-8000-000000000002"})
|
||||
self.assertEqual(status, 200, body); grant = json.loads(body)
|
||||
database = workspace / ".labfy/runtime/jobs.sqlite"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute("CREATE TRIGGER specimen_fail_result BEFORE INSERT ON research_results BEGIN SELECT RAISE(FAIL, 'SPECIMEN'); END")
|
||||
status, _, body = self.mutate(server, cookie, csrf,
|
||||
"/api/v1/research/campaigns", {"grant_id": grant["grants"][0]["grant_id"],
|
||||
"input_revision": snapshot["input_revision"],
|
||||
"action_ids": [first["action_id"]],
|
||||
"idempotency_key": "98000000-0000-4000-8000-000000000003"})
|
||||
self.assertEqual(status, 409, body)
|
||||
artifact_dir = workspace / ".labfy/research"
|
||||
self.assertEqual(list(artifact_dir.glob("*.txt")), [])
|
||||
with sqlite3.connect(database) as connection:
|
||||
self.assertEqual(connection.execute(
|
||||
"SELECT count(*) FROM research_results").fetchone()[0], 0)
|
||||
self.assertEqual(connection.execute(
|
||||
"SELECT count(*) FROM research_receipts").fetchone()[0], 0)
|
||||
|
||||
def test_response_over_action_limit_publishes_nothing(self):
|
||||
Provider.requests.clear()
|
||||
Provider.bodies["/specimen/wave-1"] = b"x" * 4097
|
||||
try:
|
||||
workspace, server = self.start_workspace("response-limit")
|
||||
cookie, csrf = self.open_automatic_session(server)
|
||||
graph = json.loads((workspace / "core-snapshot.json").read_text())
|
||||
prepared = {"selection_ids": [graph["nodes"][0]["id"]],
|
||||
"question": "Borne corps SPECIMEN ?", "exclusions": [],
|
||||
"idempotency_key": "98100000-0000-4000-8000-000000000001"}
|
||||
status, _, body = self.mutate(server, cookie, csrf,
|
||||
"/api/v1/research/prepare", prepared)
|
||||
self.assertEqual(status, 200, body); snapshot = json.loads(body)
|
||||
first = snapshot["actions"][0]
|
||||
decisions = [{"action_id": item["action_id"],
|
||||
"decision": "AUTHORIZE" if item == first else "DEFER"}
|
||||
for item in snapshot["actions"]]
|
||||
status, _, body = self.mutate(server, cookie, csrf,
|
||||
"/api/v1/research/grants", {"plan_id": snapshot["plan_id"],
|
||||
"input_revision": snapshot["input_revision"],
|
||||
"selected_action_ids": [first["action_id"]],
|
||||
"decisions": decisions, "exclusions": [],
|
||||
"idempotency_key": "98100000-0000-4000-8000-000000000002"})
|
||||
self.assertEqual(status, 200, body); grant = json.loads(body)
|
||||
status, _, body = self.mutate(server, cookie, csrf,
|
||||
"/api/v1/research/campaigns", {
|
||||
"grant_id": grant["grants"][0]["grant_id"],
|
||||
"input_revision": snapshot["input_revision"],
|
||||
"action_ids": [first["action_id"]],
|
||||
"idempotency_key": "98100000-0000-4000-8000-000000000003"})
|
||||
self.assertEqual(status, 409, body)
|
||||
self.assertEqual(list((workspace / ".labfy/research").glob("*.txt")), [])
|
||||
with sqlite3.connect(workspace / ".labfy/runtime/jobs.sqlite") as connection:
|
||||
self.assertEqual(connection.execute(
|
||||
"SELECT count(*) FROM research_results").fetchone()[0], 0)
|
||||
self.assertEqual(connection.execute(
|
||||
"SELECT count(*) FROM research_receipts").fetchone()[0], 0)
|
||||
finally:
|
||||
Provider.bodies.pop("/specimen/wave-1", None)
|
||||
|
||||
def test_crash_after_rename_reconciles_without_recontact(self):
|
||||
Provider.requests.clear()
|
||||
workspace, server = self.start_workspace("crash-reconcile")
|
||||
cookie, csrf = self.open_automatic_session(server)
|
||||
graph = json.loads((workspace / "core-snapshot.json").read_text())
|
||||
prepared = {"selection_ids": [graph["nodes"][0]["id"]],
|
||||
"question": "Reprise publication SPECIMEN ?", "exclusions": [],
|
||||
"idempotency_key": "98200000-0000-4000-8000-000000000001"}
|
||||
status, _, body = self.mutate(server, cookie, csrf,
|
||||
"/api/v1/research/prepare", prepared)
|
||||
self.assertEqual(status, 200, body); snapshot = json.loads(body)
|
||||
first = snapshot["actions"][0]
|
||||
decisions = [{"action_id": item["action_id"],
|
||||
"decision": "AUTHORIZE" if item == first else "DEFER"}
|
||||
for item in snapshot["actions"]]
|
||||
status, _, body = self.mutate(server, cookie, csrf,
|
||||
"/api/v1/research/grants", {"plan_id": snapshot["plan_id"],
|
||||
"input_revision": snapshot["input_revision"],
|
||||
"selected_action_ids": [first["action_id"]],
|
||||
"decisions": decisions, "exclusions": [],
|
||||
"idempotency_key": "98200000-0000-4000-8000-000000000002"})
|
||||
self.assertEqual(status, 200, body); grant = json.loads(body)
|
||||
campaign = {"grant_id": grant["grants"][0]["grant_id"],
|
||||
"input_revision": snapshot["input_revision"],
|
||||
"action_ids": [first["action_id"]],
|
||||
"idempotency_key": "98200000-0000-4000-8000-000000000003"}
|
||||
os.environ["LABFY_TEST_RESEARCH_CRASH_AFTER_RENAME"] = "1"
|
||||
try:
|
||||
status, _, _ = self.mutate(server, cookie, csrf,
|
||||
"/api/v1/research/campaigns", campaign)
|
||||
finally:
|
||||
os.environ.pop("LABFY_TEST_RESEARCH_CRASH_AFTER_RENAME", None)
|
||||
self.assertEqual(status, 409)
|
||||
self.assertEqual(len(Provider.requests), 1)
|
||||
artifact_dir = workspace / ".labfy/research"
|
||||
self.assertEqual(len(list(artifact_dir.glob("*.txt"))), 1)
|
||||
self.assertEqual(len(list(artifact_dir.glob(".publishing-*"))), 1)
|
||||
status, _, body = self.mutate(server, cookie, csrf,
|
||||
"/api/v1/research/campaigns", campaign)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(len(Provider.requests), 1)
|
||||
self.assertEqual(list(artifact_dir.glob(".publishing-*")), [])
|
||||
with sqlite3.connect(workspace / ".labfy/runtime/jobs.sqlite") as connection:
|
||||
self.assertEqual(connection.execute(
|
||||
"SELECT count(*) FROM research_results").fetchone()[0], 1)
|
||||
self.assertEqual(connection.execute(
|
||||
"SELECT count(*) FROM research_receipts").fetchone()[0], 1)
|
||||
|
||||
def test_research_routes_keep_host_origin_csrf_and_shape_guards(self):
|
||||
_, server = self.start_workspace("guards")
|
||||
cookie, csrf = self.open_automatic_session(server)
|
||||
self.assertEqual(self.request(server, "GET", "/api/v1/research",
|
||||
cookie=cookie, host="evil.test")[0], 403)
|
||||
value = {"selection_ids": ["x"], "question": "SPECIMEN",
|
||||
"exclusions": [],
|
||||
"idempotency_key": "97000000-0000-4000-8000-000000000001"}
|
||||
self.assertEqual(self.request(server, "POST", "/api/v1/research/prepare",
|
||||
value, cookie=cookie, csrf=csrf, origin="http://evil.test")[0], 403)
|
||||
malformed = dict(value, endpoint="http://127.0.0.1/")
|
||||
self.assertEqual(self.mutate(server, cookie, csrf,
|
||||
"/api/v1/research/prepare", malformed)[0], 400)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
199
prototypes/web-graph/tests/test_run_browser_suite.mjs
Normal file
199
prototypes/web-graph/tests/test_run_browser_suite.mjs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Writable } from "node:stream";
|
||||
import test from "node:test";
|
||||
import {
|
||||
create_interruption_controller,
|
||||
supervise_process,
|
||||
} from "./owned_process_supervisor.mjs";
|
||||
import { run_browser_suite, summarize_browser_results } from "./run_browser_suite.mjs";
|
||||
|
||||
const SHORT = { timeout_ms: 1000, term_grace_ms: 40, kill_grace_ms: 120 };
|
||||
|
||||
function null_output() {
|
||||
return new Writable({ write(_chunk, _encoding, callback) { callback(); } });
|
||||
}
|
||||
|
||||
async function wait_for(predicate, timeout_ms = 1000) {
|
||||
const deadline = Date.now() + timeout_ms;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.fail("condition non satisfaite avant la limite");
|
||||
}
|
||||
|
||||
async function process_is_running(pid) {
|
||||
try {
|
||||
const stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
||||
return stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/)[0] !== "Z";
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("classifie succès, code non nul, signal et lancement impossible", async () => {
|
||||
const silent = null_output();
|
||||
const common = { ...SHORT, stdout: silent, stderr: silent };
|
||||
|
||||
const success = await supervise_process({
|
||||
...common, command: process.execPath, args: ["-e", "process.exit(0)"],
|
||||
});
|
||||
const exit_code = await supervise_process({
|
||||
...common, command: process.execPath, args: ["-e", "process.exit(17)"],
|
||||
});
|
||||
const signaled = await supervise_process({
|
||||
...common,
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.kill(process.pid, 'SIGUSR2')"],
|
||||
});
|
||||
const launch_error = await supervise_process({
|
||||
...common, command: "/chemin/SPECIMEN/inexistant", args: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
[success.status, exit_code.status, signaled.status, launch_error.status],
|
||||
["SUCCESS", "EXIT_CODE", "SIGNALED", "LAUNCH_ERROR"],
|
||||
);
|
||||
assert.equal(exit_code.code, 17);
|
||||
assert.equal(signaled.signal, "SIGUSR2");
|
||||
assert.match(launch_error.error, /ENOENT/);
|
||||
});
|
||||
|
||||
test("chaque verdict négatif impose un code global non nul", () => {
|
||||
for (const status of ["MISSING", "LAUNCH_ERROR", "EXIT_CODE", "SIGNALED", "TIMEOUT", "INTERRUPTED"]) {
|
||||
assert.equal(summarize_browser_results([{ status }], 1).exit_code, 1, status);
|
||||
}
|
||||
assert.equal(summarize_browser_results([{ status: "SUCCESS" }], 1).exit_code, 0);
|
||||
});
|
||||
|
||||
test("le timeout escalade à SIGKILL quand le scénario ignore SIGTERM", async () => {
|
||||
const started = Date.now();
|
||||
const result = await supervise_process({
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"],
|
||||
timeout_ms: 80,
|
||||
term_grace_ms: 40,
|
||||
kill_grace_ms: 120,
|
||||
stdout: null_output(),
|
||||
stderr: null_output(),
|
||||
});
|
||||
|
||||
assert.equal(result.status, "TIMEOUT");
|
||||
assert.ok(Date.now() - started < 600, "le délai total doit rester borné");
|
||||
});
|
||||
|
||||
test("un descendant qui garde les pipes ouverts est nettoyé sans bloquer close", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "labfy-runner-SPECIMEN-"));
|
||||
const pid_file = join(directory, "descendant.pid");
|
||||
const child_source = [
|
||||
"const { spawn } = require('node:child_process');",
|
||||
"const { writeFileSync } = require('node:fs');",
|
||||
"const descendant = spawn(process.execPath, ['-e', `process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)`], { stdio: 'inherit' });",
|
||||
"writeFileSync(process.argv[1], String(descendant.pid));",
|
||||
"process.exit(0);",
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
const started = Date.now();
|
||||
const result = await supervise_process({
|
||||
command: process.execPath,
|
||||
args: ["-e", child_source, pid_file],
|
||||
cwd: directory,
|
||||
...SHORT,
|
||||
stdout: null_output(),
|
||||
stderr: null_output(),
|
||||
});
|
||||
const descendant_pid = Number.parseInt(await readFile(pid_file, "utf8"), 10);
|
||||
|
||||
assert.equal(result.status, "SUCCESS");
|
||||
assert.ok(Date.now() - started < 600, "les pipes hérités ne doivent pas retenir le verdict");
|
||||
await wait_for(async () => !(await process_is_running(descendant_pid)));
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("le nettoyage du groupe possédé épargne un témoin indépendant", async () => {
|
||||
const witness = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
witness.once("spawn", resolve);
|
||||
witness.once("error", reject);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await supervise_process({
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"],
|
||||
timeout_ms: 80,
|
||||
term_grace_ms: 40,
|
||||
kill_grace_ms: 120,
|
||||
stdout: null_output(),
|
||||
stderr: null_output(),
|
||||
});
|
||||
assert.equal(result.status, "TIMEOUT");
|
||||
assert.equal(await process_is_running(witness.pid), true);
|
||||
} finally {
|
||||
try { process.kill(-witness.pid, "SIGKILL"); } catch (error) {
|
||||
if (error.code !== "ESRCH") throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("SIGINT interrompt le scénario courant et produit un verdict unique", async () => {
|
||||
const interruption = create_interruption_controller(["SIGINT"]);
|
||||
const pending = supervise_process({
|
||||
command: process.execPath,
|
||||
args: ["-e", "setInterval(() => {}, 1000)"],
|
||||
...SHORT,
|
||||
abort_signal: interruption.signal,
|
||||
stdout: null_output(),
|
||||
stderr: null_output(),
|
||||
});
|
||||
setTimeout(() => process.kill(process.pid, "SIGINT"), 60);
|
||||
|
||||
try {
|
||||
const result = await pending;
|
||||
assert.equal(result.status, "INTERRUPTED");
|
||||
assert.equal(result.signal, "SIGINT");
|
||||
} finally {
|
||||
interruption.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("le runner classe MISSING, ne lance pas la suite après interruption et échoue globalement", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "labfy-suite-SPECIMEN-"));
|
||||
const first = join(directory, "first.mjs");
|
||||
const forbidden = join(directory, "forbidden.mjs");
|
||||
const marker = join(directory, "forbidden-ran");
|
||||
await writeFile(first, "setInterval(() => {}, 1000);\n", "utf8");
|
||||
await writeFile(forbidden, `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'bad');\n`, "utf8");
|
||||
const interruption = create_interruption_controller(["SIGINT"]);
|
||||
const output = { log() {}, error() {} };
|
||||
setTimeout(() => process.kill(process.pid, "SIGINT"), 60);
|
||||
|
||||
try {
|
||||
const summary = await run_browser_suite({
|
||||
scenario_names: ["missing.mjs", "first.mjs", "forbidden.mjs"],
|
||||
directory,
|
||||
timeout_ms: 1000,
|
||||
term_grace_ms: 40,
|
||||
kill_grace_ms: 120,
|
||||
interruption,
|
||||
output,
|
||||
});
|
||||
assert.deepEqual(summary.results.map(({ status }) => status), ["MISSING", "INTERRUPTED"]);
|
||||
assert.equal(summary.exit_code, 1);
|
||||
assert.equal(await readFile(marker, "utf8").then(() => true, () => false), false);
|
||||
} finally {
|
||||
interruption.dispose();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
122
prototypes/web-graph/tests/test_session_lifetime.py
Normal file
122
prototypes/web-graph/tests/test_session_lifetime.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Session locale : connexion tardive, expiration et nouvelle authentification."""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
WEB = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(WEB))
|
||||
import workspace_server # noqa: E402
|
||||
|
||||
|
||||
class SessionLifetimeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="labfy-session-SPECIMEN-")
|
||||
self.now = 1000.0
|
||||
clock = mock.Mock(wraps=time)
|
||||
clock.monotonic.side_effect = lambda: self.now
|
||||
self.clock_patch = mock.patch.object(workspace_server, "time", clock)
|
||||
self.clock_patch.start()
|
||||
self.addCleanup(self.clock_patch.stop)
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.server = workspace_server.WorkspaceServer(
|
||||
("127.0.0.1", 0), workspace_server.Handler,
|
||||
workspace=Path(self.temporary.name), bridge=WEB / "unused-SPECIMEN",
|
||||
bootstrap="code-SPECIMEN-only")
|
||||
self.server.bridge_call = mock.Mock(
|
||||
side_effect=AssertionError("L'authentification ne doit pas ouvrir d'enquête"))
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=3)
|
||||
self.assertFalse(self.thread.is_alive())
|
||||
self.server.bridge_call.assert_not_called()
|
||||
|
||||
def request(self, method, path, *, cookie=None, body=None, origin=None):
|
||||
connection = http.client.HTTPConnection(
|
||||
"127.0.0.1", self.server.server_port, timeout=3)
|
||||
headers = {"Host": self.server.authority}
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
if body is not None:
|
||||
headers.update({"Content-Type": "application/json",
|
||||
"Origin": origin or self.server.origin})
|
||||
try:
|
||||
connection.request(method, path, headers=headers,
|
||||
body=json.dumps(body) if body is not None else None)
|
||||
response = connection.getresponse()
|
||||
return response.status, dict(response.getheaders()), response.read()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def login(self):
|
||||
status, headers, body = self.request("GET", "/")
|
||||
self.assertEqual(status, 303)
|
||||
self.assertIn("HttpOnly", headers["Set-Cookie"])
|
||||
self.assertIn("SameSite=Strict", headers["Set-Cookie"])
|
||||
self.assertIn("Max-Age=3600", headers["Set-Cookie"])
|
||||
return headers["Set-Cookie"].split(";", 1)[0]
|
||||
|
||||
def test_production_ttl_remains_one_hour(self):
|
||||
# INVARIANT: le TTL court du harness navigateur est opt-in ; les
|
||||
# constructions normales et leur cookie restent fixés à une heure.
|
||||
self.assertEqual(workspace_server.SESSION_TTL_SECONDS, 3600)
|
||||
self.assertEqual(self.server.session_ttl_seconds, 3600)
|
||||
self.login()
|
||||
self.assertEqual(self.server.session_deadline, self.now + 3600)
|
||||
|
||||
def test_first_login_after_two_hours_has_its_own_lifetime(self):
|
||||
self.now += 7200
|
||||
cookie = self.login()
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0], 200)
|
||||
deadline = self.server.session_deadline
|
||||
self.assertEqual(deadline, self.now + 3600)
|
||||
self.now += 3599
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0], 200)
|
||||
self.assertEqual(self.server.session_deadline, deadline)
|
||||
self.now += 1
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0], 401)
|
||||
|
||||
def test_reauthentication_renews_session_without_reviving_old_cookie(self):
|
||||
old_cookie = self.login()
|
||||
old_csrf = self.server.csrf
|
||||
self.now += 3601
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=old_cookie)[0], 401)
|
||||
cookie = self.login()
|
||||
self.assertNotEqual(cookie, old_cookie)
|
||||
self.assertNotEqual(self.server.csrf, old_csrf)
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=old_cookie)[0], 401)
|
||||
status, _, body = self.request("GET", "/api/v1/session", cookie=cookie)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(json.loads(body)["csrf"], self.server.csrf)
|
||||
status, _, html = self.request("GET", "/", cookie=cookie)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertNotIn(b'id="login-form"', html)
|
||||
self.assertEqual(self.server.session_deadline, self.now + 3600)
|
||||
|
||||
def test_bootstrap_route_is_absent_and_never_extends_a_session(self):
|
||||
cookie = self.login()
|
||||
before = (self.server.session, self.server.csrf, self.server.session_deadline)
|
||||
for expired in (False, True):
|
||||
if expired:
|
||||
self.now += 3601
|
||||
status, _, _ = self.request("POST", "/api/v1/session",
|
||||
body={}, origin=self.server.origin)
|
||||
self.assertEqual(status, 404)
|
||||
self.assertEqual((self.server.session, self.server.csrf,
|
||||
self.server.session_deadline), before)
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0],
|
||||
401 if expired else 200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
176
prototypes/web-graph/tests/test_session_lifetime_browser.mjs
Normal file
176
prototypes/web-graph/tests/test_session_lifetime_browser.mjs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
const SERVER_PROGRAM = String.raw`
|
||||
import json, signal, sys, threading
|
||||
from pathlib import Path
|
||||
from web_library import WebLibrary
|
||||
from workspace_server import Handler, WorkspaceServer
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
bridge = Path(sys.argv[2])
|
||||
library = WebLibrary(root, bridge)
|
||||
inactive = root / ".inactive"
|
||||
inactive.mkdir(mode=0o700, exist_ok=True)
|
||||
# CONTRACT: expiration réelle et courte, injectée uniquement dans ce processus
|
||||
# de test. Aucune route ne permet de modifier l'horloge ou le TTL.
|
||||
server = WorkspaceServer(("127.0.0.1", 0), Handler, workspace=inactive,
|
||||
bridge=bridge, library=library,
|
||||
instance_id="session-browser-SPECIMEN", config_id="session-browser-SPECIMEN",
|
||||
session_ttl_seconds=8)
|
||||
print(json.dumps({"origin": server.origin,
|
||||
"contract": "labfy.session-lifetime.fixture.v1"}), flush=True)
|
||||
signal.signal(signal.SIGTERM,
|
||||
lambda _signum, _frame: threading.Thread(target=server.shutdown,
|
||||
daemon=True).start())
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.server_close()
|
||||
`;
|
||||
|
||||
function startServer(library, runtime, state) {
|
||||
return spawn("python3", ["-c", SERVER_PROGRAM, library,
|
||||
"../../tools/local-jobs"], {
|
||||
cwd: PROTOTYPE,
|
||||
env: { ...process.env, PYTHONUNBUFFERED: "1", XDG_RUNTIME_DIR: runtime,
|
||||
XDG_STATE_HOME: state },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function ready(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
let errors = "";
|
||||
const timer = setTimeout(() => reject(new Error(
|
||||
`démarrage Web expiré: ${errors}`)), 10000);
|
||||
server.stderr.on("data", (chunk) => { errors += chunk; });
|
||||
server.stdout.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
const newline = output.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
clearTimeout(timer);
|
||||
resolve(JSON.parse(output.slice(0, newline)));
|
||||
});
|
||||
server.once("exit", (code) => reject(new Error(
|
||||
`serveur arrêté (${code}): ${errors}`)));
|
||||
});
|
||||
}
|
||||
|
||||
async function stopServer(server) {
|
||||
if (!server || server.exitCode !== null) return;
|
||||
const exited = new Promise((resolve) => server.once("exit", resolve));
|
||||
server.kill("SIGTERM");
|
||||
await exited;
|
||||
}
|
||||
|
||||
async function createAndOpenSpecimen(page) {
|
||||
const title = "Enquête session Firefox SPECIMEN";
|
||||
await page.type("#workspace-title", title);
|
||||
await page.click("#workspace-create-form button");
|
||||
await page.waitForFunction((expected) => [...document.querySelectorAll(
|
||||
".workspace-card h3")].some((heading) => heading.textContent === expected), {}, title);
|
||||
await page.click(".workspace-card [data-workspace-id]");
|
||||
await page.waitForFunction(() => document.querySelector("#connection")
|
||||
?.textContent.includes("Ouverte"), { timeout: 15000 });
|
||||
return page.evaluate(() => window.__LABFY_TEST__.getState().workspaceId);
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
const library = await mkdtemp(join(tmpdir(), "labfy-session-library-SPECIMEN-"));
|
||||
const profile = await mkdtemp(join(tmpdir(), "labfy-session-firefox-SPECIMEN-"));
|
||||
const runtime = await mkdtemp(join(tmpdir(), "labfy-session-runtime-SPECIMEN-"));
|
||||
const state = await mkdtemp(join(tmpdir(), "labfy-session-state-SPECIMEN-"));
|
||||
let browser;
|
||||
let server;
|
||||
try {
|
||||
server = startServer(library, runtime, state);
|
||||
const session = await ready(server);
|
||||
assert.notEqual(new URL(session.origin).port, "8081");
|
||||
assert.notEqual(new URL(session.origin).port, "8080");
|
||||
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
|
||||
headless: true, userDataDir: profile, args: ["--no-remote"] });
|
||||
const page = await browser.newPage();
|
||||
const pageErrors = [];
|
||||
const unauthorized = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error)));
|
||||
page.on("response", (response) => {
|
||||
if (response.status() === 401 && new URL(response.url()).pathname.startsWith("/api/"))
|
||||
unauthorized.push(response.url());
|
||||
});
|
||||
|
||||
await openAutomaticSession(page, session.origin);
|
||||
const workspaceId = await createAndOpenSpecimen(page);
|
||||
assert.ok(workspaceId);
|
||||
await page.$eval("#report-comment", (input) => {
|
||||
input.value = "Brouillon conservé après expiration SPECIMEN";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
const before = await page.evaluate(async () => {
|
||||
const response = await fetch("/api/v1/session", { cache: "no-store" });
|
||||
return response.json();
|
||||
});
|
||||
const oldCookie = (await page.cookies()).find((cookie) =>
|
||||
cookie.name.startsWith("labfy_session_"));
|
||||
assert.ok(oldCookie);
|
||||
|
||||
// WHY: le polling réel observe un seul 401 puis la navigation racine détruit
|
||||
// son contexte ; aucune route d'administration ne force l'expiration.
|
||||
const automaticReturn = page.waitForNavigation({ waitUntil: "domcontentloaded",
|
||||
timeout: 10000 });
|
||||
await automaticReturn;
|
||||
await page.waitForSelector("#library-home:not([hidden])", { timeout: 10000 });
|
||||
assert.equal(await page.$("#login-form"), null);
|
||||
assert.equal(await page.$("#bootstrap-code"), null);
|
||||
const requestCount = unauthorized.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1400));
|
||||
assert.equal(unauthorized.length, requestCount,
|
||||
"aucune rafale API après la première transition d'expiration");
|
||||
assert.equal(requestCount, 1, "un seul polling observe le 401 avant l'annulation");
|
||||
assert.equal(await page.evaluate(() => Object.keys(sessionStorage)
|
||||
.some((key) => key.startsWith("labfy-report-draft:"))), true);
|
||||
|
||||
const renewedCookie = (await page.cookies()).find((cookie) =>
|
||||
cookie.name.startsWith("labfy_session_"));
|
||||
assert.ok(renewedCookie);
|
||||
assert.notEqual(renewedCookie.value, oldCookie.value);
|
||||
const renewed = await page.evaluate(async () => {
|
||||
const response = await fetch("/api/v1/session", { cache: "no-store" });
|
||||
return response.json();
|
||||
});
|
||||
assert.notEqual(renewed.csrf, before.csrf);
|
||||
assert.equal(await page.$eval(".workspace-card h3", (item) => item.textContent),
|
||||
"Enquête session Firefox SPECIMEN");
|
||||
await page.click(`.workspace-card [data-workspace-id="${workspaceId}"]`);
|
||||
await page.waitForFunction((expected) =>
|
||||
window.__LABFY_TEST__.getState().workspaceId === expected, {}, workspaceId);
|
||||
|
||||
const staleHeaders = { Cookie: `${oldCookie.name}=${oldCookie.value}` };
|
||||
assert.equal((await fetch(`${session.origin}/api/v1/session`,
|
||||
{ headers: staleHeaders })).status, 401);
|
||||
assert.equal((await fetch(`${session.origin}/api/v1/queue/pause`, {
|
||||
method: "POST",
|
||||
headers: { ...staleHeaders, Origin: session.origin,
|
||||
"Content-Type": "application/json", "X-Labfy-CSRF": before.csrf },
|
||||
body: "{}",
|
||||
})).status, 403);
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log("PASS Firefox session : expiration réelle, arrêt unique, reconnexion automatique, " +
|
||||
"contexte SPECIMEN et rotation cookie/CSRF");
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await stopServer(server);
|
||||
await Promise.all([library, profile, runtime, state].map((path) =>
|
||||
rm(path, { recursive: true, force: true })));
|
||||
}
|
||||
173
prototypes/web-graph/tests/test_visual_polish_browser.mjs
Normal file
173
prototypes/web-graph/tests/test_visual_polish_browser.mjs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url);
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
||||
function ready(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
const timer = setTimeout(() => reject(new Error("démarrage expiré")), 10000);
|
||||
server.stdout.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
const url = output.match(/http:\/\/127\.0\.0\.1:\d+\//);
|
||||
if (url) {
|
||||
clearTimeout(timer);
|
||||
resolve({ url: url[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`)));
|
||||
});
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
await page.screenshot({ path: `/tmp/labfy-visual-polish-${name}.png` });
|
||||
}
|
||||
|
||||
async function assertNoHorizontalOverflow(page) {
|
||||
assert.deepEqual(
|
||||
await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const workspace = await mkdtemp(join(tmpdir(), "labfy-visual-polish-"));
|
||||
const profile = await mkdtemp(join(tmpdir(), "labfy-visual-polish-firefox-"));
|
||||
let browser;
|
||||
let server;
|
||||
try {
|
||||
const init = spawnSync(
|
||||
"./tools/local-jobs",
|
||||
["init-j7-specimen", "--workspace", workspace],
|
||||
{ cwd: REPOSITORY, encoding: "utf8", timeout: 30000 },
|
||||
);
|
||||
assert.equal(init.status, 0, init.stderr);
|
||||
spawnSync("./tools/local-jobs", ["export", "--workspace", workspace], {
|
||||
cwd: REPOSITORY, timeout: 10000,
|
||||
});
|
||||
server = spawn("python3", [
|
||||
"workspace_server.py", "--workspace", workspace, "--bridge",
|
||||
"../../tools/local-jobs", "--port", "0",
|
||||
], {
|
||||
cwd: PROTOTYPE,
|
||||
env: { ...process.env, PYTHONUNBUFFERED: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const session = await ready(server);
|
||||
browser = await puppeteer.launch({
|
||||
browser: "firefox", executablePath: FIREFOX, headless: true,
|
||||
userDataDir: profile, args: ["--no-remote"],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await openAutomaticSession(page, session.url);
|
||||
await page.waitForSelector(".node");
|
||||
await assertNoHorizontalOverflow(page);
|
||||
await screenshot(page, "1440-network");
|
||||
|
||||
await page.focus("#agent-prompt");
|
||||
await page.type("#agent-prompt", "Prépare une piste SPECIMEN sans lancer d’action.");
|
||||
await page.keyboard.down("Control");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.up("Control");
|
||||
assert.equal(await page.$eval("#agent-prompt", (element) => element.value), "");
|
||||
assert.match(
|
||||
await page.$eval(".agent-conversation", (element) => element.textContent),
|
||||
/Prépare une piste SPECIMEN/,
|
||||
);
|
||||
await page.type("#agent-prompt", "Brouillon SPECIMEN à conserver pendant le défilement.");
|
||||
await page.$eval(".agent-scroll", (element) => { element.scrollTop = element.scrollHeight; });
|
||||
assert.deepEqual(
|
||||
await page.$eval("#agent-prompt", (element) => ({
|
||||
value: element.value,
|
||||
visible: element.getBoundingClientRect().bottom > 0,
|
||||
})),
|
||||
{ value: "Brouillon SPECIMEN à conserver pendant le défilement.", visible: true },
|
||||
);
|
||||
await page.click('[data-pane="agent-panel"]');
|
||||
await page.click('[data-pane="agent-panel"]');
|
||||
assert.equal(
|
||||
await page.$eval("#agent-prompt", (element) => element.value),
|
||||
"Brouillon SPECIMEN à conserver pendant le défilement.",
|
||||
);
|
||||
await screenshot(page, "1440-agent");
|
||||
|
||||
await page.click(".agent-card.authorization button");
|
||||
await screenshot(page, "1440-authorization");
|
||||
await page.select("#activity-filter", "TOOL");
|
||||
assert.equal(await page.$$eval("[data-activity-type]:not([hidden])", (items) => items.length), 1);
|
||||
await page.click("#activity-expert-toggle");
|
||||
await page.click('[data-activity-type="TOOL"] summary');
|
||||
await screenshot(page, "1440-activity");
|
||||
|
||||
await page.click(".node");
|
||||
await page.click('[data-work-panel="provenance-drawer-panel"]');
|
||||
assert.match(
|
||||
await page.$eval("#drawer-provenance-summary", (element) => element.textContent),
|
||||
/Provenance disponible/,
|
||||
);
|
||||
await screenshot(page, "1440-provenance");
|
||||
|
||||
const firstNode = await page.$(".node");
|
||||
const box = await firstNode.boundingBox();
|
||||
assert.ok(box);
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 54, box.y + box.height / 2 + 28, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
assert.ok(Object.keys(await page.evaluate(() => window.__LABFY_TEST__.getState().pinned)).length > 0);
|
||||
await screenshot(page, "1440-pinned-selection");
|
||||
|
||||
const agentSeparator = await page.$("#agent-resizer");
|
||||
const separatorBox = await agentSeparator.boundingBox();
|
||||
assert.ok(separatorBox);
|
||||
await page.mouse.move(separatorBox.x + 2, separatorBox.y + 80);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(separatorBox.x + 42, separatorBox.y + 80, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
const layoutBeforeReload = await page.evaluate(() => localStorage.getItem("labfy-layout:agent"));
|
||||
assert.ok(Number(layoutBeforeReload) >= 208);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector(".node");
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("labfy-layout:agent")), layoutBeforeReload);
|
||||
|
||||
await page.click("#graph-fullscreen");
|
||||
assert.equal(await page.$eval(".workbench", (element) => element.classList.contains("graph-priority")), true);
|
||||
await screenshot(page, "1440-graph-priority");
|
||||
await page.keyboard.press("Escape");
|
||||
assert.equal(await page.$eval(".workbench", (element) => element.classList.contains("graph-priority")), false);
|
||||
|
||||
for (const [width, height, name] of [[1280, 800, "1280"], [1024, 768, "1024"], [700, 900, "700"]]) {
|
||||
await page.setViewport({ width, height });
|
||||
await page.waitForFunction(() => document.querySelector("#graph-panel").getBoundingClientRect().height > 0);
|
||||
await assertNoHorizontalOverflow(page);
|
||||
await screenshot(page, name);
|
||||
}
|
||||
await page.click("#work-panel-toggle");
|
||||
await screenshot(page, "700-drawer-collapsed");
|
||||
await page.click("#work-panel-toggle");
|
||||
|
||||
await page.evaluate(() => window.__LABFY_TEST__.processEventData("{invalid"));
|
||||
assert.match(await page.$eval("#graph-state", (element) => element.textContent), /JSON illisible/);
|
||||
await screenshot(page, "700-error");
|
||||
assert.deepEqual(errors, []);
|
||||
console.log("PASS visual polish Firefox : 11 captures, panels, persistance, prompt, graphe et erreur");
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) {
|
||||
server.kill("SIGINT");
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
await rm(profile, { recursive: true, force: true });
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
314
prototypes/web-graph/tests/test_web_app.py
Normal file
314
prototypes/web-graph/tests/test_web_app.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
"""Régressions synthétiques du lanceur 8081 et de la bibliothèque Web."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
WEB = Path(__file__).resolve().parents[1]
|
||||
ROOT = WEB.parents[1]
|
||||
sys.path.insert(0, str(WEB))
|
||||
|
||||
import web_app # noqa: E402
|
||||
from web_library import LibraryError, WebLibrary # noqa: E402
|
||||
from workspace_server import Handler, WorkspaceServer # noqa: E402
|
||||
|
||||
|
||||
class SyntheticEnvironment(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="labfy-web-SPECIMEN-")
|
||||
self.root = Path(self.temporary.name)
|
||||
self.runtime = self.root / "runtime"
|
||||
self.state = self.root / "state"
|
||||
self.runtime.mkdir(mode=0o700)
|
||||
self.state.mkdir(mode=0o700)
|
||||
self.environment = {**os.environ, "XDG_RUNTIME_DIR": str(self.runtime),
|
||||
"XDG_STATE_HOME": str(self.state)}
|
||||
self.bridge = self.root / "fake-local-jobs"
|
||||
self.bridge.write_text("""#!/usr/bin/env python3
|
||||
import json,sys
|
||||
from pathlib import Path
|
||||
args=sys.argv[1:]
|
||||
workspace=Path(args[args.index('--workspace')+1])
|
||||
if args[0]=='create-workspace':
|
||||
title=args[args.index('--title')+1]
|
||||
(workspace/'.labfy/runtime').mkdir(parents=True,exist_ok=True)
|
||||
(workspace/'Enquete.sqlite').write_bytes(b'SPECIMEN')
|
||||
manifest={'contract':'labfy.local_workspace.v1','investigation_id':'00000000-0000-4000-8000-000000000001','title':title,'mode':'local_experimental'}
|
||||
(workspace/'.labfy/runtime/workspace.json').write_text(json.dumps(manifest))
|
||||
elif args[0]=='export':
|
||||
pass
|
||||
else:
|
||||
print('commande SPECIMEN inconnue',file=sys.stderr)
|
||||
sys.exit(2)
|
||||
""", encoding="utf-8")
|
||||
self.bridge.chmod(0o700)
|
||||
|
||||
def tearDown(self):
|
||||
# Les tests de cycle doivent toujours arrêter leur propre serveur.
|
||||
subprocess.run([sys.executable, str(WEB / "web_app.py"), "stop"],
|
||||
env=self.environment, capture_output=True, timeout=8)
|
||||
self.temporary.cleanup()
|
||||
|
||||
|
||||
class LauncherTest(SyntheticEnvironment):
|
||||
def command(self, *arguments, timeout=8):
|
||||
return subprocess.run([sys.executable, str(WEB / "web_app.py"), *arguments],
|
||||
cwd=self.root, env=self.environment, text=True, capture_output=True,
|
||||
timeout=timeout, check=False)
|
||||
|
||||
def test_default_and_invalid_ports(self):
|
||||
parser = web_app.build_parser()
|
||||
arguments = parser.parse_args(["serve", "--workspace", str(self.root / "w")])
|
||||
self.assertEqual(arguments.port, 8081)
|
||||
for port in ("-1", "65536", "abc"):
|
||||
with self.assertRaises(SystemExit):
|
||||
parser.parse_args(["serve", "--workspace", str(self.root / "w"),
|
||||
"--port", port])
|
||||
self.assertEqual(parser.parse_args(["serve", "--workspace",
|
||||
str(self.root / "w"), "--port", "0"]).port, 0)
|
||||
|
||||
def test_xdg_start_status_auto_session_double_start_and_stop(self):
|
||||
workspace = self.root / "workspace"
|
||||
started = self.command("start", "--workspace", str(workspace), "--port", "0",
|
||||
"--bridge", str(self.bridge))
|
||||
self.assertEqual(started.returncode, 0, started.stderr)
|
||||
self.assertIn("Labfy Web : http://127.0.0.1:", started.stdout)
|
||||
self.assertNotIn("Code de session", started.stdout)
|
||||
instance = (self.runtime / web_app.APP_DIRECTORY / "instance.json")
|
||||
self.assertTrue(instance.is_file())
|
||||
self.assertIn('"automatic_session":true', instance.read_text(encoding="utf-8"))
|
||||
self.assertEqual(instance.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual((self.runtime / web_app.APP_DIRECTORY).stat().st_mode & 0o777,
|
||||
0o700)
|
||||
config = self.state / web_app.APP_DIRECTORY / "config.json"
|
||||
self.assertEqual(config.stat().st_mode & 0o777, 0o600)
|
||||
status = self.command("status")
|
||||
self.assertEqual(status.returncode, 0, status.stderr)
|
||||
self.assertIn("actif", status.stdout)
|
||||
duplicate = self.command("start", "--workspace", str(workspace), "--port", "0",
|
||||
"--bridge", str(self.bridge))
|
||||
self.assertEqual(duplicate.returncode, 0, duplicate.stderr)
|
||||
self.assertIn("déjà active", duplicate.stderr)
|
||||
stopped = self.command("stop")
|
||||
self.assertEqual(stopped.returncode, 0, stopped.stderr)
|
||||
self.assertFalse(instance.exists())
|
||||
|
||||
def test_occupied_port_does_not_disturb_witness(self):
|
||||
witness = socket.socket()
|
||||
witness.bind(("127.0.0.1", 0))
|
||||
witness.listen()
|
||||
port = witness.getsockname()[1]
|
||||
result = self.command("serve", "--workspace", str(self.root / "occupied"),
|
||||
"--port", str(port), "--bridge", str(self.bridge))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
witness.settimeout(0.2)
|
||||
probe = socket.create_connection(("127.0.0.1", port), timeout=1)
|
||||
accepted, _ = witness.accept()
|
||||
probe.close()
|
||||
accepted.close()
|
||||
witness.close()
|
||||
|
||||
def test_stale_state_never_signals_unconfirmed_pid(self):
|
||||
with mock.patch.dict(os.environ, self.environment, clear=True):
|
||||
files = web_app.InstanceFiles()
|
||||
web_app._atomic_json(files.instance, {
|
||||
"pid": os.getpid(), "proc_start": web_app._proc_start(os.getpid()),
|
||||
"port": 1, "instance_id": "foreign", "config_id": "foreign"})
|
||||
with mock.patch.object(web_app.os, "kill") as kill:
|
||||
self.assertEqual(web_app.stop(None), 1)
|
||||
kill.assert_not_called()
|
||||
|
||||
|
||||
class LibraryTest(SyntheticEnvironment):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.library = WebLibrary(self.root / "library", self.bridge)
|
||||
|
||||
def _creation_intent_workspace_id(self):
|
||||
intent = json.loads(self.library.creation_intent_path.read_text(encoding="utf-8"))
|
||||
return intent["workspace_id"]
|
||||
|
||||
def _assert_resumed_once(self, expected_workspace_id, response):
|
||||
self.assertEqual(response["workspace_id"], expected_workspace_id)
|
||||
self.assertEqual(len(list(self.library.workspaces.iterdir())), 1)
|
||||
self.assertEqual(len(self.library.snapshot()["entries"]), 1)
|
||||
self.assertFalse(self.library.creation_intent_path.exists())
|
||||
|
||||
def test_empty_create_replay_conflict_and_symlink_rejection(self):
|
||||
self.assertEqual(self.library.snapshot()["entries"], [])
|
||||
created = self.library.create("Enquête SPECIMEN", "intent-1")
|
||||
self.assertFalse(created["replayed"])
|
||||
replay = self.library.create("Enquête SPECIMEN", "intent-1")
|
||||
self.assertTrue(replay["replayed"])
|
||||
self.assertEqual(replay["workspace_id"], created["workspace_id"])
|
||||
with self.assertRaisesRegex(LibraryError, "autre titre"):
|
||||
self.library.create("Titre contradictoire", "intent-1")
|
||||
workspace = self.library.workspaces / created["workspace_id"]
|
||||
moved = self.root / "moved-workspace"
|
||||
workspace.rename(moved)
|
||||
workspace.symlink_to(moved, target_is_directory=True)
|
||||
with self.assertRaisesRegex(LibraryError, "invalide"):
|
||||
self.library.snapshot()
|
||||
|
||||
def test_create_resumes_same_workspace_after_bridge_interruption(self):
|
||||
run = subprocess.run
|
||||
calls = 0
|
||||
|
||||
def interrupt_after_bridge(*arguments, **keywords):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
result = run(*arguments, **keywords)
|
||||
raise OSError("interruption SPECIMEN après bridge")
|
||||
|
||||
with mock.patch("web_library.subprocess.run", side_effect=interrupt_after_bridge):
|
||||
with self.assertRaisesRegex(OSError, "après bridge"):
|
||||
self.library.create("Reprise bridge SPECIMEN", "resume-bridge")
|
||||
workspace_id = self._creation_intent_workspace_id()
|
||||
self.library = WebLibrary(self.library.root, self.bridge)
|
||||
resumed = self.library.create("Reprise bridge SPECIMEN", "resume-bridge")
|
||||
|
||||
self.assertEqual(calls, 1)
|
||||
self._assert_resumed_once(workspace_id, resumed)
|
||||
|
||||
def test_create_resumes_same_workspace_after_validation_interruption(self):
|
||||
validate = WebLibrary._validate_created_workspace
|
||||
validations = 0
|
||||
|
||||
def interrupt_after_validation(workspace):
|
||||
nonlocal validations
|
||||
validate(workspace)
|
||||
validations += 1
|
||||
if validations == 1:
|
||||
raise OSError("interruption SPECIMEN après validation")
|
||||
|
||||
with mock.patch.object(WebLibrary, "_validate_created_workspace",
|
||||
side_effect=interrupt_after_validation):
|
||||
with self.assertRaisesRegex(OSError, "après validation"):
|
||||
self.library.create("Reprise validation SPECIMEN", "resume-validation")
|
||||
workspace_id = self._creation_intent_workspace_id()
|
||||
|
||||
self.library = WebLibrary(self.library.root, self.bridge)
|
||||
with mock.patch("web_library.subprocess.run") as bridge:
|
||||
resumed = self.library.create(
|
||||
"Reprise validation SPECIMEN", "resume-validation")
|
||||
bridge.assert_not_called()
|
||||
self._assert_resumed_once(workspace_id, resumed)
|
||||
|
||||
def test_create_republishes_same_workspace_after_registry_write_failure(self):
|
||||
with mock.patch.object(self.library, "_write_registry",
|
||||
side_effect=OSError("écriture registre SPECIMEN")):
|
||||
with self.assertRaisesRegex(OSError, "écriture registre"):
|
||||
self.library.create("Reprise registre SPECIMEN", "resume-registry")
|
||||
workspace_id = self._creation_intent_workspace_id()
|
||||
|
||||
self.library = WebLibrary(self.library.root, self.bridge)
|
||||
with mock.patch("web_library.subprocess.run") as bridge:
|
||||
resumed = self.library.create("Reprise registre SPECIMEN", "resume-registry")
|
||||
bridge.assert_not_called()
|
||||
self._assert_resumed_once(workspace_id, resumed)
|
||||
|
||||
def test_create_finishes_after_late_registry_publication_failure(self):
|
||||
write_registry = self.library._write_registry
|
||||
|
||||
def interrupt_after_publication(registry):
|
||||
write_registry(registry)
|
||||
raise OSError("interruption SPECIMEN après publication")
|
||||
|
||||
with mock.patch.object(self.library, "_write_registry",
|
||||
side_effect=interrupt_after_publication):
|
||||
with self.assertRaisesRegex(OSError, "après publication"):
|
||||
self.library.create("Publication SPECIMEN", "resume-published")
|
||||
workspace_id = self._creation_intent_workspace_id()
|
||||
|
||||
self.library = WebLibrary(self.library.root, self.bridge)
|
||||
with mock.patch("web_library.subprocess.run") as bridge:
|
||||
resumed = self.library.create("Publication SPECIMEN", "resume-published")
|
||||
bridge.assert_not_called()
|
||||
self.assertTrue(resumed["replayed"])
|
||||
self._assert_resumed_once(workspace_id, resumed)
|
||||
|
||||
def test_authenticated_routes_generation_stale_and_busy(self):
|
||||
server = WorkspaceServer(("127.0.0.1", 0), Handler,
|
||||
workspace=self.library.root / ".inactive", bridge=self.bridge,
|
||||
bootstrap="SPECIMEN-bootstrap", library=self.library,
|
||||
instance_id="SPECIMEN-instance", config_id="SPECIMEN-config")
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
cookie = None
|
||||
|
||||
def request(method, path, body=None, *, authenticated=True, csrf=True):
|
||||
nonlocal cookie
|
||||
connection = http.client.HTTPConnection("127.0.0.1", server.server_port,
|
||||
timeout=3)
|
||||
headers = {"Host": server.authority}
|
||||
if method == "POST":
|
||||
headers.update({"Origin": server.origin, "Content-Type": "application/json"})
|
||||
if csrf: headers["X-Labfy-CSRF"] = server.csrf
|
||||
if authenticated and cookie: headers["Cookie"] = cookie
|
||||
payload = json.dumps(body) if body is not None else None
|
||||
connection.request(method, path, body=payload, headers=headers)
|
||||
response = connection.getresponse()
|
||||
data = response.read()
|
||||
if response.getheader("Set-Cookie"):
|
||||
cookie = response.getheader("Set-Cookie").split(";", 1)[0]
|
||||
result = response.status, (json.loads(data) if data else {})
|
||||
connection.close()
|
||||
return result
|
||||
|
||||
try:
|
||||
self.assertEqual(request("GET", "/api/v1/library", authenticated=False)[0], 401)
|
||||
self.assertEqual(request("GET", "/", authenticated=False)[0], 303)
|
||||
self.assertEqual(request("POST", "/api/v1/library/workspaces",
|
||||
{"title": "Sans CSRF", "idempotency_key": "x"}, csrf=False)[0], 403)
|
||||
status, empty = request("GET", "/api/v1/library")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(empty["generation"], 0)
|
||||
status, first = request("POST", "/api/v1/library/workspaces",
|
||||
{"title": "Première SPECIMEN", "idempotency_key": "create-1"})
|
||||
self.assertEqual(status, 201, first)
|
||||
time.sleep(0.04)
|
||||
status, replay = request("POST", "/api/v1/library/workspaces",
|
||||
{"title": "Première SPECIMEN", "idempotency_key": "create-1"})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(replay["replayed"])
|
||||
time.sleep(0.04)
|
||||
status, second = request("POST", "/api/v1/library/workspaces",
|
||||
{"title": "Seconde SPECIMEN", "idempotency_key": "create-2"})
|
||||
self.assertEqual(status, 201, second)
|
||||
time.sleep(0.04)
|
||||
stale = request("POST", "/api/v1/library/open", {
|
||||
"workspace_id": first["workspace_id"], "expected_generation": 1})
|
||||
self.assertEqual(stale[0], 409)
|
||||
time.sleep(0.04)
|
||||
opened = request("POST", "/api/v1/library/open", {
|
||||
"workspace_id": first["workspace_id"],
|
||||
"expected_generation": second["generation"]})
|
||||
self.assertEqual(opened[0], 200, opened)
|
||||
time.sleep(0.04)
|
||||
busy = request("POST", "/api/v1/library/open", {
|
||||
"workspace_id": second["workspace_id"],
|
||||
"expected_generation": second["generation"]})
|
||||
self.assertEqual(busy[0], 409)
|
||||
session = request("GET", "/api/v1/session")[1]
|
||||
self.assertTrue(session["library_mode"])
|
||||
self.assertEqual(session["active_workspace_id"], first["workspace_id"])
|
||||
self.assertEqual(session["generation"], second["generation"])
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
380
prototypes/web-graph/tests/test_web_app_browser.mjs
Normal file
380
prototypes/web-graph/tests/test_web_app_browser.mjs
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession, resumeAutomaticSessionAfterOwnedRestart } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url);
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
||||
function startServer(library, runtime, state, port = 0) {
|
||||
const result = spawnSync("./tools/labfy", ["--library", library,
|
||||
"--port", String(port)], {
|
||||
cwd: REPOSITORY,
|
||||
env: { ...process.env, PYTHONUNBUFFERED: "1", XDG_RUNTIME_DIR: runtime,
|
||||
XDG_STATE_HOME: state },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr.toString());
|
||||
return { output: result.stdout.toString(), runtime, state };
|
||||
}
|
||||
|
||||
function ready(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
const timer = setTimeout(() => reject(new Error("démarrage Web expiré")), 10000);
|
||||
output += server.output;
|
||||
const origin = output.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
|
||||
if (origin) {
|
||||
clearTimeout(timer);
|
||||
resolve({ origin: origin[1] });
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`lanceur Web sans origine: ${output}`));
|
||||
});
|
||||
}
|
||||
|
||||
async function stopServer(server) {
|
||||
if (!server) return;
|
||||
const result = spawnSync("python3", ["web_app.py", "stop"], {
|
||||
cwd: PROTOTYPE,
|
||||
env: { ...process.env, XDG_RUNTIME_DIR: server.runtime,
|
||||
XDG_STATE_HOME: server.state },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
|
||||
async function assertServerReady(server, expectedOrigin) {
|
||||
const origin = (await ready(server)).origin;
|
||||
assert.equal(origin, expectedOrigin);
|
||||
const health = await fetch(`${origin}/healthz`, { cache: "no-store" });
|
||||
assert.equal(health.status, 200);
|
||||
const body = await health.json();
|
||||
assert.equal(body.contract, "labfy.workspace.health.v1");
|
||||
return origin;
|
||||
}
|
||||
|
||||
async function detachPageFromOwnedServer(page) {
|
||||
// CONTRACT: avant d'arrêter un backend possédé, le client de test quitte son
|
||||
// EventSource afin que Firefox ne réutilise pas une navigation interrompue
|
||||
// lorsque la même origine est relancée.
|
||||
await page.evaluate(() => window.location.replace("about:blank"));
|
||||
await page.waitForFunction(() => location.href === "about:blank");
|
||||
}
|
||||
|
||||
async function createWorkspace(page, title) {
|
||||
await page.type("#workspace-title", title);
|
||||
await page.click("#workspace-create-form button");
|
||||
await page.waitForFunction((expectedTitle) => [...document.querySelectorAll(".workspace-card")]
|
||||
.some((card) => card.querySelector("h3")?.textContent === expectedTitle), {}, title);
|
||||
return page.$$eval(".workspace-card", (cards, expectedTitle) => {
|
||||
const card = cards.find((item) => item.querySelector("h3")?.textContent === expectedTitle);
|
||||
return card?.querySelector("[data-workspace-id]")?.dataset.workspaceId ?? null;
|
||||
}, title);
|
||||
}
|
||||
|
||||
async function openWorkspace(page, workspaceId) {
|
||||
await page.click(`[data-workspace-id="${workspaceId}"]`);
|
||||
await page.waitForSelector("#workbench-shell:not([hidden])");
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const connection = document.querySelector("#connection")?.textContent ?? "";
|
||||
return connection.includes("Ouverte") || connection.includes("Poste local synchronisé");
|
||||
}, { timeout: 15000 });
|
||||
} catch (error) {
|
||||
const connection = await page.$eval("#connection", (item) => item.textContent);
|
||||
throw new Error(`ouverture ${workspaceId} incomplète : ${connection}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDownload(path) {
|
||||
let size = -1;
|
||||
let stable = 0;
|
||||
for (let attempt = 0; attempt < 120 && stable < 3; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
try {
|
||||
const current = (await stat(path)).size;
|
||||
stable = current > 0 && current === size ? stable + 1 : 0;
|
||||
size = current;
|
||||
} catch {
|
||||
stable = 0;
|
||||
}
|
||||
}
|
||||
assert.ok(stable >= 3, `téléchargement Firefox terminé ${path}`);
|
||||
return size;
|
||||
}
|
||||
|
||||
const library = await mkdtemp(join(tmpdir(), "labfy-web-library-"));
|
||||
const profile = await mkdtemp(join(tmpdir(), "labfy-web-firefox-"));
|
||||
const runtime = await mkdtemp(join(tmpdir(), "labfy-web-runtime-"));
|
||||
const state = await mkdtemp(join(tmpdir(), "labfy-web-state-"));
|
||||
const fixtures = await mkdtemp(join(tmpdir(), "labfy-web-SPECIMEN-"));
|
||||
const downloads = join(fixtures, "downloads");
|
||||
await mkdir(downloads, { mode: 0o700 });
|
||||
const specimenEml = join(fixtures, "courrier rapport SPECIMEN.eml");
|
||||
await writeFile(specimenEml,
|
||||
"From: Source SPECIMEN <source@example.test>\r\n" +
|
||||
"To: Pivot SPECIMEN <pivot@example.test>\r\n" +
|
||||
"Message-ID: <web-app-report@example.test>\r\n" +
|
||||
"Subject: Rapport navigateur A\r\n\r\n" +
|
||||
"Contenu synthétique pour le parcours A.\r\n");
|
||||
let browser;
|
||||
let server;
|
||||
try {
|
||||
server = startServer(library, runtime, state);
|
||||
const session = await ready(server);
|
||||
const assignedPort = Number.parseInt(new URL(session.origin).port, 10);
|
||||
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
|
||||
headless: true, userDataDir: profile, args: ["--no-remote"],
|
||||
extraPrefsFirefox: { "browser.download.folderList": 2,
|
||||
"browser.download.dir": downloads, "browser.download.useDownloadDir": true,
|
||||
"browser.helperApps.neverAsk.saveToDisk":
|
||||
"text/html,application/json,application/pdf,text/plain" } });
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await page.setViewport({ width: 700, height: 900 });
|
||||
await openAutomaticSession(page, session.origin, "A");
|
||||
|
||||
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 0);
|
||||
assert.match(await page.$eval("#library-empty", (item) => item.textContent), /Aucune enquête/);
|
||||
|
||||
const workspaceATitle = "Enquête A navigateur SPECIMEN";
|
||||
const workspaceAId = await createWorkspace(page, workspaceATitle);
|
||||
assert.ok(workspaceAId);
|
||||
assert.equal(await page.$eval("#workbench-shell", (item) => item.hidden), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await openWorkspace(page, workspaceAId);
|
||||
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),
|
||||
workspaceATitle);
|
||||
assert.equal(await page.$eval("#mode-badge", (item) => item.textContent.includes("SPECIMEN")), false);
|
||||
|
||||
await page.click("#open-import");
|
||||
const importInput = await page.$("#import-files");
|
||||
await importInput.uploadFile(specimenEml);
|
||||
await page.waitForFunction(() => {
|
||||
const items = [...document.querySelectorAll("#import-list li")];
|
||||
return items.length === 1 && items.every((item) => item.textContent.includes("préparé"));
|
||||
}, { timeout: 20000 });
|
||||
await page.click("#import-confirm");
|
||||
await page.waitForFunction(() => document.querySelectorAll(".node").length >= 1,
|
||||
{ timeout: 20000 });
|
||||
|
||||
await page.click('[data-work-panel="next-panel"]');
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelectorAll("#planner-list input:not(:disabled)").length >= 1,
|
||||
{ timeout: 15000 });
|
||||
await page.$$eval("#planner-list input:not(:disabled)", (inputs) => {
|
||||
inputs.forEach((input) => input.click());
|
||||
});
|
||||
await page.select("#planner-profile", "LOCAL_PRUDENT");
|
||||
await page.click("#planner-launch");
|
||||
await page.waitForFunction(() => {
|
||||
const jobs = [...document.querySelectorAll("#jobs-list li")];
|
||||
return jobs.length >= 1 && jobs.every((job) => job.dataset.jobState === "COMPLETED");
|
||||
}, { timeout: 45000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const candidate = [...document.querySelectorAll("#object-list button")]
|
||||
.find((button) => button.textContent.startsWith("email —"));
|
||||
if (!candidate) throw new Error("preuve e-mail SPECIMEN absente");
|
||||
candidate.click();
|
||||
});
|
||||
await page.click('[data-panel="actions-panel"]');
|
||||
await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll("#actions button")]
|
||||
.find((item) => item.textContent === "Ouvrir la preuve");
|
||||
if (!button) throw new Error("action Ouvrir la preuve absente");
|
||||
button.click();
|
||||
});
|
||||
await page.waitForSelector("#evidence-dialog[open] #observation-list li", { timeout: 15000 });
|
||||
await page.type("#observation-list li input[placeholder='Motif obligatoire']",
|
||||
"Revue rapport A SPECIMEN");
|
||||
await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll("#observation-list li:first-child button")]
|
||||
.find((item) => item.textContent === "Confirmer");
|
||||
if (!button) throw new Error("action Confirmer absente");
|
||||
button.click();
|
||||
});
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector("#evidence-status")?.textContent.includes("journalisée"),
|
||||
{ timeout: 15000 });
|
||||
await page.click("#evidence-dialog .dialog-close button");
|
||||
|
||||
await page.click('[data-report-action="toggle"]');
|
||||
await page.click('[data-work-panel="reports-panel"]');
|
||||
await page.click("#report-name", { clickCount: 3 });
|
||||
await page.type("#report-name", "Rapport enquête A SPECIMEN");
|
||||
await page.click("#report-preview");
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector("#report-preview-content")?.textContent.includes("Coupe"),
|
||||
{ timeout: 15000 });
|
||||
await page.click("#report-generate");
|
||||
await page.waitForFunction(() => document.querySelectorAll("#report-status a").length === 5,
|
||||
{ timeout: 30000 });
|
||||
|
||||
const reportHref = await page.$eval('#report-status a[href$="report.json"]',
|
||||
(link) => link.getAttribute("href"));
|
||||
const reportId = reportHref.split("/")[4];
|
||||
const artifactNames = ["report.json", "report.html", "report.pdf", "manifest.json",
|
||||
"NOTICE.txt"];
|
||||
const receivedSizes = new Map();
|
||||
for (const name of artifactNames) {
|
||||
await page.$eval(`#report-status a[href$="${name}"]`, (link) => link.click());
|
||||
receivedSizes.set(name, await waitForDownload(join(downloads, name)));
|
||||
}
|
||||
const manifestBytes = await readFile(join(downloads, "manifest.json"));
|
||||
const manifest = JSON.parse(manifestBytes);
|
||||
assert.equal(manifest.contract, "labfy.report_manifest.v1");
|
||||
assert.equal(manifest.report_id, reportId);
|
||||
assert.deepEqual(new Set(manifest.files.map((entry) => entry.path)),
|
||||
new Set(artifactNames.filter((name) => name !== "manifest.json")));
|
||||
for (const entry of manifest.files) {
|
||||
const bytes = await readFile(join(downloads, entry.path));
|
||||
assert.equal(bytes.length, entry.size, `taille manifestée ${entry.path}`);
|
||||
assert.equal(receivedSizes.get(entry.path), entry.size, `taille reçue ${entry.path}`);
|
||||
assert.equal(createHash("sha256").update(bytes).digest("hex"), entry.sha256,
|
||||
`SHA-256 ${entry.path}`);
|
||||
}
|
||||
assert.equal(receivedSizes.get("manifest.json"), manifestBytes.length,
|
||||
"taille reçue manifest.json");
|
||||
const reportHtml = (await readFile(join(downloads, "report.html"))).toString("utf8");
|
||||
assert.doesNotMatch(reportHtml, /<(?:script|iframe|frame|object|embed|link)\b/i);
|
||||
assert.doesNotMatch(reportHtml, /\b(?:src|href|action)\s*=\s*["'](?:https?:|\/\/)/i);
|
||||
assert.doesNotMatch(reportHtml, /url\s*\(/i);
|
||||
const offlinePage = await browser.newPage();
|
||||
await offlinePage.goto(`file://${join(downloads, "report.html")}`,
|
||||
{ waitUntil: "domcontentloaded" });
|
||||
assert.match(await offlinePage.$eval("body", (body) => body.textContent),
|
||||
/Rapport enquête A SPECIMEN/);
|
||||
assert.equal(await offlinePage.$$eval(
|
||||
"script,iframe,frame,object,embed,link,img,audio,video,source",
|
||||
(nodes) => nodes.length), 0);
|
||||
await offlinePage.close();
|
||||
const pdfText = spawnSync("pdftotext", [join(downloads, "report.pdf"), "-"],
|
||||
{ encoding: "utf8" });
|
||||
assert.equal(pdfText.status, 0, pdfText.stderr);
|
||||
assert.match(pdfText.stdout.split(/\s+/).join(" "), /Rapport enquête A SPECIMEN/);
|
||||
|
||||
await page.$eval("#report-name", (input) => {
|
||||
input.value = "Brouillon rapport A SPECIMEN";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
let draftKeys = await page.evaluate(() => Object.keys(sessionStorage)
|
||||
.filter((key) => key.startsWith("labfy-report-draft:")));
|
||||
assert.equal(draftKeys.length, 1);
|
||||
assert.ok(draftKeys[0].includes(workspaceAId));
|
||||
|
||||
// INVARIANT: une actualisation devenue tardive après « Accueil » ne doit pas
|
||||
// restaurer le workbench ni altérer la bibliothèque rendue ensuite.
|
||||
await page.setRequestInterception(true);
|
||||
let releaseSnapshot;
|
||||
const delayedSnapshot = new Promise((resolve) => { releaseSnapshot = resolve; });
|
||||
let markSnapshotContinued;
|
||||
const snapshotContinued = new Promise((resolve) => { markSnapshotContinued = resolve; });
|
||||
let held = false;
|
||||
const holdSnapshotRequest = async (request) => {
|
||||
if (!held && new URL(request.url()).pathname === "/api/v1/snapshot") {
|
||||
held = true;
|
||||
await delayedSnapshot;
|
||||
await request.continue().catch(() => {});
|
||||
markSnapshotContinued();
|
||||
return;
|
||||
}
|
||||
await request.continue().catch(() => {});
|
||||
};
|
||||
page.on("request", holdSnapshotRequest);
|
||||
await page.waitForFunction(() => window.__LABFY_TEST__.getState().workspaceId !== null);
|
||||
for (let attempt = 0; attempt < 100 && !held; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
assert.equal(held, true);
|
||||
await page.click("#home-button");
|
||||
await page.waitForSelector("#library-home:not([hidden])");
|
||||
releaseSnapshot();
|
||||
await snapshotContinued;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
assert.equal(await page.$eval("#workbench-shell", (item) => item.hidden), true);
|
||||
assert.match(await page.$eval(".workspace-card p", (item) => item.textContent), /Ouverte/);
|
||||
await page.setRequestInterception(false);
|
||||
page.off("request", holdSnapshotRequest);
|
||||
|
||||
// Le port initial est attribué par le système. Sa réutilisation conserve le
|
||||
// même origin et permet de vérifier les brouillons session après redémarrage.
|
||||
await detachPageFromOwnedServer(page);
|
||||
await stopServer(server);
|
||||
server = startServer(library, runtime, state, assignedPort);
|
||||
const restartedForB = await assertServerReady(server, session.origin);
|
||||
await resumeAutomaticSessionAfterOwnedRestart(page, restartedForB, "B");
|
||||
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 1);
|
||||
assert.equal(await page.$eval(".workspace-card h3", (item) => item.textContent), workspaceATitle);
|
||||
assert.match(await page.$eval(".workspace-card p", (item) => item.textContent), /Prête à ouvrir/);
|
||||
|
||||
const workspaceBTitle = "Enquête B navigateur SPECIMEN";
|
||||
const workspaceBId = await createWorkspace(page, workspaceBTitle);
|
||||
assert.ok(workspaceBId);
|
||||
assert.notEqual(workspaceBId, workspaceAId);
|
||||
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 2);
|
||||
await openWorkspace(page, workspaceBId);
|
||||
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),
|
||||
workspaceBTitle);
|
||||
assert.equal(await page.$eval("#report-name", (item) => item.value), "Rapport d’enquête");
|
||||
draftKeys = await page.evaluate(() => Object.keys(sessionStorage)
|
||||
.filter((key) => key.startsWith("labfy-report-draft:")));
|
||||
assert.equal(draftKeys.length, 1);
|
||||
assert.ok(draftKeys[0].includes(workspaceAId));
|
||||
assert.equal(draftKeys[0].includes(workspaceBId), false);
|
||||
|
||||
await page.$eval("#report-name", (input) => {
|
||||
input.value = "Brouillon rapport B SPECIMEN";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
draftKeys = await page.evaluate(() => Object.keys(sessionStorage)
|
||||
.filter((key) => key.startsWith("labfy-report-draft:")));
|
||||
assert.equal(draftKeys.length, 2);
|
||||
assert.equal(draftKeys.some((key) => key.includes(workspaceAId)), true);
|
||||
assert.equal(draftKeys.some((key) => key.includes(workspaceBId)), true);
|
||||
|
||||
await page.click("#home-button");
|
||||
await page.waitForSelector("#library-home:not([hidden])");
|
||||
await detachPageFromOwnedServer(page);
|
||||
await stopServer(server);
|
||||
server = startServer(library, runtime, state, assignedPort);
|
||||
const restartedForA = await assertServerReady(server, session.origin);
|
||||
await resumeAutomaticSessionAfterOwnedRestart(page, restartedForA, "C");
|
||||
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 2);
|
||||
await openWorkspace(page, workspaceAId);
|
||||
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),
|
||||
workspaceATitle);
|
||||
assert.equal(await page.evaluate(() => window.__LABFY_TEST__.getState().workspaceId),
|
||||
workspaceAId);
|
||||
// La création de B incrémente la génération de bibliothèque : le brouillon
|
||||
// A de la génération précédente reste stocké mais ne peut contaminer A ou B.
|
||||
assert.equal(await page.$eval("#report-name", (item) => item.value),
|
||||
"Rapport d’enquête");
|
||||
assert.notEqual(await page.$eval("#report-name", (item) => item.value),
|
||||
"Brouillon rapport B SPECIMEN");
|
||||
draftKeys = await page.evaluate(() => Object.keys(sessionStorage)
|
||||
.filter((key) => key.startsWith("labfy-report-draft:")));
|
||||
assert.equal(draftKeys.length, 2);
|
||||
assert.equal(draftKeys.some((key) => key.includes(workspaceAId)), true);
|
||||
assert.equal(draftKeys.some((key) => key.includes(workspaceBId)), true);
|
||||
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = "200%"; });
|
||||
assert.equal(await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth > document.documentElement.clientWidth), false);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log("PASS navigateur Web : A importée/analysée/revue avec rapport vérifié, " +
|
||||
"bibliothèque A/B, brouillons isolés et deux redémarrages");
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await stopServer(server);
|
||||
await Promise.all([library, profile, runtime, state, fixtures].map((path) =>
|
||||
rm(path, { recursive: true, force: true })));
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url),
|
||||
PROTOTYPE = new URL("..", import.meta.url);
|
||||
|
|
@ -14,11 +15,10 @@ function ready(server) {
|
|||
const timer = setTimeout(() => reject(new Error("démarrage expiré")), 8000);
|
||||
server.stdout.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//),
|
||||
code = text.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (url && code) {
|
||||
const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
|
||||
if (url) {
|
||||
clearTimeout(timer);
|
||||
resolve({ url: url[0], code: code[1] });
|
||||
resolve({ url: url[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (value) =>
|
||||
|
|
@ -26,14 +26,6 @@ function ready(server) {
|
|||
);
|
||||
});
|
||||
}
|
||||
async function login(page, session) {
|
||||
await page.goto(session.url, { waitUntil: "domcontentloaded" });
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button"),
|
||||
]);
|
||||
}
|
||||
|
||||
const workspace = await mkdtemp(join(tmpdir(), "labfy-workbench-")),
|
||||
profile = await mkdtemp(join(tmpdir(), "labfy-workbench-firefox-"));
|
||||
|
|
@ -81,7 +73,7 @@ try {
|
|||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
const start = Date.now();
|
||||
await login(page, session);
|
||||
await openAutomaticSession(page, session.url);
|
||||
await page.waitForSelector(".node");
|
||||
const metrics = await page.evaluate(() => ({
|
||||
nodes: document.querySelectorAll(".node").length,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url);
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
|
|
@ -19,10 +20,9 @@ function ready(server) {
|
|||
server.stdout.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
const url = output.match(/http:\/\/127\.0\.0\.1:\d+\//);
|
||||
const code = output.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (url && code) {
|
||||
if (url) {
|
||||
clearTimeout(timer);
|
||||
resolve({ url: url[0], code: code[1] });
|
||||
resolve({ url: url[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) =>
|
||||
|
|
@ -31,14 +31,6 @@ function ready(server) {
|
|||
});
|
||||
}
|
||||
|
||||
async function login(page, session) {
|
||||
await page.goto(session.url, { waitUntil: "domcontentloaded" });
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button"),
|
||||
]);
|
||||
}
|
||||
|
||||
const workspace = await mkdtemp(join(tmpdir(), "labfy-workbench-populated-"));
|
||||
const profile = await mkdtemp(
|
||||
|
|
@ -89,7 +81,7 @@ try {
|
|||
await page.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
const authenticationStarted = performance.now();
|
||||
await login(page, session);
|
||||
await openAutomaticSession(page, session.url);
|
||||
await page.waitForSelector(".node");
|
||||
const firstRenderMs = Math.round(performance.now() - authenticationStarted);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url);
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
|
|
@ -16,10 +17,9 @@ function waitForServer(server) {
|
|||
server.stdout.on("data", (chunk) => {
|
||||
output += chunk.toString();
|
||||
const url = output.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
|
||||
const code = output.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (url && code) {
|
||||
if (url) {
|
||||
clearTimeout(timeout);
|
||||
resolve({ url: url[0], code: code[1] });
|
||||
resolve({ url: url[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) => reject(new Error(`serveur J6 arrêté (${code})`)));
|
||||
|
|
@ -62,13 +62,9 @@ try {
|
|||
headless: true, userDataDir: profile, args: ["--no-remote"] });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(session.url, { waitUntil: "domcontentloaded" });
|
||||
assert.ok(await page.$("#login-form"));
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button")]);
|
||||
await openAutomaticSession(page, session.url);
|
||||
await page.waitForSelector(`[data-id="evidence:${manifest.eml_id}"]`);
|
||||
assert.match(await page.$eval("#mode-badge", (item) => item.textContent), /Poste J6/);
|
||||
assert.match(await page.$eval("#mode-badge", (item) => item.textContent), /Poste local/);
|
||||
|
||||
await page.click("#queue-pause");
|
||||
await clickEvidenceCapability(page, manifest.eml_id,
|
||||
|
|
|
|||
|
|
@ -35,11 +35,8 @@ class WorkspaceServerTest(unittest.TestCase):
|
|||
bridge=cls.bridge, bootstrap="SPECIMEN-CODE")
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
status, headers, _ = cls.request_raw(
|
||||
"POST", "/api/v1/session", {"bootstrap_code": "SPECIMEN-CODE"},
|
||||
origin=cls.server.origin,
|
||||
)
|
||||
if status != 200:
|
||||
status, headers, _ = cls.request_raw("GET", "/")
|
||||
if status != 303:
|
||||
raise RuntimeError("session J6 impossible")
|
||||
cls.cookie = headers["Set-Cookie"].split(";", 1)[0]
|
||||
status, _, body = cls.request_raw("GET", "/api/v1/session", cookie=cls.cookie)
|
||||
|
|
@ -85,8 +82,7 @@ class WorkspaceServerTest(unittest.TestCase):
|
|||
|
||||
def test_authentication_origin_csrf_and_input_rejections(self):
|
||||
self.assertEqual(self.request_raw(
|
||||
"POST", "/api/v1/session", {"bootstrap_code": ["SPECIMEN-CODE"]},
|
||||
origin=self.server.origin)[0], 400)
|
||||
"POST", "/api/v1/session", {}, origin=self.server.origin)[0], 404)
|
||||
self.assertEqual(self.request_raw("GET", "/api/v1/jobs")[0], 401)
|
||||
self.assertEqual(self.request_raw("GET", "/api/v1/jobs", host="evil.test")[0], 403)
|
||||
self.assertEqual(self.request_raw("POST", "/api/v1/queue/pause", {},
|
||||
|
|
|
|||
375
prototypes/web-graph/web_app.py
Normal file
375
prototypes/web-graph/web_app.py
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Lanceur local explicite et gestionnaire d'instance du poste Web Labfy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import secrets
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
from web_library import WebLibrary
|
||||
from workspace_server import Handler, WorkspaceServer
|
||||
|
||||
DEFAULT_PORT = 8081
|
||||
APP_DIRECTORY = "labfy-investigation-web"
|
||||
|
||||
|
||||
def valid_port(value):
|
||||
try:
|
||||
port = int(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise argparse.ArgumentTypeError("le port doit être un entier") from error
|
||||
if not 0 <= port <= 65535:
|
||||
raise argparse.ArgumentTypeError("le port doit être compris entre 0 et 65535")
|
||||
return port
|
||||
|
||||
|
||||
def _private_directory(path: Path):
|
||||
absolute = Path(os.path.abspath(path))
|
||||
for component in reversed((absolute, *absolute.parents)):
|
||||
if component.exists() and component.is_symlink():
|
||||
raise RuntimeError(f"Lien symbolique interdit dans l'état : {component}")
|
||||
path = absolute
|
||||
if path.exists() and (path.is_symlink() or not path.is_dir()):
|
||||
raise RuntimeError(f"Répertoire d'état non sûr : {path}")
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if path.stat().st_uid != os.getuid():
|
||||
raise RuntimeError(f"Répertoire d'état d'un autre utilisateur : {path}")
|
||||
os.chmod(path, 0o700)
|
||||
return path
|
||||
|
||||
|
||||
def runtime_directory(env=None):
|
||||
env = os.environ if env is None else env
|
||||
base = env.get("XDG_RUNTIME_DIR")
|
||||
if base:
|
||||
root = Path(base)
|
||||
if root.is_symlink() or not root.is_dir() or root.stat().st_uid != os.getuid():
|
||||
raise RuntimeError("XDG_RUNTIME_DIR non sûr")
|
||||
else:
|
||||
# WHY: le repli est propre à l'UID et non partageable dans /tmp.
|
||||
root = Path(f"/tmp/labfy-runtime-{os.getuid()}")
|
||||
return _private_directory(root / APP_DIRECTORY)
|
||||
|
||||
|
||||
def state_directory(env=None):
|
||||
env = os.environ if env is None else env
|
||||
base = Path(env.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
|
||||
return _private_directory(base / APP_DIRECTORY)
|
||||
|
||||
|
||||
def _atomic_json(path, value):
|
||||
temporary = path.with_name(f".{path.name}-{secrets.token_hex(8)}.tmp")
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600)
|
||||
try:
|
||||
payload = (json.dumps(value, ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")) + "\n").encode()
|
||||
os.write(descriptor, payload)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _read_private_json(path):
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_uid != os.getuid():
|
||||
raise RuntimeError("Fichier d'instance absent ou non sûr")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _proc_start(pid):
|
||||
try:
|
||||
# /proc/PID/stat place le nom entre parenthèses ; le champ 22 est le 20e
|
||||
# champ après la dernière parenthèse.
|
||||
fields = Path(f"/proc/{pid}/stat").read_text(encoding="ascii").rsplit(")", 1)[1].split()
|
||||
return fields[19]
|
||||
except (OSError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _config(selection, bridge):
|
||||
mode, path = selection
|
||||
value = {"mode": mode, "path": str(path.absolute()), "bridge": str(bridge.resolve())}
|
||||
digest = hashlib.sha256(json.dumps(value, sort_keys=True,
|
||||
separators=(",", ":")).encode()).hexdigest()
|
||||
return value, digest
|
||||
|
||||
|
||||
def _health(state, timeout=0.8):
|
||||
try:
|
||||
connection = http.client.HTTPConnection("127.0.0.1", state["port"], timeout=timeout)
|
||||
connection.request("GET", "/healthz", headers={"Host": f"127.0.0.1:{state['port']}"})
|
||||
response = connection.getresponse()
|
||||
value = json.loads(response.read())
|
||||
connection.close()
|
||||
return (response.status == 200 and value.get("instance_id") == state["instance_id"] and
|
||||
value.get("config_id") == state["config_id"])
|
||||
except (OSError, ValueError, KeyError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
|
||||
def _identity_valid(state):
|
||||
return (isinstance(state, dict) and isinstance(state.get("pid"), int) and
|
||||
not isinstance(state.get("pid"), bool) and state["pid"] > 1 and
|
||||
isinstance(state.get("proc_start"), str) and
|
||||
state.get("proc_start") == _proc_start(state["pid"]) and _health(state))
|
||||
|
||||
|
||||
class InstanceFiles:
|
||||
def __init__(self):
|
||||
self.runtime = runtime_directory()
|
||||
self.state_root = state_directory()
|
||||
self.instance = self.runtime / "instance.json"
|
||||
self.config = self.state_root / "config.json"
|
||||
self.lifecycle = self.runtime / "lifecycle.lock"
|
||||
self.owner = self.runtime / "instance.lock"
|
||||
|
||||
def lifecycle_lock(self):
|
||||
descriptor = os.open(self.lifecycle, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
return descriptor
|
||||
|
||||
def clear_stale(self):
|
||||
for path in (self.instance,):
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _selection(args):
|
||||
if args.library is not None:
|
||||
return "library", args.library.absolute()
|
||||
return "workspace", args.workspace.absolute()
|
||||
|
||||
|
||||
def _handshake(fd, value):
|
||||
if fd is None:
|
||||
return
|
||||
with os.fdopen(fd, "w", encoding="utf-8", closefd=True) as output:
|
||||
output.write(json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
output.flush()
|
||||
|
||||
|
||||
def serve(args):
|
||||
files = InstanceFiles()
|
||||
owner = os.open(files.owner, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
fcntl.flock(owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
os.close(owner)
|
||||
_handshake(args.handshake_fd, {"ok": False, "error": "Une instance est déjà active"})
|
||||
return 1
|
||||
selection = _selection(args)
|
||||
config, config_id = _config(selection, args.bridge)
|
||||
instance_id = secrets.token_urlsafe(24)
|
||||
library = None
|
||||
if selection[0] == "library":
|
||||
library = WebLibrary(selection[1], args.bridge)
|
||||
workspace = library.root / ".inactive"
|
||||
workspace.mkdir(mode=0o700, exist_ok=True)
|
||||
else:
|
||||
workspace = selection[1]
|
||||
workspace.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
try:
|
||||
server = WorkspaceServer(("127.0.0.1", args.port), Handler,
|
||||
workspace=workspace, bridge=args.bridge, bootstrap="",
|
||||
library=library, instance_id=instance_id, config_id=config_id,
|
||||
automatic_session=args.automatic_session)
|
||||
except OSError as error:
|
||||
_handshake(args.handshake_fd, {"ok": False,
|
||||
"error": f"Port indisponible : {error}"})
|
||||
os.close(owner)
|
||||
return 1
|
||||
state = {"contract": "labfy.web_instance.v2", "pid": os.getpid(),
|
||||
"proc_start": _proc_start(os.getpid()), "instance_id": instance_id,
|
||||
"config_id": config_id, "port": server.server_port, "config": config,
|
||||
"automatic_session": True}
|
||||
try:
|
||||
# CONTRACT: la configuration durable ne contient jamais le secret
|
||||
# d'amorçage, qui reste exclusivement dans le runtime privé.
|
||||
_atomic_json(files.config, {"contract": "labfy.web_config.v1",
|
||||
"config_id": config_id, **config})
|
||||
_atomic_json(files.instance, state)
|
||||
answer = {"ok": True, "origin": server.origin,
|
||||
"instance_id": instance_id}
|
||||
_handshake(args.handshake_fd, answer)
|
||||
if args.handshake_fd is None:
|
||||
print(f"Labfy Web : {server.origin}/", flush=True)
|
||||
# INVARIANT: SIGTERM n'arrête que cette instance déjà authentifiée
|
||||
# par le lanceur ; shutdown s'exécute hors du gestionnaire de signal.
|
||||
def request_shutdown(_signum, _frame):
|
||||
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||
signal.signal(signal.SIGTERM, request_shutdown)
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.stop_worker()
|
||||
server.server_close()
|
||||
try:
|
||||
current = _read_private_json(files.instance)
|
||||
if current.get("instance_id") == instance_id:
|
||||
files.clear_stale()
|
||||
except (OSError, RuntimeError, json.JSONDecodeError):
|
||||
pass
|
||||
os.close(owner)
|
||||
return 0
|
||||
|
||||
|
||||
def start(args):
|
||||
files = InstanceFiles()
|
||||
lifecycle = files.lifecycle_lock()
|
||||
try:
|
||||
try:
|
||||
current = _read_private_json(files.instance)
|
||||
except (OSError, RuntimeError, json.JSONDecodeError):
|
||||
current = None
|
||||
if current is not None and _identity_valid(current):
|
||||
origin = f"http://127.0.0.1:{current['port']}/"
|
||||
if (current.get("automatic_session") is True and
|
||||
current.get("contract") == "labfy.web_instance.v2"):
|
||||
if args.open_browser:
|
||||
webbrowser.open(origin)
|
||||
print(f"Instance déjà active sur {origin}", file=sys.stderr)
|
||||
return 0
|
||||
# CONTRACT: une instance authentifiée mais d'ancien profil ne peut
|
||||
# jamais être rejointe : elle ramènerait le parcours code retiré.
|
||||
print(f"Instance incompatible sur {origin} ; remplacement contrôlé",
|
||||
file=sys.stderr)
|
||||
os.kill(current["pid"], signal.SIGTERM)
|
||||
deadline = time.monotonic() + 5
|
||||
while (time.monotonic() < deadline and
|
||||
_proc_start(current["pid"]) == current["proc_start"]):
|
||||
time.sleep(0.05)
|
||||
if _proc_start(current["pid"]) == current["proc_start"]:
|
||||
print("Arrêt de l'instance incompatible expiré", file=sys.stderr)
|
||||
return 1
|
||||
files.clear_stale()
|
||||
files.clear_stale()
|
||||
read_fd, write_fd = os.pipe()
|
||||
command = [sys.executable, str(Path(__file__).resolve()), "serve",
|
||||
"--port", str(args.port), "--bridge", str(args.bridge),
|
||||
"--handshake-fd", str(write_fd)]
|
||||
command.append("--automatic-session")
|
||||
mode, path = _selection(args)
|
||||
command.extend((f"--{mode}", str(path)))
|
||||
process = subprocess.Popen(command, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True, pass_fds=(write_fd,), close_fds=True)
|
||||
os.close(write_fd)
|
||||
ready, _, _ = select.select((read_fd,), (), (), 10)
|
||||
if not ready:
|
||||
process.terminate()
|
||||
process.wait(timeout=3)
|
||||
os.close(read_fd)
|
||||
print("Démarrage expiré", file=sys.stderr)
|
||||
return 1
|
||||
with os.fdopen(read_fd, "r", encoding="utf-8") as pipe:
|
||||
line = pipe.readline()
|
||||
if not line:
|
||||
print("Le serveur n'a pas publié son état", file=sys.stderr)
|
||||
return 1
|
||||
response = json.loads(line)
|
||||
if not response.get("ok"):
|
||||
print(response.get("error", "Démarrage refusé"), file=sys.stderr)
|
||||
return 1
|
||||
print(f"Labfy Web : {response['origin']}/")
|
||||
if args.open_browser:
|
||||
webbrowser.open(f"{response['origin']}/")
|
||||
return 0
|
||||
finally:
|
||||
os.close(lifecycle)
|
||||
|
||||
|
||||
def status(_args):
|
||||
files = InstanceFiles()
|
||||
try:
|
||||
state = _read_private_json(files.instance)
|
||||
except (OSError, RuntimeError, json.JSONDecodeError):
|
||||
print("arrêté")
|
||||
return 1
|
||||
if not _identity_valid(state):
|
||||
print("état périmé")
|
||||
return 1
|
||||
print(f"actif pid={state['pid']} origin=http://127.0.0.1:{state['port']}/")
|
||||
return 0
|
||||
|
||||
|
||||
def stop(_args):
|
||||
files = InstanceFiles()
|
||||
lifecycle = files.lifecycle_lock()
|
||||
try:
|
||||
try:
|
||||
state = _read_private_json(files.instance)
|
||||
except (OSError, RuntimeError, json.JSONDecodeError):
|
||||
print("Aucune instance active", file=sys.stderr)
|
||||
return 1
|
||||
# CONTRACT: ne jamais signaler un PID sur la seule foi d'un fichier d'état.
|
||||
if not _identity_valid(state):
|
||||
print("Instance périmée ou identité non confirmée", file=sys.stderr)
|
||||
return 1
|
||||
os.kill(state["pid"], signal.SIGTERM)
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline and _proc_start(state["pid"]) == state["proc_start"]:
|
||||
time.sleep(0.05)
|
||||
if _proc_start(state["pid"]) == state["proc_start"]:
|
||||
print("Arrêt expiré ; aucun signal forcé envoyé", file=sys.stderr)
|
||||
return 1
|
||||
files.clear_stale()
|
||||
print("arrêté")
|
||||
return 0
|
||||
finally:
|
||||
os.close(lifecycle)
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(description="Poste Web local Labfy")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
repository = Path(__file__).resolve().parents[2]
|
||||
for name, function in (("serve", serve), ("start", start)):
|
||||
command = subparsers.add_parser(name)
|
||||
targets = command.add_mutually_exclusive_group(required=True)
|
||||
targets.add_argument("--library", type=Path)
|
||||
targets.add_argument("--workspace", type=Path)
|
||||
command.add_argument("--port", type=valid_port, default=DEFAULT_PORT)
|
||||
command.add_argument("--bridge", type=Path, default=repository / "tools/local-jobs")
|
||||
command.add_argument("--automatic-session", action="store_true",
|
||||
help="établit une session locale sans code visible")
|
||||
if name == "start":
|
||||
command.add_argument("--open-browser", action="store_true",
|
||||
help="ouvre l'interface locale après vérification")
|
||||
if name == "serve":
|
||||
command.add_argument("--handshake-fd", type=int, help=argparse.SUPPRESS)
|
||||
command.set_defaults(function=function)
|
||||
for name, function in (("status", status), ("stop", stop)):
|
||||
command = subparsers.add_parser(name)
|
||||
command.set_defaults(function=function)
|
||||
help_command = subparsers.add_parser("help")
|
||||
help_command.set_defaults(function=lambda _args: (parser.print_help() or 0))
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return args.function(args)
|
||||
except (RuntimeError, ValueError, OSError, json.JSONDecodeError) as error:
|
||||
print(f"web-app: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
296
prototypes/web-graph/web_library.py
Normal file
296
prototypes/web-graph/web_library.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Bibliothèque locale privée d'espaces Labfy explicitement enregistrés."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
MAX_ENTRIES = 256
|
||||
REGISTRY_CONTRACT = "labfy.web_library.registry.v1"
|
||||
CREATION_INTENT_CONTRACT = "labfy.web_library.creation_intent.v1"
|
||||
|
||||
|
||||
class LibraryError(ValueError):
|
||||
"""Refus d'une opération de bibliothèque sans effet implicite sur le disque."""
|
||||
|
||||
|
||||
class WebLibrary:
|
||||
def __init__(self, root: Path, bridge: Path):
|
||||
self.root = Path(root).absolute()
|
||||
self.bridge = Path(bridge).resolve()
|
||||
self._prepare_private_root()
|
||||
self.workspaces = self.root / "workspaces"
|
||||
self.workspaces.mkdir(mode=0o700, exist_ok=True)
|
||||
self._assert_plain_directory(self.workspaces)
|
||||
self.registry_path = self.root / "registry.json"
|
||||
self.creation_intent_path = self.root / "creation-intent.json"
|
||||
self.lock_path = self.root / "registry.lock"
|
||||
|
||||
def _prepare_private_root(self):
|
||||
for component in reversed((self.root, *self.root.parents)):
|
||||
if component.exists() and component.is_symlink():
|
||||
raise LibraryError(
|
||||
f"Lien symbolique interdit dans la bibliothèque : {component}")
|
||||
if self.root.exists():
|
||||
self._assert_plain_directory(self.root)
|
||||
else:
|
||||
self.root.mkdir(mode=0o700, parents=True)
|
||||
os.chmod(self.root, 0o700)
|
||||
|
||||
@staticmethod
|
||||
def _assert_plain_directory(path: Path):
|
||||
# INVARIANT: aucun composant géré ne peut rediriger la bibliothèque.
|
||||
if path.is_symlink() or not path.is_dir():
|
||||
raise LibraryError(f"Répertoire de bibliothèque invalide : {path}")
|
||||
|
||||
def _locked(self):
|
||||
descriptor = os.open(self.lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
os.fchmod(descriptor, 0o600)
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
return descriptor
|
||||
|
||||
def _empty_registry(self):
|
||||
return {"contract": REGISTRY_CONTRACT, "generation": 0, "entries": []}
|
||||
|
||||
def _read_registry(self):
|
||||
if not self.registry_path.exists():
|
||||
return self._empty_registry()
|
||||
if self.registry_path.is_symlink() or not self.registry_path.is_file():
|
||||
raise LibraryError("Registre de bibliothèque invalide")
|
||||
try:
|
||||
value = json.loads(self.registry_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise LibraryError("Registre de bibliothèque illisible") from error
|
||||
if (not isinstance(value, dict) or value.get("contract") != REGISTRY_CONTRACT or
|
||||
not isinstance(value.get("generation"), int) or
|
||||
not isinstance(value.get("entries"), list) or
|
||||
len(value["entries"]) > MAX_ENTRIES):
|
||||
raise LibraryError("Registre de bibliothèque mal formé")
|
||||
seen = set()
|
||||
for entry in value["entries"]:
|
||||
if not self._valid_entry(entry) or entry["workspace_id"] in seen:
|
||||
raise LibraryError("Entrée de bibliothèque invalide")
|
||||
seen.add(entry["workspace_id"])
|
||||
self._workspace_path(entry["workspace_id"], require_exists=True)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _valid_entry(entry):
|
||||
if not isinstance(entry, dict) or set(entry) != {
|
||||
"workspace_id", "title", "idempotency_key", "request_hash"}:
|
||||
return False
|
||||
try:
|
||||
uuid.UUID(entry["workspace_id"])
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
return (isinstance(entry["title"], str) and 0 < len(entry["title"]) <= 200 and
|
||||
isinstance(entry["idempotency_key"], str) and
|
||||
0 < len(entry["idempotency_key"]) <= 128 and
|
||||
isinstance(entry["request_hash"], str) and
|
||||
len(entry["request_hash"]) == 64)
|
||||
|
||||
def _write_registry(self, value):
|
||||
self._write_json(self.registry_path, value, "registry")
|
||||
|
||||
def _write_json(self, destination, value, temporary_prefix):
|
||||
temporary = self.root / f".{temporary_prefix}-{uuid.uuid4().hex}.tmp"
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600)
|
||||
try:
|
||||
data = (json.dumps(value, ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")) + "\n").encode()
|
||||
os.write(descriptor, data)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
temporary.replace(destination)
|
||||
self._sync_root()
|
||||
|
||||
def _sync_root(self):
|
||||
directory = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
def _read_creation_intent(self):
|
||||
if not self.creation_intent_path.exists():
|
||||
return None
|
||||
if (self.creation_intent_path.is_symlink() or
|
||||
not self.creation_intent_path.is_file()):
|
||||
raise LibraryError("Intention de création invalide")
|
||||
try:
|
||||
value = json.loads(self.creation_intent_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise LibraryError("Intention de création illisible") from error
|
||||
if (not isinstance(value, dict) or
|
||||
value.get("contract") != CREATION_INTENT_CONTRACT or
|
||||
not self._valid_entry({key: item for key, item in value.items()
|
||||
if key != "contract"}) or
|
||||
set(value) != {"contract", "workspace_id", "title",
|
||||
"idempotency_key", "request_hash"}):
|
||||
raise LibraryError("Intention de création mal formée")
|
||||
return {key: item for key, item in value.items() if key != "contract"}
|
||||
|
||||
def _write_creation_intent(self, entry):
|
||||
self._write_json(self.creation_intent_path,
|
||||
{"contract": CREATION_INTENT_CONTRACT, **entry},
|
||||
"creation-intent")
|
||||
|
||||
def _clear_creation_intent(self):
|
||||
try:
|
||||
self.creation_intent_path.unlink()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
self._sync_root()
|
||||
|
||||
def _workspace_path(self, workspace_id, *, require_exists):
|
||||
try:
|
||||
normalized = str(uuid.UUID(workspace_id))
|
||||
except (ValueError, TypeError, AttributeError) as error:
|
||||
raise LibraryError("Identifiant d'enquête invalide") from error
|
||||
if normalized != workspace_id:
|
||||
raise LibraryError("Identifiant d'enquête non canonique")
|
||||
candidate = self.workspaces / normalized
|
||||
if require_exists:
|
||||
self._assert_plain_directory(candidate)
|
||||
# CONTRACT: un espace enregistré est un enfant direct, jamais un chemin exploré.
|
||||
if candidate.resolve().parent != self.workspaces.resolve():
|
||||
raise LibraryError("Espace hors bibliothèque")
|
||||
return candidate
|
||||
|
||||
@staticmethod
|
||||
def _public_entry(entry):
|
||||
return {"workspace_id": entry["workspace_id"], "title": entry["title"],
|
||||
"state": "READY"}
|
||||
|
||||
def snapshot(self, active_workspace_id=None):
|
||||
descriptor = self._locked()
|
||||
try:
|
||||
registry = self._read_registry()
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
entries = sorted((self._public_entry(item) for item in registry["entries"]),
|
||||
key=lambda item: (item["title"].casefold(), item["workspace_id"]))
|
||||
return {"contract": "labfy.web_library.v1", "entries": entries,
|
||||
"active_workspace_id": active_workspace_id,
|
||||
"generation": registry["generation"]}
|
||||
|
||||
def create(self, title, idempotency_key):
|
||||
if (not isinstance(title, str) or not title.strip() or len(title) > 200 or
|
||||
any(ord(character) < 32 for character in title)):
|
||||
raise TypeError("Titre d'enquête invalide")
|
||||
if (not isinstance(idempotency_key, str) or not idempotency_key or
|
||||
len(idempotency_key) > 128):
|
||||
raise TypeError("Clé d'idempotence invalide")
|
||||
title = title.strip()
|
||||
request_hash = hashlib.sha256(title.encode("utf-8")).hexdigest()
|
||||
descriptor = self._locked()
|
||||
try:
|
||||
registry = self._read_registry()
|
||||
intent = self._read_creation_intent()
|
||||
if intent is not None:
|
||||
published = next((item for item in registry["entries"]
|
||||
if item["workspace_id"] == intent["workspace_id"]), None)
|
||||
if published is not None:
|
||||
if published != intent:
|
||||
raise LibraryError("Intention de création incohérente avec le registre")
|
||||
self._clear_creation_intent()
|
||||
intent = None
|
||||
replay = next((item for item in registry["entries"]
|
||||
if item["idempotency_key"] == idempotency_key), None)
|
||||
if replay is not None:
|
||||
if replay["request_hash"] != request_hash:
|
||||
raise LibraryError("Clé d'idempotence déjà liée à un autre titre")
|
||||
return self._response(replay, registry["generation"], True)
|
||||
if len(registry["entries"]) >= MAX_ENTRIES:
|
||||
raise LibraryError("Bibliothèque limitée à 256 enquêtes")
|
||||
if intent is None:
|
||||
entry = {"workspace_id": str(uuid.uuid4()), "title": title,
|
||||
"idempotency_key": idempotency_key,
|
||||
"request_hash": request_hash}
|
||||
self._write_creation_intent(entry)
|
||||
workspace = self._workspace_path(entry["workspace_id"],
|
||||
require_exists=False)
|
||||
workspace.mkdir(mode=0o700)
|
||||
workspace_ready = False
|
||||
else:
|
||||
if (intent["idempotency_key"] != idempotency_key or
|
||||
intent["request_hash"] != request_hash):
|
||||
raise LibraryError(
|
||||
"Une création interrompue doit être reprise avec sa clé d'idempotence")
|
||||
entry = intent
|
||||
workspace = self._workspace_path(entry["workspace_id"],
|
||||
require_exists=False)
|
||||
if not workspace.exists():
|
||||
workspace.mkdir(mode=0o700)
|
||||
workspace_ready = False
|
||||
else:
|
||||
self._workspace_path(entry["workspace_id"], require_exists=True)
|
||||
try:
|
||||
self._validate_created_workspace(workspace)
|
||||
workspace_ready = True
|
||||
except LibraryError:
|
||||
workspace_ready = False
|
||||
if not workspace_ready:
|
||||
# CONTRACT: le bridge C existant reste l'unique créateur de l'enquête.
|
||||
result = subprocess.run(
|
||||
[str(self.bridge), "create-workspace", "--workspace", str(workspace),
|
||||
"--title", title], cwd=self.bridge.parents[1], text=True,
|
||||
capture_output=True, timeout=30, check=False)
|
||||
if result.returncode != 0:
|
||||
message = (result.stderr.strip().splitlines()[-1]
|
||||
if result.stderr.strip() else "Création C refusée")
|
||||
raise LibraryError(message.removeprefix("local-jobs: "))
|
||||
self._validate_created_workspace(workspace)
|
||||
registry["entries"].append(entry)
|
||||
registry["generation"] += 1
|
||||
self._write_registry(registry)
|
||||
self._clear_creation_intent()
|
||||
return self._response(entry, registry["generation"], False)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
@staticmethod
|
||||
def _validate_created_workspace(workspace):
|
||||
database = workspace / "Enquete.sqlite"
|
||||
manifest = workspace / ".labfy" / "runtime" / "workspace.json"
|
||||
if (database.is_symlink() or not database.is_file() or manifest.is_symlink() or
|
||||
not manifest.is_file()):
|
||||
raise LibraryError("Le bridge C n'a pas publié un espace valide")
|
||||
try:
|
||||
value = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise LibraryError("Manifeste d'enquête invalide") from error
|
||||
if value.get("contract") != "labfy.local_workspace.v1":
|
||||
raise LibraryError("Contrat de manifeste d'enquête inattendu")
|
||||
|
||||
@staticmethod
|
||||
def _response(entry, generation, replayed):
|
||||
return {"contract": "labfy.web_library.workspace.v1",
|
||||
**WebLibrary._public_entry(entry), "generation": generation,
|
||||
"replayed": replayed}
|
||||
|
||||
def open(self, workspace_id, expected_generation):
|
||||
if not isinstance(expected_generation, int) or isinstance(expected_generation, bool):
|
||||
raise TypeError("Génération attendue invalide")
|
||||
descriptor = self._locked()
|
||||
try:
|
||||
registry = self._read_registry()
|
||||
if expected_generation != registry["generation"]:
|
||||
raise LibraryError("Projection de bibliothèque périmée")
|
||||
entry = next((item for item in registry["entries"]
|
||||
if item["workspace_id"] == workspace_id), None)
|
||||
if entry is None:
|
||||
raise LibraryError("Enquête absente de la bibliothèque")
|
||||
workspace = self._workspace_path(workspace_id, require_exists=True)
|
||||
self._validate_created_workspace(workspace)
|
||||
return workspace, entry, registry["generation"]
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
|
@ -27,6 +27,7 @@ from report_bundle import publish as publish_report, verify as verify_report, Re
|
|||
ROOT = Path(__file__).resolve().parent
|
||||
PUBLIC = ROOT / "public"
|
||||
MAX_BODY = 4096
|
||||
SESSION_TTL_SECONDS = 3600
|
||||
MAX_EXPORT = 1024 * 1024
|
||||
MAX_PLAN_ITEMS = 8
|
||||
MAX_UPLOAD_BYTES = 4 * 1024 * 1024
|
||||
|
|
@ -47,16 +48,38 @@ class WorkspaceServer(ThreadingHTTPServer):
|
|||
block_on_close = False
|
||||
|
||||
def __init__(self, address, handler, *, workspace: Path, bridge: Path,
|
||||
bootstrap: str):
|
||||
bootstrap: str = "", library=None, instance_id=None, config_id=None,
|
||||
session_ttl_seconds=SESSION_TTL_SECONDS,
|
||||
research_fixture_authority=None, automatic_session=False):
|
||||
if (not isinstance(session_ttl_seconds, int) or
|
||||
isinstance(session_ttl_seconds, bool) or session_ttl_seconds <= 0):
|
||||
raise ValueError("Durée de session invalide")
|
||||
super().__init__(address, handler)
|
||||
self.workspace = workspace.resolve()
|
||||
self.bridge = bridge.resolve()
|
||||
self.authority = f"127.0.0.1:{self.server_port}"
|
||||
self.origin = f"http://{self.authority}"
|
||||
self.bootstrap = bootstrap
|
||||
# CONTRACT: le poste Web ne possède plus de parcours code. Toute
|
||||
# première navigation loopback établit seulement un cookie HttpOnly ;
|
||||
# Host, Origin et CSRF restent exigés pour chaque mutation.
|
||||
self.automatic_session = True
|
||||
self.library = library
|
||||
self.active_workspace_id = None
|
||||
self.library_generation = (library.snapshot(None)["generation"]
|
||||
if library is not None else 0)
|
||||
self.instance_id = instance_id or secrets.token_urlsafe(18)
|
||||
self.config_id = config_id or hashlib.sha256(
|
||||
str(self.workspace).encode()).hexdigest()
|
||||
self.cookie_name = f"labfy_session_{self.instance_id[:12]}"
|
||||
self.session = secrets.token_urlsafe(32)
|
||||
self.csrf = secrets.token_urlsafe(32)
|
||||
self.session_deadline = time.monotonic() + 3600
|
||||
self.session_lock = threading.RLock()
|
||||
# CONTRACT: la production conserve 3600 s. L'injection constructeur
|
||||
# permet seulement aux tests d'observer une vraie expiration sans route
|
||||
# d'administration ni manipulation de l'horloge du processus.
|
||||
self.session_ttl_seconds = session_ttl_seconds
|
||||
# La durée commence à la connexion, jamais au démarrage du serveur.
|
||||
self.session_deadline = 0.0
|
||||
self.worker = None
|
||||
self.worker_supervisor = None
|
||||
self.worker_supervisor_stop = threading.Event()
|
||||
|
|
@ -70,6 +93,49 @@ class WorkspaceServer(ThreadingHTTPServer):
|
|||
self.active_uploads = 0
|
||||
self.reserved_upload_bytes = 0
|
||||
self.upload_mutexes = {}
|
||||
# CONTRACT: cette autorité de laboratoire n'est ni une donnée UI ni un
|
||||
# réglage persistant. Le bridge C applique encore sa propre whitelist.
|
||||
self.research_fixture_authority = research_fixture_authority
|
||||
|
||||
def library_snapshot(self):
|
||||
if self.library is None:
|
||||
raise ValueError("Mode bibliothèque inactif")
|
||||
value = self.library.snapshot(self.active_workspace_id)
|
||||
self.library_generation = value["generation"]
|
||||
return value
|
||||
|
||||
def create_library_workspace(self, title, idempotency_key):
|
||||
if self.library is None:
|
||||
raise ValueError("Mode bibliothèque inactif")
|
||||
value = self.library.create(title, idempotency_key)
|
||||
self.library_generation = value["generation"]
|
||||
return value
|
||||
|
||||
def open_library_workspace(self, workspace_id, expected_generation):
|
||||
if self.library is None:
|
||||
raise ValueError("Mode bibliothèque inactif")
|
||||
with self.lock:
|
||||
if (self.active_workspace_id is not None and
|
||||
self.active_workspace_id != workspace_id):
|
||||
raise ValueError("Une autre enquête est déjà active")
|
||||
workspace, entry, generation = self.library.open(
|
||||
workspace_id, expected_generation)
|
||||
if self.active_workspace_id is None:
|
||||
previous = self.workspace
|
||||
self.workspace = workspace
|
||||
try:
|
||||
# CONTRACT: l'ouverture valide aussi le pipeline C existant ;
|
||||
# le serveur Web ne reconstruit aucune projection lui-même.
|
||||
self.bridge_call(["export"])
|
||||
self.context()
|
||||
except Exception:
|
||||
self.workspace = previous
|
||||
raise
|
||||
self.active_workspace_id = workspace_id
|
||||
self.library_generation = generation
|
||||
return {"contract": "labfy.web_library.open.v1",
|
||||
"workspace_id": workspace_id, "title": entry["title"],
|
||||
"state": "READY", "generation": generation}
|
||||
|
||||
def upload_mutex(self, upload_id):
|
||||
"""Return the process-local owner lock for one persisted upload."""
|
||||
|
|
@ -101,7 +167,12 @@ class WorkspaceServer(ThreadingHTTPServer):
|
|||
def bridge_call(self, arguments, timeout=8):
|
||||
command = [str(self.bridge), arguments[0], "--workspace",
|
||||
str(self.workspace), *arguments[1:]]
|
||||
environment = os.environ.copy()
|
||||
if self.research_fixture_authority is not None:
|
||||
environment["LABFY_RESEARCH_FIXTURE_AUTHORITY"] = \
|
||||
self.research_fixture_authority
|
||||
result = subprocess.run(command, cwd=self.bridge.parents[1], text=True,
|
||||
env=environment,
|
||||
capture_output=True, timeout=timeout, check=False)
|
||||
if result.returncode != 0:
|
||||
message = result.stderr.strip().splitlines()[-1] if result.stderr.strip() else "Commande C refusée"
|
||||
|
|
@ -229,18 +300,20 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return self.headers.get("Host") == self.server.authority
|
||||
|
||||
def _authenticated(self):
|
||||
if time.monotonic() > self.server.session_deadline:
|
||||
return False
|
||||
cookie = SimpleCookie(self.headers.get("Cookie", ""))
|
||||
value = cookie.get("labfy_session")
|
||||
return value is not None and hmac.compare_digest(value.value,
|
||||
self.server.session)
|
||||
with self.server.session_lock:
|
||||
if time.monotonic() >= self.server.session_deadline:
|
||||
return False
|
||||
cookie = SimpleCookie(self.headers.get("Cookie", ""))
|
||||
value = cookie.get(self.server.cookie_name)
|
||||
return value is not None and hmac.compare_digest(
|
||||
value.value, self.server.session)
|
||||
|
||||
def _mutation_allowed(self):
|
||||
return (self._authenticated() and
|
||||
self.headers.get("Origin") == self.server.origin and
|
||||
hmac.compare_digest(self.headers.get("X-Labfy-CSRF", ""),
|
||||
self.server.csrf))
|
||||
with self.server.session_lock:
|
||||
return (self._authenticated() and
|
||||
self.headers.get("Origin") == self.server.origin and
|
||||
hmac.compare_digest(self.headers.get("X-Labfy-CSRF", ""),
|
||||
self.server.csrf))
|
||||
|
||||
def _content_length(self, maximum, *, expected=None):
|
||||
"""Validate HTTP/1.1 framing before a route consumes its body."""
|
||||
|
|
@ -317,6 +390,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||
raise TypeError("Champ JSON vide ou trop long")
|
||||
return value
|
||||
|
||||
def _library_open_body(self):
|
||||
value = self._json_body()
|
||||
if (not isinstance(value, dict) or
|
||||
set(value) != {"workspace_id", "expected_generation"} or
|
||||
not isinstance(value["workspace_id"], str) or
|
||||
not isinstance(value["expected_generation"], int) or
|
||||
isinstance(value["expected_generation"], bool)):
|
||||
raise TypeError("Demande d'ouverture de bibliothèque invalide")
|
||||
return value
|
||||
|
||||
def _plan_body(self):
|
||||
value = self._json_body()
|
||||
if not isinstance(value, dict) or set(value) != {
|
||||
|
|
@ -335,6 +418,69 @@ class Handler(BaseHTTPRequestHandler):
|
|||
raise TypeError("Champ de plan invalide")
|
||||
return value
|
||||
|
||||
def _research_body(self, kind):
|
||||
value = self._json_body()
|
||||
shapes = {
|
||||
"prepare": {"selection_ids", "question", "exclusions",
|
||||
"idempotency_key"},
|
||||
"grant": {"plan_id", "input_revision", "selected_action_ids",
|
||||
"decisions", "exclusions", "idempotency_key"},
|
||||
"campaign": {"grant_id", "input_revision", "action_ids",
|
||||
"idempotency_key"},
|
||||
"revoke": {"grant_id"},
|
||||
}
|
||||
if not isinstance(value, dict) or set(value) != shapes[kind]:
|
||||
raise TypeError("Champs de recherche inattendus")
|
||||
if kind == "grant":
|
||||
decisions = value["decisions"]
|
||||
if (not isinstance(decisions, list) or not decisions or
|
||||
len(decisions) > 8 or any(not isinstance(item, dict) or
|
||||
set(item) != {"action_id", "decision"}
|
||||
for item in decisions)):
|
||||
raise TypeError("Décisions de recherche invalides")
|
||||
decision_ids = []
|
||||
for item in decisions:
|
||||
if (not isinstance(item["action_id"], str) or
|
||||
not isinstance(item["decision"], str) or
|
||||
item["decision"] not in {"AUTHORIZE", "DEFER", "REFUSE"}):
|
||||
raise TypeError("Décision de recherche invalide")
|
||||
try:
|
||||
uuid.UUID(item["action_id"])
|
||||
except ValueError as error:
|
||||
raise TypeError("Identifiant de décision invalide") from error
|
||||
decision_ids.append(item["action_id"])
|
||||
if len(set(decision_ids)) != len(decision_ids):
|
||||
raise TypeError("Décision de recherche dupliquée")
|
||||
authorized = {item["action_id"] for item in decisions
|
||||
if item["decision"] == "AUTHORIZE"}
|
||||
if not set(value["selected_action_ids"]) <= authorized:
|
||||
raise TypeError("Action sélectionnée sans autorisation")
|
||||
list_names = shapes[kind] & {
|
||||
"selection_ids", "exclusions", "selected_action_ids", "action_ids"}
|
||||
for name in list_names:
|
||||
items = value[name]
|
||||
if (not isinstance(items, list) or len(items) > 8 or
|
||||
(name != "exclusions" and not items) or
|
||||
len(set(items)) != len(items) or
|
||||
any(not isinstance(item, str) or not item or
|
||||
len(item) > 160 or "," in item for item in items)):
|
||||
raise TypeError("Liste de recherche invalide")
|
||||
for name, item in value.items():
|
||||
if name in list_names or name == "decisions":
|
||||
continue
|
||||
if name == "input_revision":
|
||||
if not isinstance(item, int) or isinstance(item, bool) or item < 0:
|
||||
raise TypeError("Révision de recherche invalide")
|
||||
elif (not isinstance(item, str) or not item or len(item) > 512 or
|
||||
"\x00" in item):
|
||||
raise TypeError("Champ de recherche invalide")
|
||||
for name in shapes[kind] & {"plan_id", "grant_id", "idempotency_key"}:
|
||||
try:
|
||||
uuid.UUID(value[name])
|
||||
except ValueError as error:
|
||||
raise TypeError("Identifiant de recherche invalide") from error
|
||||
return value
|
||||
|
||||
def _report_preview_body(self):
|
||||
value = self._json_body()
|
||||
if not isinstance(value, dict) or set(value) != {"object_ids", "title",
|
||||
|
|
@ -412,11 +558,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||
path = parsed.path
|
||||
if path == "/healthz":
|
||||
self._json(HTTPStatus.OK, {"contract": "labfy.workspace.health.v1",
|
||||
"status": "ready"})
|
||||
"status": "ready", "instance_id": self.server.instance_id,
|
||||
"config_id": self.server.config_id})
|
||||
return
|
||||
if path == "/favicon.ico":
|
||||
# CONTRACT: les navigateurs demandent implicitement cette icône ;
|
||||
# l'absence d'asset ne doit pas transformer un parcours sain en 404.
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self._headers()
|
||||
self.end_headers()
|
||||
return
|
||||
if path in {"/login.html", "/login.js"}:
|
||||
self._error(HTTPStatus.NOT_FOUND, "route_unknown", "Route inconnue")
|
||||
return
|
||||
if not self._authenticated():
|
||||
if path in {"/", "/login.js", "/styles.css"}:
|
||||
self._serve_static("/login.html" if path == "/" else path)
|
||||
if path == "/":
|
||||
self._open_automatic_session()
|
||||
return
|
||||
self._error(HTTPStatus.UNAUTHORIZED, "session_required",
|
||||
"Session locale requise")
|
||||
|
|
@ -430,7 +587,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||
"workspace_state": "READY" if context else "EMPTY",
|
||||
"investigation_id": context.get("investigation_id") if context else None,
|
||||
"title": context.get("title", "SPECIMEN") if context else None,
|
||||
"mode": context.get("mode", "specimen") if context else "local_experimental"})
|
||||
"mode": context.get("mode", "specimen") if context else "local_experimental",
|
||||
"library_mode": self.server.library is not None,
|
||||
"active_workspace_id": self.server.active_workspace_id,
|
||||
"generation": self.server.library_generation})
|
||||
elif path == "/api/v1/library":
|
||||
try:
|
||||
self._json(HTTPStatus.OK, self.server.library_snapshot())
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
self._error(HTTPStatus.CONFLICT, "library_unavailable", str(error))
|
||||
elif path.startswith("/api/v1/evidence/"):
|
||||
parts = path.split("/")
|
||||
if len(parts) != 6 or parts[5] not in {"preview", "observations"}:
|
||||
|
|
@ -467,6 +632,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||
elif path == "/api/v1/planner":
|
||||
self._serve_export("planner-snapshot.json",
|
||||
"labfy.local_planner.snapshot.v1")
|
||||
elif path == "/api/v1/research":
|
||||
try:
|
||||
value = json.loads(self.server.bridge_call(
|
||||
["research-snapshot-json"]))
|
||||
self._json(HTTPStatus.OK, value)
|
||||
except (ValueError, json.JSONDecodeError,
|
||||
subprocess.TimeoutExpired) as error:
|
||||
self._error(HTTPStatus.CONFLICT, "research_unavailable", str(error))
|
||||
elif path.startswith("/api/v1/reports/"):
|
||||
parts=path.split("/")
|
||||
report_id=parts[4] if len(parts)>4 else ""
|
||||
|
|
@ -504,7 +677,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/api/v1/session":
|
||||
self._open_session()
|
||||
self._error(HTTPStatus.NOT_FOUND, "route_unknown", "Route inconnue")
|
||||
return
|
||||
if not self._mutation_allowed():
|
||||
self._error(HTTPStatus.FORBIDDEN, "mutation_rejected",
|
||||
|
|
@ -518,7 +691,21 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return
|
||||
self.server.last_command = now
|
||||
try:
|
||||
if path == "/api/v1/library/workspaces":
|
||||
value = self._body({"title", "idempotency_key"})
|
||||
response = self.server.create_library_workspace(
|
||||
value["title"], value["idempotency_key"])
|
||||
self._json(HTTPStatus.OK if response["replayed"] else HTTPStatus.CREATED,
|
||||
response)
|
||||
return
|
||||
if path == "/api/v1/library/open":
|
||||
value = self._library_open_body()
|
||||
self._json(HTTPStatus.OK, self.server.open_library_workspace(
|
||||
value["workspace_id"], value["expected_generation"]))
|
||||
return
|
||||
if path == "/api/v1/workspace":
|
||||
if self.server.library is not None:
|
||||
raise ValueError("Utiliser la création explicite de la bibliothèque")
|
||||
value = self._body({"title"})
|
||||
if self.server.context() is not None:
|
||||
raise ValueError("L’espace est déjà créé")
|
||||
|
|
@ -615,6 +802,45 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self.server.start_worker()
|
||||
self._json(HTTPStatus.ACCEPTED, json.loads(output))
|
||||
return
|
||||
if path == "/api/v1/research/prepare":
|
||||
value = self._research_body("prepare")
|
||||
graph = json.loads((self.server.workspace /
|
||||
"core-snapshot.json").read_text())
|
||||
revision = graph.get("revision")
|
||||
if (not isinstance(revision, int) or isinstance(revision, bool)
|
||||
or revision < 0):
|
||||
raise ValueError("Révision du snapshot cœur invalide")
|
||||
output = self.server.bridge_call(["research-prepare-json",
|
||||
"--selection", ",".join(value["selection_ids"]),
|
||||
"--question", value["question"], "--exclusions",
|
||||
",".join(value["exclusions"]), "--revision",
|
||||
str(revision), "--key",
|
||||
value["idempotency_key"]], timeout=15)
|
||||
self._json(HTTPStatus.OK, json.loads(output)); return
|
||||
if path == "/api/v1/research/grants":
|
||||
value = self._research_body("grant")
|
||||
output = self.server.bridge_call(["research-grant-json",
|
||||
"--plan", value["plan_id"], "--revision",
|
||||
str(value["input_revision"]), "--actions",
|
||||
",".join(value["selected_action_ids"]), "--decisions",
|
||||
",".join(f'{item["action_id"]}={item["decision"]}'
|
||||
for item in value["decisions"]), "--exclusions",
|
||||
",".join(value["exclusions"]), "--key",
|
||||
value["idempotency_key"]], timeout=15)
|
||||
self._json(HTTPStatus.OK, json.loads(output)); return
|
||||
if path == "/api/v1/research/campaigns":
|
||||
value = self._research_body("campaign")
|
||||
output = self.server.bridge_call(["research-campaign-json",
|
||||
"--grant", value["grant_id"], "--revision",
|
||||
str(value["input_revision"]), "--actions",
|
||||
",".join(value["action_ids"]), "--key",
|
||||
value["idempotency_key"]], timeout=20)
|
||||
self._json(HTTPStatus.OK, json.loads(output)); return
|
||||
if path == "/api/v1/research/revoke":
|
||||
value = self._research_body("revoke")
|
||||
output = self.server.bridge_call(["research-revoke-json",
|
||||
"--grant", value["grant_id"]])
|
||||
self._json(HTTPStatus.OK, json.loads(output)); return
|
||||
if path == "/api/v1/reports/preview":
|
||||
value=self._report_preview_body();generated=time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime())
|
||||
output=self.server.bridge_call(["prepare-report-json","--objects",",".join(value["object_ids"]),"--title",value["title"],"--comment",value["comment"],"--generated-at",generated,"--sections",",".join(value["sections"])],timeout=15)
|
||||
|
|
@ -899,22 +1125,23 @@ class Handler(BaseHTTPRequestHandler):
|
|||
except FileNotFoundError:pass
|
||||
self._json(HTTPStatus.OK,{"contract":"labfy.local_upload.v1","state":"CANCELLED"})
|
||||
|
||||
def _open_session(self):
|
||||
if self.headers.get("Origin") != self.server.origin:
|
||||
self._error(HTTPStatus.FORBIDDEN, "origin_rejected", "Origin exact obligatoire")
|
||||
return
|
||||
try:
|
||||
value = self._body({"bootstrap_code"})
|
||||
except (TypeError, OverflowError) as error:
|
||||
self._error(HTTPStatus.BAD_REQUEST, "invalid_request", str(error))
|
||||
return
|
||||
if not hmac.compare_digest(value["bootstrap_code"], self.server.bootstrap):
|
||||
self._error(HTTPStatus.FORBIDDEN, "bootstrap_rejected", "Code éphémère invalide")
|
||||
return
|
||||
cookie = (f"labfy_session={self.server.session}; HttpOnly; SameSite=Strict; "
|
||||
"Path=/; Max-Age=3600")
|
||||
self._json(HTTPStatus.OK, {"contract": "labfy.workspace.session.v1",
|
||||
"authenticated": True}, cookie=cookie)
|
||||
def _open_automatic_session(self):
|
||||
with self.server.session_lock:
|
||||
self.server.session = secrets.token_urlsafe(32)
|
||||
self.server.csrf = secrets.token_urlsafe(32)
|
||||
self.server.session_deadline = (time.monotonic() +
|
||||
self.server.session_ttl_seconds)
|
||||
cookie = (f"{self.server.cookie_name}={self.server.session}; "
|
||||
"HttpOnly; SameSite=Strict; "
|
||||
f"Path=/; Max-Age={self.server.session_ttl_seconds}")
|
||||
# INVARIANT: aucun secret n'est inclus dans l'URL ou dans le document;
|
||||
# l'application récupère seulement le CSRF depuis la même origine.
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self._headers()
|
||||
self.send_header("Set-Cookie", cookie)
|
||||
self.send_header("Location", "/")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def _serve_export(self, name, contract):
|
||||
path = self.server.workspace / name
|
||||
|
|
@ -961,14 +1188,17 @@ def main():
|
|||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--bridge", type=Path, required=True)
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
def port(value):
|
||||
parsed = int(value)
|
||||
if not 0 <= parsed <= 65535:
|
||||
raise argparse.ArgumentTypeError("port hors limites")
|
||||
return parsed
|
||||
parser.add_argument("--port", type=port, default=8081)
|
||||
args = parser.parse_args()
|
||||
bootstrap = secrets.token_urlsafe(12)
|
||||
server = WorkspaceServer(("127.0.0.1", args.port), Handler,
|
||||
workspace=args.workspace, bridge=args.bridge,
|
||||
bootstrap=bootstrap)
|
||||
bootstrap="")
|
||||
print(f"Labfy J6 : {server.origin}/", flush=True)
|
||||
print(f"Code de session éphémère : {bootstrap}", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,9 @@
|
|||
/******************************************************************************
|
||||
* @file local_job_store.c
|
||||
* @brief JobStore SQLite V3, distinct de la base métier V20.
|
||||
* @brief JobStore SQLite V4, distinct de la base métier V20.
|
||||
******************************************************************************/
|
||||
#include "core/local_job_store.h"
|
||||
#include "core/research_store.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <glib/gstdio.h>
|
||||
|
|
@ -79,6 +80,263 @@ static const char *v2_to_v3_sql =
|
|||
"CREATE UNIQUE INDEX idx_jobs_active_analysis ON jobs(source_evidence_id,capability_id) "
|
||||
"WHERE state IN('QUEUED','RUNNING','RETRY_WAIT');";
|
||||
|
||||
static const char *research_schema_sql =
|
||||
"CREATE TABLE research_plans("
|
||||
"plan_id TEXT PRIMARY KEY,investigation_id TEXT NOT NULL,"
|
||||
"idempotency_key TEXT NOT NULL,content_fingerprint TEXT NOT NULL,"
|
||||
"input_fingerprint TEXT NOT NULL,input_revision INTEGER NOT NULL CHECK(input_revision>0),"
|
||||
"created_at TEXT NOT NULL,UNIQUE(investigation_id,idempotency_key));"
|
||||
"CREATE TABLE research_seeds("
|
||||
"plan_id TEXT NOT NULL REFERENCES research_plans(plan_id) ON DELETE RESTRICT,"
|
||||
"rank INTEGER NOT NULL,kind TEXT NOT NULL,subject TEXT NOT NULL,PRIMARY KEY(plan_id,rank));"
|
||||
"CREATE TABLE research_actions("
|
||||
"action_id TEXT PRIMARY KEY,plan_id TEXT NOT NULL REFERENCES research_plans(plan_id) ON DELETE RESTRICT,"
|
||||
"rank INTEGER NOT NULL,capability_id TEXT NOT NULL,provider_id TEXT NOT NULL,"
|
||||
"endpoint TEXT NOT NULL,subject TEXT NOT NULL,contact_class TEXT NOT NULL,"
|
||||
"disclosure TEXT NOT NULL,max_requests INTEGER NOT NULL CHECK(max_requests>0),"
|
||||
"max_response_bytes INTEGER NOT NULL CHECK(max_response_bytes>0),"
|
||||
"max_active_ms INTEGER NOT NULL CHECK(max_active_ms>0),UNIQUE(plan_id,rank));"
|
||||
"CREATE TABLE scope_grants("
|
||||
"grant_id TEXT PRIMARY KEY,investigation_id TEXT NOT NULL,idempotency_key TEXT NOT NULL,"
|
||||
"content_fingerprint TEXT NOT NULL,plan_fingerprint TEXT NOT NULL,created_at TEXT NOT NULL,"
|
||||
"expires_at TEXT NOT NULL,revoked_at TEXT,max_requests INTEGER NOT NULL CHECK(max_requests>0),"
|
||||
"max_response_bytes INTEGER NOT NULL CHECK(max_response_bytes>0),"
|
||||
"max_active_ms INTEGER NOT NULL CHECK(max_active_ms>0),"
|
||||
"UNIQUE(investigation_id,idempotency_key),"
|
||||
"FOREIGN KEY(investigation_id,plan_fingerprint) REFERENCES research_plans(investigation_id,content_fingerprint));"
|
||||
"CREATE TABLE scope_grant_actions("
|
||||
"grant_id TEXT NOT NULL REFERENCES scope_grants(grant_id) ON DELETE RESTRICT,"
|
||||
"action_id TEXT NOT NULL REFERENCES research_actions(action_id) ON DELETE RESTRICT,"
|
||||
"subject TEXT NOT NULL,provider_id TEXT NOT NULL,endpoint TEXT NOT NULL,"
|
||||
"PRIMARY KEY(grant_id,action_id));"
|
||||
"CREATE TABLE scope_grant_exclusions("
|
||||
"grant_id TEXT NOT NULL REFERENCES scope_grants(grant_id) ON DELETE RESTRICT,"
|
||||
"subject TEXT NOT NULL,PRIMARY KEY(grant_id,subject));"
|
||||
"CREATE TABLE research_action_decisions("
|
||||
"action_id TEXT PRIMARY KEY REFERENCES research_actions(action_id) ON DELETE RESTRICT,"
|
||||
"plan_id TEXT NOT NULL REFERENCES research_plans(plan_id) ON DELETE RESTRICT,"
|
||||
"decision_code TEXT NOT NULL CHECK(decision_code IN('AUTHORIZE','DEFER','REFUSE')),"
|
||||
"decided_at TEXT NOT NULL);"
|
||||
"CREATE TABLE research_campaigns("
|
||||
"campaign_id TEXT PRIMARY KEY,investigation_id TEXT NOT NULL,"
|
||||
"grant_id TEXT NOT NULL REFERENCES scope_grants(grant_id) ON DELETE RESTRICT,"
|
||||
"idempotency_key TEXT NOT NULL,content_fingerprint TEXT NOT NULL,"
|
||||
"created_at TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'ADMITTED',"
|
||||
"UNIQUE(investigation_id,idempotency_key));"
|
||||
"CREATE TABLE research_results("
|
||||
"result_id TEXT PRIMARY KEY,campaign_id TEXT NOT NULL REFERENCES research_campaigns(campaign_id) ON DELETE RESTRICT,"
|
||||
"action_id TEXT NOT NULL REFERENCES research_actions(action_id) ON DELETE RESTRICT,"
|
||||
"raw_artifact_relative_path TEXT NOT NULL,content_sha256 TEXT NOT NULL,status TEXT NOT NULL);"
|
||||
"CREATE TABLE research_receipts("
|
||||
"receipt_id TEXT PRIMARY KEY,result_id TEXT NOT NULL UNIQUE REFERENCES research_results(result_id) ON DELETE RESTRICT,"
|
||||
"campaign_id TEXT NOT NULL REFERENCES research_campaigns(campaign_id) ON DELETE RESTRICT,"
|
||||
"grant_id TEXT NOT NULL,action_id TEXT NOT NULL,decided_at TEXT NOT NULL,decision_code TEXT NOT NULL,"
|
||||
"FOREIGN KEY(grant_id,action_id) REFERENCES scope_grant_actions(grant_id,action_id));"
|
||||
"CREATE UNIQUE INDEX idx_research_plan_owner_fingerprint "
|
||||
"ON research_plans(investigation_id,content_fingerprint);";
|
||||
|
||||
/* V4 n'est pas publiée : cette extension additive permet aussi d'ouvrir une
|
||||
* base V4 créée par une build intermédiaire de la tranche OSINT. */
|
||||
static const char *research_decision_extension_sql =
|
||||
"CREATE TABLE IF NOT EXISTS research_action_decisions("
|
||||
"action_id TEXT PRIMARY KEY REFERENCES research_actions(action_id) ON DELETE RESTRICT,"
|
||||
"plan_id TEXT NOT NULL REFERENCES research_plans(plan_id) ON DELETE RESTRICT,"
|
||||
"decision_code TEXT NOT NULL CHECK(decision_code IN('AUTHORIZE','DEFER','REFUSE')),"
|
||||
"decided_at TEXT NOT NULL);";
|
||||
|
||||
/* Le Makefile historique lie ce fichier seul dans plusieurs outils. Ces
|
||||
* gardes internes maintiennent cette compatibilité sans rendre le JobStore
|
||||
* dépendant d'un nouveau module à l'édition de liens. */
|
||||
static gboolean research_text(const char *value) {
|
||||
return value != NULL && value[0] != '\0';
|
||||
}
|
||||
|
||||
static gboolean research_hash(const char *value) {
|
||||
if (value == NULL || strlen(value) != 64)
|
||||
return FALSE;
|
||||
for (gsize i = 0; i < 64; i++)
|
||||
if (!g_ascii_isxdigit(value[i]))
|
||||
return FALSE;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static gboolean research_timestamp(const char *value) {
|
||||
GDateTime *parsed = value != NULL
|
||||
? g_date_time_new_from_iso8601(value, NULL)
|
||||
: NULL;
|
||||
if (parsed == NULL)
|
||||
return FALSE;
|
||||
g_date_time_unref(parsed);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static gboolean research_relative_artifact(const char *value) {
|
||||
if (!research_text(value) || g_path_is_absolute(value) || strchr(value, '\\'))
|
||||
return FALSE;
|
||||
char **parts = g_strsplit(value, "/", -1);
|
||||
gboolean valid = TRUE;
|
||||
for (gsize i = 0; valid && parts[i] != NULL; i++)
|
||||
valid = parts[i][0] != '\0' && g_strcmp0(parts[i], ".") != 0 &&
|
||||
g_strcmp0(parts[i], "..") != 0;
|
||||
g_strfreev(parts);
|
||||
return valid;
|
||||
}
|
||||
|
||||
static const char *research_seed_code(ResearchSeedKind kind) {
|
||||
static const char *const codes[] = {"DOMAIN", "IP", "HTTP_URL", "EMAIL",
|
||||
"SEARCH_TERM"};
|
||||
return kind <= RESEARCH_SEED_SEARCH_TERM ? codes[kind] : NULL;
|
||||
}
|
||||
|
||||
static const char *research_contact_code(ResearchContactClass contact) {
|
||||
static const char *const codes[] = {"NONE", "THIRD_PARTY", "TARGET"};
|
||||
return contact <= RESEARCH_CONTACT_TARGET ? codes[contact] : NULL;
|
||||
}
|
||||
|
||||
static const char *research_decision_code(ResearchActionDecisionCode decision) {
|
||||
static const char *const codes[] = {"AUTHORIZE", "DEFER", "REFUSE"};
|
||||
return decision <= RESEARCH_ACTION_REFUSE ? codes[decision] : NULL;
|
||||
}
|
||||
|
||||
static gboolean bind_text(sqlite3_stmt *statement, int index,
|
||||
const char *value);
|
||||
|
||||
static gboolean research_action_guard(const ResearchAction *action) {
|
||||
return action != NULL && g_uuid_string_is_valid(action->action_id) &&
|
||||
research_text(action->capability_id) && research_text(action->provider_id) &&
|
||||
research_text(action->endpoint) && research_text(action->subject) &&
|
||||
research_contact_code(action->contact) != NULL &&
|
||||
research_text(action->disclosure) && action->max_requests > 0 &&
|
||||
action->max_response_bytes > 0 &&
|
||||
action->max_response_bytes <= G_MAXINT64 && action->max_active_ms > 0;
|
||||
}
|
||||
|
||||
static gboolean research_plan_guard(const ResearchPlan *plan) {
|
||||
if (plan == NULL || g_strcmp0(plan->contract, RESEARCH_PLAN_CONTRACT) != 0 ||
|
||||
!g_uuid_string_is_valid(plan->plan_id) ||
|
||||
!research_text(plan->idempotency_key) ||
|
||||
!research_hash(plan->content_fingerprint) ||
|
||||
!research_hash(plan->input_fingerprint) || plan->input_revision == 0 ||
|
||||
!research_timestamp(plan->created_at) || plan->seeds == NULL ||
|
||||
plan->seed_count == 0 || plan->actions == NULL || plan->action_count == 0)
|
||||
return FALSE;
|
||||
GHashTable *ids = g_hash_table_new(g_str_hash, g_str_equal);
|
||||
gboolean valid = TRUE;
|
||||
for (gsize i = 0; valid && i < plan->seed_count; i++)
|
||||
valid = research_seed_code(plan->seeds[i].kind) != NULL &&
|
||||
research_text(plan->seeds[i].subject);
|
||||
for (gsize i = 0; valid && i < plan->action_count; i++)
|
||||
valid = research_action_guard(&plan->actions[i]) &&
|
||||
g_hash_table_add(ids, (gpointer)plan->actions[i].action_id);
|
||||
g_hash_table_unref(ids);
|
||||
return valid;
|
||||
}
|
||||
|
||||
static gboolean research_grant_guard(const ScopeGrant *grant,
|
||||
const ResearchActionDecision *decisions, gsize decision_count,
|
||||
const char *decided_at) {
|
||||
if (grant == NULL || g_strcmp0(grant->contract, RESEARCH_GRANT_CONTRACT) != 0 ||
|
||||
!g_uuid_string_is_valid(grant->grant_id) ||
|
||||
!g_uuid_string_is_valid(grant->investigation_id) ||
|
||||
!research_text(grant->idempotency_key) ||
|
||||
!research_hash(grant->content_fingerprint) ||
|
||||
!research_hash(grant->plan_fingerprint) ||
|
||||
!research_timestamp(grant->created_at) ||
|
||||
!research_timestamp(grant->expires_at) ||
|
||||
grant->selected_action_ids == NULL || grant->selected_action_count == 0 ||
|
||||
decisions == NULL || decision_count == 0 ||
|
||||
!research_timestamp(decided_at) ||
|
||||
grant->max_requests == 0 || grant->max_response_bytes == 0 ||
|
||||
grant->max_response_bytes > G_MAXINT64 ||
|
||||
grant->max_active_ms == 0)
|
||||
return FALSE;
|
||||
GHashTable *ids = g_hash_table_new(g_str_hash, g_str_equal);
|
||||
for (gsize i = 0; i < grant->selected_action_count; i++)
|
||||
if (!g_uuid_string_is_valid(grant->selected_action_ids[i]))
|
||||
goto invalid;
|
||||
for (gsize i = 0; i < decision_count; i++)
|
||||
if (!g_uuid_string_is_valid(decisions[i].action_id) ||
|
||||
research_decision_code(decisions[i].code) == NULL ||
|
||||
!g_hash_table_add(ids, (gpointer)decisions[i].action_id))
|
||||
goto invalid;
|
||||
for (gsize i = 0; i < grant->excluded_subject_count; i++)
|
||||
if (!research_text(grant->excluded_subjects[i]))
|
||||
goto invalid;
|
||||
g_hash_table_unref(ids);
|
||||
return TRUE;
|
||||
invalid:
|
||||
g_hash_table_unref(ids);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* CONTRACT: chaque admission conserve une décision humaine pour exactement
|
||||
* chaque action du ResearchPlan référencé. WHY: une projection partielle ne
|
||||
* permettrait pas d'auditer les actions DEFER ou REFUSE. */
|
||||
static gboolean research_grant_decisions_cover_plan(sqlite3 *db,
|
||||
const ScopeGrant *grant, const ResearchActionDecision *decisions,
|
||||
gsize decision_count) {
|
||||
sqlite3_stmt *count = NULL;
|
||||
gboolean ok = sqlite3_prepare_v2(db,
|
||||
"SELECT count(*) FROM research_actions a JOIN research_plans p "
|
||||
"ON p.plan_id=a.plan_id WHERE p.investigation_id=?1 "
|
||||
"AND p.content_fingerprint=?2;", -1, &count, NULL) == SQLITE_OK &&
|
||||
bind_text(count, 1, grant->investigation_id) &&
|
||||
bind_text(count, 2, grant->plan_fingerprint) &&
|
||||
sqlite3_step(count) == SQLITE_ROW &&
|
||||
sqlite3_column_int64(count, 0) == (sqlite3_int64)decision_count;
|
||||
sqlite3_finalize(count);
|
||||
/* INVARIANT: les UUID sont déjà uniques via research_grant_guard(); avec
|
||||
* la même cardinalité, leur appartenance au plan rend la couverture
|
||||
* bijective, sans migration du schéma V4. */
|
||||
for (gsize i = 0; ok && i < decision_count; i++) {
|
||||
sqlite3_stmt *member = NULL;
|
||||
ok = sqlite3_prepare_v2(db,
|
||||
"SELECT EXISTS(SELECT 1 FROM research_actions a "
|
||||
"JOIN research_plans p ON p.plan_id=a.plan_id "
|
||||
"WHERE a.action_id=?1 AND p.investigation_id=?2 "
|
||||
"AND p.content_fingerprint=?3);", -1, &member, NULL) == SQLITE_OK &&
|
||||
bind_text(member, 1, decisions[i].action_id) &&
|
||||
bind_text(member, 2, grant->investigation_id) &&
|
||||
bind_text(member, 3, grant->plan_fingerprint) &&
|
||||
sqlite3_step(member) == SQLITE_ROW && sqlite3_column_int(member, 0) == 1;
|
||||
sqlite3_finalize(member);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
static gboolean research_campaign_guard(const ResearchCampaign *campaign) {
|
||||
return campaign != NULL &&
|
||||
g_strcmp0(campaign->contract, RESEARCH_CAMPAIGN_CONTRACT) == 0 &&
|
||||
g_uuid_string_is_valid(campaign->campaign_id) &&
|
||||
g_uuid_string_is_valid(campaign->investigation_id) &&
|
||||
g_uuid_string_is_valid(campaign->grant_id) &&
|
||||
research_text(campaign->idempotency_key) &&
|
||||
research_hash(campaign->content_fingerprint) &&
|
||||
research_timestamp(campaign->created_at);
|
||||
}
|
||||
|
||||
static gboolean research_result_guard(const ResearchResult *result) {
|
||||
return result != NULL &&
|
||||
g_strcmp0(result->contract, RESEARCH_RESULT_CONTRACT) == 0 &&
|
||||
g_uuid_string_is_valid(result->result_id) &&
|
||||
g_uuid_string_is_valid(result->campaign_id) &&
|
||||
g_uuid_string_is_valid(result->action_id) &&
|
||||
research_relative_artifact(result->raw_artifact_relative_path) &&
|
||||
research_hash(result->content_sha256) && research_text(result->status);
|
||||
}
|
||||
|
||||
static gboolean research_receipt_guard(const ResearchReceipt *receipt) {
|
||||
return receipt != NULL &&
|
||||
g_strcmp0(receipt->contract, RESEARCH_RECEIPT_CONTRACT) == 0 &&
|
||||
g_uuid_string_is_valid(receipt->receipt_id) &&
|
||||
g_uuid_string_is_valid(receipt->result_id) &&
|
||||
g_uuid_string_is_valid(receipt->campaign_id) &&
|
||||
g_uuid_string_is_valid(receipt->grant_id) &&
|
||||
g_uuid_string_is_valid(receipt->action_id) &&
|
||||
research_timestamp(receipt->decided_at) &&
|
||||
research_text(receipt->decision_code);
|
||||
}
|
||||
|
||||
#ifdef LOCAL_JOB_STORE_ENABLE_TEST_HOOKS
|
||||
static gboolean fail_migration_after_schema = FALSE;
|
||||
void local_job_store_test_fail_migration_after_schema(gboolean enabled) {
|
||||
|
|
@ -173,11 +431,11 @@ LocalJobStore *local_job_store_create(const char *path,
|
|||
return NULL;
|
||||
char *sql = g_strdup_printf(
|
||||
"BEGIN IMMEDIATE;%s"
|
||||
"%sINSERT INTO metadata VALUES('schema_version','3');"
|
||||
"%s%sINSERT INTO metadata VALUES('schema_version','4');"
|
||||
"INSERT INTO metadata VALUES('investigation_id','%s');"
|
||||
"INSERT INTO metadata VALUES('revision','0');"
|
||||
"INSERT INTO controls VALUES(1,0,0,'1970-01-01T00:00:00Z');COMMIT;",
|
||||
schema_sql, plan_schema_sql, investigation_id);
|
||||
schema_sql, plan_schema_sql, research_schema_sql, investigation_id);
|
||||
gboolean success =
|
||||
exec_sql(store->db, sql, error) && g_chmod(path, 0600) == 0;
|
||||
g_free(sql);
|
||||
|
|
@ -216,26 +474,28 @@ LocalJobStore *local_job_store_open(const char *path,
|
|||
owner = g_strcmp0(value, investigation_id) == 0;
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
if ((schema_version < 1 || schema_version > 3) || !owner) {
|
||||
if ((schema_version < 1 || schema_version > 4) || !owner) {
|
||||
job_error(error, G_IO_ERROR_INVALID_DATA,
|
||||
schema_version < 1 || schema_version > 3
|
||||
schema_version < 1 || schema_version > 4
|
||||
? "Version JobStore inconnue ; aucune recréation automatique."
|
||||
: "Le JobStore appartient à une autre enquête.");
|
||||
local_job_store_close(store);
|
||||
return NULL;
|
||||
}
|
||||
/* WHY: V2 ne touche que l'ordonnanceur local. Le lecteur read-only conserve
|
||||
* la compatibilité V1 et ne migre jamais; seul un ouvreur exclusif en écriture
|
||||
* applique atomiquement l'ajout non destructif des plans et budgets. */
|
||||
if (schema_version < 3 && !read_only) {
|
||||
/* WHY: V4 reste une base opérationnelle distincte de V20/V21. Le lecteur
|
||||
* read-only conserve les versions historiques sans les migrer; seul un
|
||||
* ouvreur en écriture applique les ajouts dans une transaction unique. */
|
||||
if (schema_version < 4 && !read_only) {
|
||||
/* CONTRACT: schéma, données et version sont une seule transaction. La
|
||||
* séparation des appels rend testable un crash logique après mutation,
|
||||
* sans affaiblir l'atomicité SQLite. */
|
||||
gboolean migrated = exec_sql(store->db, "BEGIN IMMEDIATE;", error);
|
||||
if (migrated && schema_version == 1)
|
||||
migrated = exec_sql(store->db, plan_schema_sql, error);
|
||||
if (migrated)
|
||||
if (migrated && schema_version < 3)
|
||||
migrated = exec_sql(store->db, v2_to_v3_sql, error);
|
||||
if (migrated)
|
||||
migrated = exec_sql(store->db, research_schema_sql, error);
|
||||
#ifdef LOCAL_JOB_STORE_ENABLE_TEST_HOOKS
|
||||
if (migrated && fail_migration_after_schema) {
|
||||
job_error(error, G_IO_ERROR_FAILED,
|
||||
|
|
@ -245,7 +505,7 @@ LocalJobStore *local_job_store_open(const char *path,
|
|||
#endif
|
||||
if (migrated)
|
||||
migrated = exec_sql(store->db,
|
||||
"UPDATE metadata SET value='3' WHERE key='schema_version';", error);
|
||||
"UPDATE metadata SET value='4' WHERE key='schema_version';", error);
|
||||
if (migrated)
|
||||
migrated = exec_sql(store->db, "COMMIT;", error);
|
||||
if (!migrated) {
|
||||
|
|
@ -254,6 +514,16 @@ LocalJobStore *local_job_store_open(const char *path,
|
|||
return NULL;
|
||||
}
|
||||
}
|
||||
if (schema_version == 4 && !read_only) {
|
||||
gboolean extended = exec_sql(store->db, "BEGIN IMMEDIATE;", error) &&
|
||||
exec_sql(store->db, research_decision_extension_sql, error) &&
|
||||
exec_sql(store->db, "COMMIT;", error);
|
||||
if (!extended) {
|
||||
exec_sql(store->db, "ROLLBACK;", NULL);
|
||||
local_job_store_close(store);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
return store;
|
||||
failure:
|
||||
sqlite3_finalize(statement);
|
||||
|
|
@ -1004,6 +1274,494 @@ gboolean local_job_store_integrity(LocalJobStore *s, GError **error) {
|
|||
return ok;
|
||||
}
|
||||
|
||||
static gboolean research_begin(LocalJobStore *store, GError **error) {
|
||||
if (store == NULL) {
|
||||
job_error(error, G_IO_ERROR_INVALID_ARGUMENT, "ResearchStore absent.");
|
||||
return FALSE;
|
||||
}
|
||||
return exec_sql(store->db, "BEGIN IMMEDIATE;", error);
|
||||
}
|
||||
|
||||
static gboolean research_existing(sqlite3 *db, const char *table,
|
||||
const char *investigation_id,
|
||||
const char *idempotency_key,
|
||||
const char *content_fingerprint,
|
||||
gboolean *out_found, gboolean *out_same) {
|
||||
char *sql = g_strdup_printf(
|
||||
"SELECT content_fingerprint FROM %s WHERE investigation_id=?1 AND "
|
||||
"idempotency_key=?2;", table);
|
||||
sqlite3_stmt *statement = NULL;
|
||||
gboolean ok = sqlite3_prepare_v2(db, sql, -1, &statement, NULL) == SQLITE_OK &&
|
||||
bind_text(statement, 1, investigation_id) &&
|
||||
bind_text(statement, 2, idempotency_key);
|
||||
g_free(sql);
|
||||
if (!ok) {
|
||||
sqlite3_finalize(statement);
|
||||
return FALSE;
|
||||
}
|
||||
int step = sqlite3_step(statement);
|
||||
*out_found = step == SQLITE_ROW;
|
||||
*out_same = *out_found &&
|
||||
g_strcmp0((const char *)sqlite3_column_text(statement, 0),
|
||||
content_fingerprint) == 0;
|
||||
ok = step == SQLITE_ROW || step == SQLITE_DONE;
|
||||
sqlite3_finalize(statement);
|
||||
return ok;
|
||||
}
|
||||
|
||||
gboolean research_store_admit_plan(LocalJobStore *store,
|
||||
const ResearchPlan *plan,
|
||||
gboolean *out_reused, GError **error) {
|
||||
if (out_reused != NULL)
|
||||
*out_reused = FALSE;
|
||||
if (store == NULL || !research_plan_guard(plan)) {
|
||||
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
|
||||
"Contrat ResearchPlan V1 invalide.");
|
||||
return FALSE;
|
||||
}
|
||||
if (!research_begin(store, error))
|
||||
return FALSE;
|
||||
gboolean found = FALSE, same = FALSE;
|
||||
gboolean ok = research_existing(store->db, "research_plans",
|
||||
store->investigation_id, plan->idempotency_key,
|
||||
plan->content_fingerprint, &found, &same);
|
||||
if (ok && found) {
|
||||
if (!same) {
|
||||
job_error(error, G_IO_ERROR_EXISTS,
|
||||
"Clé ResearchPlan réutilisée avec un contenu différent.");
|
||||
exec_sql(store->db, "ROLLBACK;", NULL);
|
||||
return FALSE;
|
||||
}
|
||||
if (out_reused != NULL)
|
||||
*out_reused = TRUE;
|
||||
return exec_sql(store->db, "COMMIT;", error);
|
||||
}
|
||||
sqlite3_stmt *insert = NULL;
|
||||
ok = ok && sqlite3_prepare_v2(store->db,
|
||||
"INSERT INTO research_plans VALUES(?1,?2,?3,?4,?5,?6,?7);",
|
||||
-1, &insert, NULL) == SQLITE_OK &&
|
||||
bind_text(insert, 1, plan->plan_id) &&
|
||||
bind_text(insert, 2, store->investigation_id) &&
|
||||
bind_text(insert, 3, plan->idempotency_key) &&
|
||||
bind_text(insert, 4, plan->content_fingerprint) &&
|
||||
bind_text(insert, 5, plan->input_fingerprint) &&
|
||||
sqlite3_bind_int64(insert, 6, (sqlite3_int64)plan->input_revision) ==
|
||||
SQLITE_OK &&
|
||||
bind_text(insert, 7, plan->created_at) &&
|
||||
sqlite3_step(insert) == SQLITE_DONE;
|
||||
sqlite3_finalize(insert);
|
||||
for (gsize i = 0; ok && i < plan->seed_count; i++) {
|
||||
sqlite3_stmt *seed = NULL;
|
||||
ok = sqlite3_prepare_v2(store->db,
|
||||
"INSERT INTO research_seeds VALUES(?1,?2,?3,?4);", -1, &seed,
|
||||
NULL) == SQLITE_OK && bind_text(seed, 1, plan->plan_id) &&
|
||||
sqlite3_bind_int64(seed, 2, (sqlite3_int64)i) == SQLITE_OK &&
|
||||
bind_text(seed, 3, research_seed_code(plan->seeds[i].kind)) &&
|
||||
bind_text(seed, 4, plan->seeds[i].subject) &&
|
||||
sqlite3_step(seed) == SQLITE_DONE;
|
||||
sqlite3_finalize(seed);
|
||||
}
|
||||
for (gsize i = 0; ok && i < plan->action_count; i++) {
|
||||
const ResearchAction *action = &plan->actions[i];
|
||||
sqlite3_stmt *item = NULL;
|
||||
ok = sqlite3_prepare_v2(store->db,
|
||||
"INSERT INTO research_actions VALUES("
|
||||
"?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12);", -1, &item,
|
||||
NULL) == SQLITE_OK && bind_text(item, 1, action->action_id) &&
|
||||
bind_text(item, 2, plan->plan_id) &&
|
||||
sqlite3_bind_int64(item, 3, (sqlite3_int64)i) == SQLITE_OK &&
|
||||
bind_text(item, 4, action->capability_id) &&
|
||||
bind_text(item, 5, action->provider_id) &&
|
||||
bind_text(item, 6, action->endpoint) &&
|
||||
bind_text(item, 7, action->subject) &&
|
||||
bind_text(item, 8, research_contact_code(action->contact)) &&
|
||||
bind_text(item, 9, action->disclosure) &&
|
||||
sqlite3_bind_int64(item, 10, action->max_requests) == SQLITE_OK &&
|
||||
sqlite3_bind_int64(item, 11,
|
||||
(sqlite3_int64)action->max_response_bytes) ==
|
||||
SQLITE_OK &&
|
||||
sqlite3_bind_int64(item, 12, action->max_active_ms) == SQLITE_OK &&
|
||||
sqlite3_step(item) == SQLITE_DONE;
|
||||
sqlite3_finalize(item);
|
||||
}
|
||||
if (ok)
|
||||
ok = exec_sql(store->db, "COMMIT;", error);
|
||||
if (!ok) {
|
||||
exec_sql(store->db, "ROLLBACK;", NULL);
|
||||
job_error(error, G_IO_ERROR_FAILED,
|
||||
"Admission atomique du ResearchPlan impossible.");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
gboolean research_store_admit_grant(LocalJobStore *store,
|
||||
const ScopeGrant *grant,
|
||||
const ResearchActionDecision *decisions,
|
||||
gsize decision_count,
|
||||
const char *decided_at,
|
||||
gboolean *out_reused, GError **error) {
|
||||
if (out_reused != NULL)
|
||||
*out_reused = FALSE;
|
||||
if (store == NULL || !research_grant_guard(grant, decisions,
|
||||
decision_count, decided_at)) {
|
||||
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
|
||||
"Contrat ScopeGrant V1 invalide.");
|
||||
return FALSE;
|
||||
}
|
||||
if (g_strcmp0(store->investigation_id, grant->investigation_id) != 0) {
|
||||
job_error(error, G_IO_ERROR_PERMISSION_DENIED,
|
||||
"Grant refusé pour une autre enquête.");
|
||||
return FALSE;
|
||||
}
|
||||
if (!research_begin(store, error))
|
||||
return FALSE;
|
||||
if (!research_grant_decisions_cover_plan(store->db, grant, decisions,
|
||||
decision_count)) {
|
||||
exec_sql(store->db, "ROLLBACK;", NULL);
|
||||
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
|
||||
"Les décisions ne couvrent pas bijectivement le ResearchPlan.");
|
||||
return FALSE;
|
||||
}
|
||||
gboolean found = FALSE, same = FALSE;
|
||||
gboolean ok = research_existing(store->db, "scope_grants",
|
||||
grant->investigation_id, grant->idempotency_key,
|
||||
grant->content_fingerprint, &found, &same);
|
||||
if (ok && found) {
|
||||
if (!same) {
|
||||
job_error(error, G_IO_ERROR_EXISTS,
|
||||
"Clé ScopeGrant réutilisée avec un contenu différent.");
|
||||
exec_sql(store->db, "ROLLBACK;", NULL);
|
||||
return FALSE;
|
||||
}
|
||||
if (out_reused != NULL)
|
||||
*out_reused = TRUE;
|
||||
return exec_sql(store->db, "COMMIT;", error);
|
||||
}
|
||||
sqlite3_stmt *insert = NULL;
|
||||
ok = ok && sqlite3_prepare_v2(store->db,
|
||||
"INSERT INTO scope_grants VALUES("
|
||||
"?1,?2,?3,?4,?5,?6,?7,NULL,?8,?9,?10);", -1, &insert,
|
||||
NULL) == SQLITE_OK && bind_text(insert, 1, grant->grant_id) &&
|
||||
bind_text(insert, 2, grant->investigation_id) &&
|
||||
bind_text(insert, 3, grant->idempotency_key) &&
|
||||
bind_text(insert, 4, grant->content_fingerprint) &&
|
||||
bind_text(insert, 5, grant->plan_fingerprint) &&
|
||||
bind_text(insert, 6, grant->created_at) &&
|
||||
bind_text(insert, 7, grant->expires_at) &&
|
||||
sqlite3_bind_int64(insert, 8, grant->max_requests) == SQLITE_OK &&
|
||||
sqlite3_bind_int64(insert, 9,
|
||||
(sqlite3_int64)grant->max_response_bytes) ==
|
||||
SQLITE_OK &&
|
||||
sqlite3_bind_int64(insert, 10, grant->max_active_ms) == SQLITE_OK &&
|
||||
sqlite3_step(insert) == SQLITE_DONE;
|
||||
sqlite3_finalize(insert);
|
||||
/* CONTRACT: la décision humaine est persistée avant de matérialiser le
|
||||
* grant. REFUSE est terminal : une requête ultérieure ne peut ni l'effacer
|
||||
* ni réadmettre l'action sous une nouvelle clé de grant. */
|
||||
for (gsize i = 0; ok && i < decision_count; i++) {
|
||||
sqlite3_stmt *decision = NULL;
|
||||
ok = sqlite3_prepare_v2(store->db,
|
||||
"INSERT INTO research_action_decisions(action_id,plan_id,decision_code,decided_at) "
|
||||
"SELECT ?1,p.plan_id,?2,?3 FROM research_actions a "
|
||||
"JOIN research_plans p ON p.plan_id=a.plan_id "
|
||||
"WHERE a.action_id=?1 AND p.investigation_id=?4 "
|
||||
"AND p.content_fingerprint=?5 "
|
||||
"ON CONFLICT(action_id) DO UPDATE SET decision_code=excluded.decision_code,"
|
||||
"decided_at=excluded.decided_at WHERE "
|
||||
"research_action_decisions.decision_code!='REFUSE' "
|
||||
"OR excluded.decision_code='REFUSE';", -1, &decision, NULL) == SQLITE_OK &&
|
||||
bind_text(decision, 1, decisions[i].action_id) &&
|
||||
bind_text(decision, 2,
|
||||
research_decision_code(decisions[i].code)) &&
|
||||
bind_text(decision, 3, decided_at) &&
|
||||
bind_text(decision, 4, grant->investigation_id) &&
|
||||
bind_text(decision, 5, grant->plan_fingerprint) &&
|
||||
sqlite3_step(decision) == SQLITE_DONE &&
|
||||
sqlite3_changes(store->db) == 1;
|
||||
sqlite3_finalize(decision);
|
||||
}
|
||||
for (gsize i = 0; ok && i < grant->selected_action_count; i++) {
|
||||
sqlite3_stmt *selected = NULL;
|
||||
ok = sqlite3_prepare_v2(store->db,
|
||||
"INSERT INTO scope_grant_actions(grant_id,action_id,subject,provider_id,endpoint) "
|
||||
"SELECT ?1,a.action_id,a.subject,a.provider_id,a.endpoint FROM research_actions a "
|
||||
"JOIN research_plans p ON p.plan_id=a.plan_id WHERE a.action_id=?2 "
|
||||
"AND p.investigation_id=?3 AND p.content_fingerprint=?4 "
|
||||
"AND EXISTS(SELECT 1 FROM research_action_decisions d "
|
||||
"WHERE d.action_id=a.action_id AND d.decision_code='AUTHORIZE') "
|
||||
"AND (a.rank!=1 OR EXISTS(SELECT 1 FROM research_actions first "
|
||||
"JOIN research_action_decisions fd ON fd.action_id=first.action_id "
|
||||
"JOIN research_results rr ON rr.action_id=first.action_id "
|
||||
"JOIN research_receipts rc ON rc.result_id=rr.result_id "
|
||||
"WHERE first.plan_id=a.plan_id AND first.rank=0 "
|
||||
"AND fd.decision_code='AUTHORIZE'));", -1,
|
||||
&selected, NULL) == SQLITE_OK &&
|
||||
bind_text(selected, 1, grant->grant_id) &&
|
||||
bind_text(selected, 2, grant->selected_action_ids[i]) &&
|
||||
bind_text(selected, 3, grant->investigation_id) &&
|
||||
bind_text(selected, 4, grant->plan_fingerprint) &&
|
||||
sqlite3_step(selected) == SQLITE_DONE && sqlite3_changes(store->db) == 1;
|
||||
sqlite3_finalize(selected);
|
||||
}
|
||||
for (gsize i = 0; ok && i < grant->excluded_subject_count; i++) {
|
||||
sqlite3_stmt *excluded = NULL;
|
||||
ok = sqlite3_prepare_v2(store->db,
|
||||
"INSERT INTO scope_grant_exclusions VALUES(?1,?2);", -1, &excluded,
|
||||
NULL) == SQLITE_OK && bind_text(excluded, 1, grant->grant_id) &&
|
||||
bind_text(excluded, 2, grant->excluded_subjects[i]) &&
|
||||
sqlite3_step(excluded) == SQLITE_DONE;
|
||||
sqlite3_finalize(excluded);
|
||||
}
|
||||
if (ok)
|
||||
ok = exec_sql(store->db, "COMMIT;", error);
|
||||
if (!ok) {
|
||||
exec_sql(store->db, "ROLLBACK;", NULL);
|
||||
job_error(error, G_IO_ERROR_FAILED,
|
||||
"Admission atomique du ScopeGrant impossible.");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
gboolean research_store_revoke_grant(LocalJobStore *store,
|
||||
const char *grant_id,
|
||||
const char *revoked_at,
|
||||
GError **error) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
gboolean ok = store != NULL && g_uuid_string_is_valid(grant_id) &&
|
||||
revoked_at != NULL && sqlite3_prepare_v2(store->db,
|
||||
"UPDATE scope_grants SET revoked_at=COALESCE(revoked_at,?2) "
|
||||
"WHERE grant_id=?1 AND investigation_id=?3;", -1, &statement,
|
||||
NULL) == SQLITE_OK && bind_text(statement, 1, grant_id) &&
|
||||
bind_text(statement, 2, revoked_at) &&
|
||||
bind_text(statement, 3, store->investigation_id) &&
|
||||
sqlite3_step(statement) == SQLITE_DONE && sqlite3_changes(store->db) == 1;
|
||||
sqlite3_finalize(statement);
|
||||
if (!ok)
|
||||
job_error(error, G_IO_ERROR_NOT_FOUND, "ScopeGrant à révoquer introuvable.");
|
||||
return ok;
|
||||
}
|
||||
|
||||
gboolean research_store_admit_campaign(LocalJobStore *store,
|
||||
const ResearchCampaign *campaign,
|
||||
gboolean *out_reused, GError **error) {
|
||||
if (out_reused != NULL)
|
||||
*out_reused = FALSE;
|
||||
if (store == NULL || !research_campaign_guard(campaign)) {
|
||||
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
|
||||
"Contrat ResearchCampaign V1 invalide.");
|
||||
return FALSE;
|
||||
}
|
||||
if (g_strcmp0(store->investigation_id, campaign->investigation_id) != 0) {
|
||||
job_error(error, G_IO_ERROR_PERMISSION_DENIED,
|
||||
"Campagne refusée pour une autre enquête.");
|
||||
return FALSE;
|
||||
}
|
||||
if (!research_begin(store, error))
|
||||
return FALSE;
|
||||
gboolean found = FALSE, same = FALSE;
|
||||
gboolean ok = research_existing(store->db, "research_campaigns",
|
||||
campaign->investigation_id, campaign->idempotency_key,
|
||||
campaign->content_fingerprint, &found, &same);
|
||||
if (ok && found) {
|
||||
if (!same) {
|
||||
job_error(error, G_IO_ERROR_EXISTS,
|
||||
"Clé ResearchCampaign réutilisée avec un contenu différent.");
|
||||
exec_sql(store->db, "ROLLBACK;", NULL);
|
||||
return FALSE;
|
||||
}
|
||||
if (out_reused != NULL)
|
||||
*out_reused = TRUE;
|
||||
return exec_sql(store->db, "COMMIT;", error);
|
||||
}
|
||||
sqlite3_stmt *insert = NULL;
|
||||
ok = ok && sqlite3_prepare_v2(store->db,
|
||||
"INSERT INTO research_campaigns(campaign_id,investigation_id,grant_id,"
|
||||
"idempotency_key,content_fingerprint,created_at) "
|
||||
"SELECT ?1,?2,g.grant_id,?4,?5,?6 FROM scope_grants g "
|
||||
"WHERE g.grant_id=?3 AND g.investigation_id=?2;", -1, &insert,
|
||||
NULL) == SQLITE_OK && bind_text(insert, 1, campaign->campaign_id) &&
|
||||
bind_text(insert, 2, campaign->investigation_id) &&
|
||||
bind_text(insert, 3, campaign->grant_id) &&
|
||||
bind_text(insert, 4, campaign->idempotency_key) &&
|
||||
bind_text(insert, 5, campaign->content_fingerprint) &&
|
||||
bind_text(insert, 6, campaign->created_at) &&
|
||||
sqlite3_step(insert) == SQLITE_DONE && sqlite3_changes(store->db) == 1;
|
||||
sqlite3_finalize(insert);
|
||||
if (ok)
|
||||
ok = exec_sql(store->db, "COMMIT;", error);
|
||||
if (!ok) {
|
||||
exec_sql(store->db, "ROLLBACK;", NULL);
|
||||
job_error(error, G_IO_ERROR_FAILED,
|
||||
"Admission atomique de la ResearchCampaign impossible.");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
gboolean research_store_record_result(LocalJobStore *store,
|
||||
const ResearchResult *result,
|
||||
const ResearchReceipt *receipt,
|
||||
GError **error) {
|
||||
if (store == NULL || !research_result_guard(result) ||
|
||||
!research_receipt_guard(receipt)) {
|
||||
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
|
||||
"Contrat résultat ou reçu de recherche invalide.");
|
||||
return FALSE;
|
||||
}
|
||||
if (g_strcmp0(result->result_id, receipt->result_id) != 0 ||
|
||||
g_strcmp0(result->campaign_id, receipt->campaign_id) != 0 ||
|
||||
g_strcmp0(result->action_id, receipt->action_id) != 0) {
|
||||
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
|
||||
"Résultat et reçu de recherche incohérents.");
|
||||
return FALSE;
|
||||
}
|
||||
if (!research_begin(store, error))
|
||||
return FALSE;
|
||||
sqlite3_stmt *item = NULL;
|
||||
gboolean ok = sqlite3_prepare_v2(store->db,
|
||||
"INSERT INTO research_results VALUES(?1,?2,?3,?4,?5,?6);", -1,
|
||||
&item, NULL) == SQLITE_OK && bind_text(item, 1, result->result_id) &&
|
||||
bind_text(item, 2, result->campaign_id) &&
|
||||
bind_text(item, 3, result->action_id) &&
|
||||
bind_text(item, 4, result->raw_artifact_relative_path) &&
|
||||
bind_text(item, 5, result->content_sha256) &&
|
||||
bind_text(item, 6, result->status) && sqlite3_step(item) == SQLITE_DONE;
|
||||
sqlite3_finalize(item);
|
||||
sqlite3_stmt *proof = NULL;
|
||||
ok = ok && sqlite3_prepare_v2(store->db,
|
||||
"INSERT INTO research_receipts "
|
||||
"SELECT ?1,?2,c.campaign_id,c.grant_id,?4,?5,?6 "
|
||||
"FROM research_campaigns c JOIN scope_grant_actions ga "
|
||||
"ON ga.grant_id=c.grant_id AND ga.action_id=?4 "
|
||||
"WHERE c.campaign_id=?3 AND c.investigation_id=?7 AND c.grant_id=?8;",
|
||||
-1, &proof, NULL) == SQLITE_OK &&
|
||||
bind_text(proof, 1, receipt->receipt_id) &&
|
||||
bind_text(proof, 2, receipt->result_id) &&
|
||||
bind_text(proof, 3, receipt->campaign_id) &&
|
||||
bind_text(proof, 4, receipt->action_id) &&
|
||||
bind_text(proof, 5, receipt->decided_at) &&
|
||||
bind_text(proof, 6, receipt->decision_code) &&
|
||||
bind_text(proof, 7, store->investigation_id) &&
|
||||
bind_text(proof, 8, receipt->grant_id) &&
|
||||
sqlite3_step(proof) == SQLITE_DONE && sqlite3_changes(store->db) == 1;
|
||||
sqlite3_finalize(proof);
|
||||
if (ok)
|
||||
ok = exec_sql(store->db, "COMMIT;", error);
|
||||
if (!ok) {
|
||||
exec_sql(store->db, "ROLLBACK;", NULL);
|
||||
job_error(error, G_IO_ERROR_FAILED,
|
||||
"Publication atomique du résultat et du reçu impossible.");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
gboolean research_store_policy_decide(LocalJobStore *store,
|
||||
const char *grant_id,
|
||||
const ResearchPolicyRequest *request,
|
||||
ResearchPolicyDecision *out_decision,
|
||||
GError **error) {
|
||||
if (store == NULL || !g_uuid_string_is_valid(grant_id) || request == NULL ||
|
||||
!research_action_guard(request->action) || out_decision == NULL ||
|
||||
!research_timestamp(request->now)) {
|
||||
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
|
||||
"Requête de policy de recherche invalide.");
|
||||
return FALSE;
|
||||
}
|
||||
sqlite3_stmt *query = NULL;
|
||||
gboolean ok = sqlite3_prepare_v2(store->db,
|
||||
"SELECT g.investigation_id,g.plan_fingerprint,g.expires_at,g.revoked_at,"
|
||||
"g.max_requests,g.max_response_bytes,g.max_active_ms,"
|
||||
"EXISTS(SELECT 1 FROM scope_grant_exclusions e WHERE e.grant_id=g.grant_id AND e.subject=?2) "
|
||||
"FROM scope_grants g WHERE g.grant_id=?1;", -1, &query, NULL) ==
|
||||
SQLITE_OK && bind_text(query, 1, grant_id) &&
|
||||
bind_text(query, 2, request->action->subject) &&
|
||||
sqlite3_step(query) == SQLITE_ROW;
|
||||
if (!ok) {
|
||||
sqlite3_finalize(query);
|
||||
job_error(error, G_IO_ERROR_NOT_FOUND, "ScopeGrant de policy introuvable.");
|
||||
return FALSE;
|
||||
}
|
||||
const char *owner = (const char *)sqlite3_column_text(query, 0);
|
||||
const char *plan = (const char *)sqlite3_column_text(query, 1);
|
||||
const char *expires = (const char *)sqlite3_column_text(query, 2);
|
||||
GDateTime *now_value = g_date_time_new_from_iso8601(request->now, NULL);
|
||||
GDateTime *expires_value = g_date_time_new_from_iso8601(expires, NULL);
|
||||
if (now_value == NULL || expires_value == NULL) {
|
||||
if (now_value != NULL)
|
||||
g_date_time_unref(now_value);
|
||||
if (expires_value != NULL)
|
||||
g_date_time_unref(expires_value);
|
||||
sqlite3_finalize(query);
|
||||
job_error(error, G_IO_ERROR_INVALID_DATA,
|
||||
"Horodatage persistant du ScopeGrant invalide.");
|
||||
return FALSE;
|
||||
}
|
||||
gboolean expired = g_date_time_compare(now_value, expires_value) >= 0;
|
||||
g_date_time_unref(now_value);
|
||||
g_date_time_unref(expires_value);
|
||||
gboolean revoked = sqlite3_column_type(query, 3) != SQLITE_NULL;
|
||||
guint64 max_requests = (guint64)sqlite3_column_int64(query, 4);
|
||||
guint64 max_bytes = (guint64)sqlite3_column_int64(query, 5);
|
||||
guint64 max_ms = (guint64)sqlite3_column_int64(query, 6);
|
||||
gboolean excluded = sqlite3_column_int(query, 7) != 0;
|
||||
ResearchPolicyDecision decision = RESEARCH_POLICY_ALLOW;
|
||||
if (excluded)
|
||||
decision = RESEARCH_POLICY_DENY_EXCLUDED;
|
||||
else if (revoked)
|
||||
decision = RESEARCH_POLICY_DENY_REVOKED;
|
||||
else if (expired)
|
||||
decision = RESEARCH_POLICY_DENY_EXPIRED;
|
||||
else if (g_strcmp0(owner, store->investigation_id) != 0 ||
|
||||
g_strcmp0(owner, request->investigation_id) != 0)
|
||||
decision = RESEARCH_POLICY_DENY_INVESTIGATION;
|
||||
else if (g_strcmp0(plan, request->plan_fingerprint) != 0)
|
||||
decision = RESEARCH_POLICY_DENY_PLAN;
|
||||
else if (request->requested_requests > max_requests ||
|
||||
request->requested_response_bytes > max_bytes ||
|
||||
request->requested_active_ms > max_ms)
|
||||
decision = RESEARCH_POLICY_DENY_BUDGET;
|
||||
sqlite3_finalize(query);
|
||||
if (decision == RESEARCH_POLICY_ALLOW) {
|
||||
const ResearchAction *action = request->action;
|
||||
sqlite3_stmt *selected = NULL;
|
||||
ok = sqlite3_prepare_v2(store->db,
|
||||
"SELECT a.capability_id,a.contact_class,a.disclosure FROM scope_grant_actions ga "
|
||||
"JOIN research_actions a ON a.action_id=ga.action_id WHERE ga.grant_id=?1 "
|
||||
"AND ga.action_id=?2 AND ga.subject=?3 AND ga.provider_id=?4 AND ga.endpoint=?5 "
|
||||
"AND EXISTS(SELECT 1 FROM research_action_decisions d "
|
||||
"WHERE d.action_id=a.action_id AND d.decision_code='AUTHORIZE') "
|
||||
"AND (a.rank!=1 OR EXISTS(SELECT 1 FROM research_actions first "
|
||||
"JOIN research_action_decisions fd ON fd.action_id=first.action_id "
|
||||
"JOIN research_results rr ON rr.action_id=first.action_id "
|
||||
"JOIN research_receipts rc ON rc.result_id=rr.result_id "
|
||||
"WHERE first.plan_id=a.plan_id AND first.rank=0 "
|
||||
"AND fd.decision_code='AUTHORIZE'));",
|
||||
-1, &selected, NULL) == SQLITE_OK && bind_text(selected, 1, grant_id) &&
|
||||
bind_text(selected, 2, action->action_id) &&
|
||||
bind_text(selected, 3, action->subject) &&
|
||||
bind_text(selected, 4, action->provider_id) &&
|
||||
bind_text(selected, 5, action->endpoint);
|
||||
int step = ok ? sqlite3_step(selected) : SQLITE_ERROR;
|
||||
if (step == SQLITE_DONE)
|
||||
decision = RESEARCH_POLICY_DENY_ACTION;
|
||||
else if (step != SQLITE_ROW)
|
||||
ok = FALSE;
|
||||
else if (g_strcmp0((const char *)sqlite3_column_text(selected, 0),
|
||||
action->capability_id) != 0 ||
|
||||
g_strcmp0((const char *)sqlite3_column_text(selected, 1),
|
||||
research_contact_code(action->contact)) != 0 ||
|
||||
g_strcmp0((const char *)sqlite3_column_text(selected, 2),
|
||||
action->disclosure) != 0)
|
||||
decision = RESEARCH_POLICY_DENY_ACTION_CONTENT;
|
||||
sqlite3_finalize(selected);
|
||||
}
|
||||
if (!ok) {
|
||||
job_error(error, G_IO_ERROR_FAILED, "Évaluation de policy impossible.");
|
||||
return FALSE;
|
||||
}
|
||||
*out_decision = decision;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean local_job_store_export_atomic(LocalJobStore *s, const char *path,
|
||||
GError **error) {
|
||||
GPtrArray *jobs = local_job_store_list(s, error);
|
||||
|
|
|
|||
26
src/core/research_adapter_cdx.c
Normal file
26
src/core/research_adapter_cdx.c
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#include "core/research_adapter_cdx.h"
|
||||
#include <string.h>
|
||||
static gboolean timestamp(const char *s){if(!s||strlen(s)!=14)return FALSE;for(guint i=0;i<14;i++)if(!g_ascii_isdigit(s[i]))return FALSE;return TRUE;}
|
||||
static const char *host_of(const char *url,GUri **owned){*owned=g_uri_parse(url,G_URI_FLAGS_NONE,NULL);return *owned?g_uri_get_host(*owned):NULL;}
|
||||
gboolean research_adapter_cdx_normalize(const ResearchAction *a,ResearchCdxMatch match,const ResearchCdxFixture *f,ResearchCdxResult *out,GError **error){
|
||||
if (!a || !a->subject || !f || !out || match > RESEARCH_CDX_EXACT_HOST ||
|
||||
(f->row_count > 0 && f->rows == NULL)) {
|
||||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
|
||||
"Réponse CDX invalide.");
|
||||
return FALSE;
|
||||
}
|
||||
ResearchCdxResult r={.captures=g_ptr_array_new_with_free_func(g_free)};
|
||||
GUri *subject_uri=NULL;const char *subject_host=match==RESEARCH_CDX_EXACT_HOST?host_of(a->subject,&subject_uri):NULL;
|
||||
if(match==RESEARCH_CDX_EXACT_HOST&&!subject_host)subject_host=a->subject;
|
||||
for(gsize i=0;i<f->row_count;i++){const ResearchCdxRow *row=&f->rows[i];GUri *u=NULL;const char *h=host_of(row->original_url,&u);
|
||||
gboolean exact=match==RESEARCH_CDX_EXACT_URL?g_strcmp0(row->original_url,a->subject)==0:(h&&g_ascii_strcasecmp(h,subject_host)==0);
|
||||
if(!exact||!timestamp(row->timestamp)||!row->digest){if(u)g_uri_unref(u);if(subject_uri)g_uri_unref(subject_uri);research_cdx_result_clear(&r);g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_DATA,"Capture CDX hors sujet ou date invalide.");return FALSE;}
|
||||
g_ptr_array_add(r.captures,g_strdup_printf("%s\t%s\t%u\t%s",row->timestamp,row->original_url,row->status_code,row->digest));if(u)g_uri_unref(u);}
|
||||
if (subject_uri)
|
||||
g_uri_unref(subject_uri);
|
||||
r.next_cursor = g_strdup(f->next_cursor);
|
||||
r.next_piste = r.captures->len > 0;
|
||||
*out = r;
|
||||
return TRUE;
|
||||
}
|
||||
void research_cdx_result_clear(ResearchCdxResult *r){if(!r)return;g_clear_pointer(&r->captures,g_ptr_array_unref);g_free(r->next_cursor);*r=(ResearchCdxResult){0};}
|
||||
48
src/core/research_adapter_dns.c
Normal file
48
src/core/research_adapter_dns.c
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
#include "core/research_adapter_dns.h"
|
||||
#include <string.h>
|
||||
static gboolean dns_name(const char *s) {
|
||||
if (!s || !*s || strlen(s) > 253 || strchr(s, '\\') || strchr(s, '@')) return FALSE;
|
||||
for (const unsigned char *p=(const unsigned char *)s; *p; p++)
|
||||
if (*p < 0x21 || *p == 0x7f) return FALSE;
|
||||
return TRUE;
|
||||
}
|
||||
static char *record_string(const ResearchDnsRecord *r) {
|
||||
return g_strdup_printf("%s\t%u\t%u\t%s", r->owner, r->ttl,
|
||||
r->preference, r->value);
|
||||
}
|
||||
gboolean research_adapter_dns_normalize(const ResearchAction *action,
|
||||
ResearchDnsType requested_type, const ResearchDnsFixture *fixture,
|
||||
ResearchDnsResult *out, GError **error) {
|
||||
if (!action || !dns_name(action->subject) || !fixture || !out ||
|
||||
(fixture->record_count > 0 && fixture->records == NULL) ||
|
||||
requested_type > RESEARCH_DNS_PTR || fixture->status > RESEARCH_DNS_STATUS_TIMEOUT) {
|
||||
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_ARGUMENT,"Réponse DNS typée invalide."); return FALSE;
|
||||
}
|
||||
ResearchDnsResult result={.status=fixture->status,
|
||||
.records=g_ptr_array_new_with_free_func(g_free)};
|
||||
GHashTable *edges=g_hash_table_new_full(g_str_hash,g_str_equal,g_free,g_free);
|
||||
for (gsize i=0;i<fixture->record_count;i++) {
|
||||
const ResearchDnsRecord *r=&fixture->records[i];
|
||||
if (r->type != requested_type || !dns_name(r->owner) || !r->value || !*r->value) {
|
||||
g_hash_table_unref(edges); research_dns_result_clear(&result);
|
||||
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_DATA,"Enregistrement DNS incohérent."); return FALSE;
|
||||
}
|
||||
g_ptr_array_add(result.records,record_string(r));
|
||||
if (r->type == RESEARCH_DNS_CNAME) g_hash_table_replace(edges,g_ascii_strdown(r->owner,-1),g_ascii_strdown(r->value,-1));
|
||||
}
|
||||
if (requested_type == RESEARCH_DNS_CNAME) {
|
||||
GHashTableIter iter; gpointer start;
|
||||
g_hash_table_iter_init(&iter,edges);
|
||||
while (g_hash_table_iter_next(&iter,&start,NULL)) {
|
||||
GHashTable *seen=g_hash_table_new(g_str_hash,g_str_equal); const char *cur=start;
|
||||
while (cur && g_hash_table_add(seen,(gpointer)cur)) cur=g_hash_table_lookup(edges,cur);
|
||||
if (cur) result.cname_cycle=TRUE;
|
||||
g_hash_table_unref(seen);
|
||||
}
|
||||
}
|
||||
g_hash_table_unref(edges);
|
||||
/* INVARIANT: une cible CNAME est une piste, jamais une propagation de scope. */
|
||||
result.next_piste=fixture->status==RESEARCH_DNS_STATUS_OK && result.records->len>0 && !result.cname_cycle;
|
||||
*out=result; return TRUE;
|
||||
}
|
||||
void research_dns_result_clear(ResearchDnsResult *r) { if (!r) return; g_clear_pointer(&r->records,g_ptr_array_unref); *r=(ResearchDnsResult){0}; }
|
||||
19
src/core/research_adapter_page.c
Normal file
19
src/core/research_adapter_page.c
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#include "core/research_adapter_page.h"
|
||||
#include <string.h>
|
||||
static gboolean append_bounded(GString *s,char c,gsize max){if(s->len>=max)return FALSE;g_string_append_c(s,c);return TRUE;}
|
||||
gboolean research_adapter_page_normalize(const ResearchAction *a,const ResearchPageFixture *f,gsize max,ResearchPageResult *out,GError **error){
|
||||
if(!a||!a->subject||!f||!f->content_type||!f->body||!max||f->body_size>a->max_response_bytes||!g_utf8_validate((const char*)f->body,f->body_size,NULL)){
|
||||
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_ARGUMENT,"Page absente, trop grande ou non UTF-8.");return FALSE;}
|
||||
gboolean html=g_ascii_strncasecmp(f->content_type,"text/html",9)==0,text=g_ascii_strncasecmp(f->content_type,"text/plain",10)==0;
|
||||
if(!html&&!text){g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_NOT_SUPPORTED,"Type de page non inerte refusé.");return FALSE;}
|
||||
GString *s=g_string_new(NULL);gboolean tag=FALSE,space=FALSE;
|
||||
for(gsize i=0;i<f->body_size;i++){char c=(char)f->body[i];if(html&&c=='<'){tag=TRUE;space=TRUE;continue;}if(html&&tag){if(c=='>')tag=FALSE;continue;}if(g_ascii_isspace(c)){if(!space&&s->len&&!append_bounded(s,' ',max))goto large;space=TRUE;}else{if(!append_bounded(s,c,max))goto large;space=FALSE;}}
|
||||
g_strstrip(s->str);
|
||||
s->len = strlen(s->str);
|
||||
gboolean has_text = s->len > 0;
|
||||
char *inert_text = g_string_free(s, FALSE);
|
||||
*out = (ResearchPageResult){inert_text, html, TRUE, has_text};
|
||||
return TRUE;
|
||||
large:g_string_free(s,TRUE);g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_NO_SPACE,"Texte de page hors limite.");return FALSE;
|
||||
}
|
||||
void research_page_result_clear(ResearchPageResult *r){if(!r)return;g_free(r->inert_text);*r=(ResearchPageResult){0};}
|
||||
43
src/core/research_adapter_rdap.c
Normal file
43
src/core/research_adapter_rdap.c
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#include "core/research_adapter_rdap.h"
|
||||
#include <string.h>
|
||||
static gboolean domain_suffix(const char *subject,const char *suffix) {
|
||||
gsize a=strlen(subject),b=strlen(suffix); if (!b || b>a) return FALSE;
|
||||
return g_ascii_strcasecmp(subject+a-b,suffix)==0 && (a==b || subject[a-b-1]=='.');
|
||||
}
|
||||
static gboolean ip_prefix(const char *subject,const char *prefix,guint *bits) {
|
||||
char **p=g_strsplit(prefix,"/",2); GInetAddress *a=g_inet_address_new_from_string(subject),*n=g_inet_address_new_from_string(p[0]);
|
||||
char *end=NULL; guint64 count=p[1]?g_ascii_strtoull(p[1],&end,10):999;
|
||||
gboolean ok=a&&n&&g_inet_address_get_family(a)==g_inet_address_get_family(n);
|
||||
guint max=ok&&g_inet_address_get_family(a)==G_SOCKET_FAMILY_IPV4?32:128;
|
||||
ok=ok&&p[1]&&end&&*end=='\0'&&count<=max;
|
||||
if(ok){const guint8 *x=g_inet_address_to_bytes(a),*y=g_inet_address_to_bytes(n); guint full=count/8,rem=count%8; ok=memcmp(x,y,full)==0 && (!rem || (x[full]>>(8-rem))==(y[full]>>(8-rem)));}
|
||||
if (ok)
|
||||
*bits = (guint)count;
|
||||
g_clear_object(&a);
|
||||
g_clear_object(&n);
|
||||
g_strfreev(p);
|
||||
return ok;
|
||||
}
|
||||
gboolean research_adapter_rdap_select(const ResearchAction *action,
|
||||
ResearchRdapServiceKind kind,const ResearchRdapBootstrap *b,ResearchRdapResult *out,GError **error){
|
||||
if (!action || !action->subject || !b || !out || !b->fetched_at ||
|
||||
!b->expires_at || (b->entry_count > 0 && b->entries == NULL)) {
|
||||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
|
||||
"Bootstrap RDAP invalide.");
|
||||
return FALSE;
|
||||
}
|
||||
const ResearchRdapBootstrapEntry *best=NULL;guint score=0;
|
||||
for(gsize i=0;i<b->entry_count;i++){const ResearchRdapBootstrapEntry *e=&b->entries[i];guint s=0;gboolean match=FALSE;
|
||||
if(e->kind!=kind||!e->prefix||!e->base_url)continue;
|
||||
if(kind==RESEARCH_RDAP_DOMAIN_SERVICE){match=domain_suffix(action->subject,e->prefix);s=(guint)strlen(e->prefix)*8;}else match=ip_prefix(action->subject,e->prefix,&s);
|
||||
if(match&&(!best||s>score)){best=e;score=s;}}
|
||||
if (!best) {
|
||||
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND,
|
||||
"Service RDAP bootstrap introuvable.");
|
||||
return FALSE;
|
||||
}
|
||||
*out = (ResearchRdapResult){g_strdup(best->base_url), score,
|
||||
g_strdup(b->fetched_at), g_strdup(b->expires_at), g_strdup(b->etag), TRUE};
|
||||
return TRUE;
|
||||
}
|
||||
void research_rdap_result_clear(ResearchRdapResult *r){if(!r)return;g_free(r->service_url);g_free(r->cache_fetched_at);g_free(r->cache_expires_at);g_free(r->cache_etag);*r=(ResearchRdapResult){0};}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue