Compare commits

...

3 commits

162 changed files with 8840 additions and 48384 deletions

View file

@ -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
View file

@ -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)

View file

@ -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.

View file

@ -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).

View file

@ -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.

View file

@ -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

View file

@ -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é.

View 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.

View file

@ -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
View 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.

View file

@ -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).

View file

@ -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

View file

@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 */

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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é |

View file

@ -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"

File diff suppressed because it is too large Load diff

View file

@ -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>

View file

@ -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>

View file

@ -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.";
});

View file

@ -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; } }

View file

@ -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)]))

View 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;
}

View 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 });
});
}

View 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();
});
}

View 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();
}

View 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()

View file

@ -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");

View file

@ -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 });

View 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 })));
}

View file

@ -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

View file

@ -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});}}

View file

@ -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");

View file

@ -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"):

View file

@ -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&&current===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});

View 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 });
}

View 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()

View 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 });
}
});

View 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()

View 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 })));
}

View 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 });
}

View 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()

View 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 })));
}

View file

@ -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,

View file

@ -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);

View file

@ -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,

View file

@ -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", {},

View 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())

View 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)

View file

@ -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

View file

@ -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);

View 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};}

View 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}; }

View 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};}

View 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