labfy-investigation/tools/local_jobs.c

2395 lines
125 KiB
C
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "core/core_graph_projection_service.h"
#include "core/core_graph_snapshot_serializer.h"
#include "core/eml_analysis_persistence_service.h"
#include "core/evidence_importer.h"
#include "core/evidence_preview.h"
#include "core/file_hash.h"
#include "core/exiftool_persistence_service.h"
#include "core/local_capability_registry.h"
#include "core/local_job_store.h"
#include "core/local_correlation_service.h"
#include "core/local_planner_service.h"
#include "core/local_report_service.h"
#include "core/local_tool_runner.h"
#include "core/observation_review_service.h"
#include "core/research_store.h"
#include "dao/evidence_entity_dao.h"
#include "dao/evidence_dao.h"
#include "dao/extraction_dao.h"
#include "dao/investigation_dao.h"
#include "database/database.h"
#include "database/statement.h"
#include "models/evidence_record.h"
#include "models/investigation_record.h"
#include <fcntl.h>
#include <errno.h>
#include <glib/gstdio.h>
#include <json-glib/json-glib.h>
#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sqlite3.h>
#define EML_JOB "82000000-0000-4000-8000-000000000011"
#define EML_REQUEST "82000000-0000-4000-8000-000000000012"
#define EML_DERIVATIVE "82000000-0000-4000-8000-000000000013"
#define EXIF_JOB "82000000-0000-4000-8000-000000000021"
#define EXIF_REQUEST "82000000-0000-4000-8000-000000000022"
#define EXIF_DERIVATIVE "82000000-0000-4000-8000-000000000023"
static const char *const timestamp = "2026-09-26T21:00:00Z";
static const char *const eml_content =
"From: Alice SPECIMEN <alice@example.test>\r\n"
"To: Bob SPECIMEN <bob@example.test>\r\n"
"Received: from relay.example.test ([192.0.2.80]) by mx.example.test;\r\n"
"Message-ID: <j5@example.test>\r\nDate: Sat, 26 Sep 2026 21:00:00 +0200\r\n"
"Subject: Reprise J5\r\n\r\nContenu entièrement synthétique.\r\n";
static const char *const png_base64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+"
"A8AAQUBAScY42YAAAAASUVORK5CYII=";
static const char *const j7_eml_a =
"From: Alice SPECIMEN <alice@example.test>\r\n"
"To: Pivot SPECIMEN <pivot@shared.test>\r\n"
"Message-ID: <j7-a@shared.test>\r\n"
"Received: from relay.shared.test ([192.0.2.44]) by mx.shared.test;\r\n"
"Subject: Homonyme SPECIMEN\r\n\r\nCorpus synthétique J7.\r\n";
static const char *const j7_eml_b =
"From: Alice SPECIMEN <other@shared.test>\r\n"
"To: alice@example.test, alice@example.test\r\n"
"Message-ID: <j7-b@shared.test>\r\n"
"Received: from edge.shared.test ([192.0.2.44]) by mx.other.test;\r\n"
"Subject: Répétition SPECIMEN\r\n\r\nCorpus synthétique J7.\r\n";
static const char *const j7_eml_c =
"From: Homonyme SPECIMEN <unrelated@shared.test>\r\n"
"To: distinct@example.invalid\r\n"
"Message-ID: <j7-c@unrelated.test>\r\n"
"Received: from shared.test ([198.51.100.9]) by mx.unrelated.test;\r\n"
"Subject: Domaine seulement SPECIMEN\r\n\r\nAucune identité commune déduite.\r\n";
typedef struct {
char *investigation_id;
char *eml_id;
char *image_id;
char *title;
gboolean synthetic;
} Manifest;
static gboolean manifest_read(const char *root, Manifest *manifest,
GError **error);
static void manifest_clear(Manifest *manifest);
static LocalJobStore *open_store(const char *root, Manifest *manifest,
GError **error);
static gboolean write_private_atomic(const char *path, const char *contents,
GError **error);
/* CONTRACT: research commands accept identifiers and revisions only. The
* laboratory provider authority is process configuration, never UI input. */
static gboolean research_prepare_json(const char *root, const char *selection,
const char *question, const char *exclusions, const char *revision,
const char *key, GError **error);
static gboolean research_grant_json(const char *root, const char *plan_id,
const char *revision, const char *actions, const char *decisions,
const char *exclusions,
const char *key, GError **error);
static gboolean research_campaign_json(const char *root, const char *grant_id,
const char *revision, const char *actions, const char *key, GError **error);
static gboolean research_snapshot_json(const char *root, GError **error);
static gboolean research_revoke_json(const char *root, const char *grant_id,
GError **error);
static void json_add_nullable(JsonBuilder *builder, const char *name,
const char *value) {
json_builder_set_member_name(builder, name);
if (value != NULL) json_builder_add_string_value(builder, value);
else json_builder_add_null_value(builder);
}
static gboolean print_json_builder(JsonBuilder *builder, GError **error) {
JsonNode *root = json_builder_get_root(builder);
JsonGenerator *generator = json_generator_new();
json_generator_set_root(generator, root);
char *data = json_generator_to_data(generator, NULL);
gboolean ok = data != NULL && printf("%s\n", data) >= 0;
if (!ok) g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED,
"Impossible d'écrire la réponse JSON.");
g_free(data); g_object_unref(generator); json_node_free(root);
return ok;
}
static volatile sig_atomic_t worker_stop_requested = 0;
typedef struct { GMutex mutex; GCond condition; gboolean finished;
guint timeout_ms; GCancellable *cancellable; } BudgetTimer;
static gpointer budget_timer_run(gpointer data) {
BudgetTimer *timer=data; gint64 deadline=g_get_monotonic_time()+
(gint64)timer->timeout_ms*1000;
g_mutex_lock(&timer->mutex);
while(!timer->finished && g_cond_wait_until(&timer->condition,&timer->mutex,deadline));
gboolean expired=!timer->finished; g_mutex_unlock(&timer->mutex);
if (expired)
g_cancellable_cancel(timer->cancellable);
return NULL;
}
static void worker_stop_handler(int signal_number) {
(void)signal_number;
worker_stop_requested = 1;
local_tool_runner_request_process_cancel();
}
static char *workspace_path(const char *root, const char *leaf) {
return g_build_filename(root, leaf, NULL);
}
static gboolean validate_workspace_json(const char *root, GError **error) {
char *database_path = workspace_path(root, "Enquete.sqlite");
/* WHY: discovery is privacy-sensitive and read-only. This API deliberately
* bypasses manifest/export code so validation cannot migrate the database or
* publish derived snapshots as a side effect. */
Database *database = database_open_read_only(database_path, error);
InvestigationRecord *record = database != NULL
? investigation_dao_load(database) : NULL;
const char *identifier = record != NULL
? investigation_record_get_id(record) : NULL;
const char *title = record != NULL
? investigation_record_get_name(record) : NULL;
gboolean ok = identifier != NULL && g_uuid_string_is_valid(identifier) &&
title != NULL && title[0] != '\0';
char *jobs_path = workspace_path(root, ".labfy/runtime/jobs.sqlite");
LocalJobStore *store = ok
? local_job_store_open(jobs_path, identifier, TRUE, error) : NULL;
ok = ok && store != NULL;
if (!ok && database != NULL && error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"La base ne fournit pas une enquête valide.");
if (ok) {
JsonBuilder *builder = json_builder_new();
json_builder_begin_object(builder);
json_builder_set_member_name(builder, "contract");
json_builder_add_string_value(builder, "labfy.local_workspace.validation.v1");
json_builder_set_member_name(builder, "workspace_id");
json_builder_add_string_value(builder, identifier);
json_builder_set_member_name(builder, "title");
json_builder_add_string_value(builder, title);
json_builder_end_object(builder);
ok = print_json_builder(builder, error);
g_object_unref(builder);
}
investigation_record_free(record);
local_job_store_close(store);
database_close(database);
g_free(jobs_path);
g_free(database_path);
return ok;
}
static char *now_iso(void) {
GDateTime *now = g_date_time_new_now_utc();
char *value = g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ");
g_date_time_unref(now);
return value;
}
static gboolean export_correlations(const char *root, Database *database,
const char *investigation_id,
GError **error) {
LocalCorrelationLimits limits = {2000U, 500U, 1000U, 1024U * 1024U};
GBytes *snapshot = local_correlation_service_build(
database, investigation_id, limits, error);
char *path = workspace_path(root, "correlation-snapshot.json");
gboolean ok = snapshot != NULL &&
local_correlation_snapshot_write_atomic(snapshot, path, error);
g_clear_pointer(&snapshot, g_bytes_unref);
g_free(path);
return ok;
}
static gboolean export_planner(const char *root, Database *database,
LocalJobStore *store,
LocalCapabilityRegistry *registry,
const char *investigation_id, GError **error) {
GBytes *snapshot = local_planner_service_build(database, store, registry,
investigation_id, 1024U * 1024U, error);
char *path = workspace_path(root, "planner-snapshot.json");
gboolean ok = snapshot != NULL &&
local_planner_snapshot_write_atomic(snapshot, path, error);
g_clear_pointer(&snapshot, g_bytes_unref); g_free(path); return ok;
}
static gboolean prepare_report(const char *root, const char *objects_csv,
const char *title, const char *comment, const char *generated_at,
const char *sections,
GError **error) {
if (objects_csv == NULL || title == NULL || generated_at == NULL) {
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_ARGUMENT,
"Paramètres du rapport incomplets."); return FALSE;
}
gchar **objects=g_strsplit(objects_csv,",",65);gsize count=g_strv_length(objects);
Manifest manifest={0};gboolean ok=count>0U&&count<=64U&&manifest_read(root,&manifest,error);
char *database_path=workspace_path(root,"Enquete.sqlite");
Database *database=ok?database_open_read_only(database_path,error):NULL;
gboolean evidence=sections!=NULL&&strstr(sections,"evidence")!=NULL;
gboolean timeline=sections!=NULL&&strstr(sections,"timeline")!=NULL;
gboolean infrastructure=sections!=NULL&&strstr(sections,"infrastructure")!=NULL;
if(!evidence&&!timeline&&!infrastructure){g_set_error_literal(error,G_IO_ERROR,
G_IO_ERROR_INVALID_ARGUMENT,"Sections de rapport invalides.");ok=FALSE;}
LocalReportRequest request={title,comment,"MINIMAL",generated_at,
(const char *const *)objects,count,evidence,timeline,infrastructure};
GBytes *document=database!=NULL?local_report_service_build(database,
manifest.investigation_id,&request,(LocalReportLimits){64U,256U,256U,
1024U*1024U},error):NULL;
if(document!=NULL){gsize size=0U;const char *data=g_bytes_get_data(document,&size);
ok=fwrite(data,1,size,stdout)==size&&fputc('\n',stdout)!=EOF;}else ok=FALSE;
g_clear_pointer(&document,g_bytes_unref);database_close(database);
g_free(database_path);manifest_clear(&manifest);g_strfreev(objects);return ok;
}
static char *derived_uuid(const char *key, const char *purpose) {
char *input = g_strconcat(key, ":", purpose, NULL);
char *hash = g_compute_checksum_for_string(G_CHECKSUM_SHA256, input, -1);
char *uuid = g_strdup_printf("%.8s-%.4s-4%.3s-8%.3s-%.12s", hash, hash + 8,
hash + 12, hash + 15, hash + 18);
g_free(hash);
g_free(input);
return uuid;
}
static void manifest_clear(Manifest *manifest) {
g_free(manifest->investigation_id);
g_free(manifest->eml_id);
g_free(manifest->image_id);
g_free(manifest->title);
memset(manifest, 0, sizeof(*manifest));
}
static gboolean manifest_write(const char *root, const Manifest *manifest,
GError **error) {
char *path = workspace_path(root, ".labfy/runtime/specimen.json");
char *data = g_strdup_printf(
"{\"contract\":\"labfy.local_jobs.specimen.v1\",\"synthetic\":true,"
"\"snapshot_file\":\"core-snapshot.json\","
"\"snapshot_contract\":\"labfy.web_graph.snapshot.v3\","
"\"investigation_id\":\"%s\",\"eml_id\":\"%s\","
"\"image_id\":\"%s\"}\n",
manifest->investigation_id, manifest->eml_id, manifest->image_id);
gboolean ok =
g_file_set_contents(path, data, -1, error) && g_chmod(path, 0600) == 0;
g_free(data);
g_free(path);
return ok;
}
static gboolean manifest_read(const char *root, Manifest *manifest,
GError **error) {
char *path = workspace_path(root, ".labfy/runtime/workspace.json");
gboolean generic = g_file_test(path, G_FILE_TEST_IS_REGULAR);
if (!generic) {
g_free(path);
path = workspace_path(root, ".labfy/runtime/specimen.json");
}
JsonParser *parser = json_parser_new();
gboolean ok = json_parser_load_from_file(parser, path, error);
if (ok) {
JsonObject *object = json_node_get_object(json_parser_get_root(parser));
ok = object != NULL && g_strcmp0(json_object_get_string_member(object,
"contract"), generic ? "labfy.local_workspace.v1"
: "labfy.local_jobs.specimen.v1") == 0;
if (ok) {
manifest->investigation_id =
g_strdup(json_object_get_string_member(object, "investigation_id"));
manifest->synthetic = !generic;
manifest->title = g_strdup(generic
? json_object_get_string_member(object, "title") : "SPECIMEN J5");
if (!generic) {
manifest->eml_id = g_strdup(json_object_get_string_member(object, "eml_id"));
manifest->image_id = g_strdup(json_object_get_string_member(object, "image_id"));
}
ok = g_uuid_string_is_valid(manifest->investigation_id) &&
manifest->title != NULL && manifest->title[0] != '\0';
}
}
if (!ok && error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"Contexte d’espace local invalide.");
g_object_unref(parser);
g_free(path);
return ok;
}
static gboolean create_workspace(const char *root, const char *title,
GError **error) {
if (title == NULL || !g_utf8_validate(title, -1, NULL) ||
title[0] == '\0' || strlen(title) > 160U) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"Titre d’enquête invalide.");
return FALSE;
}
char *runtime = workspace_path(root, ".labfy/runtime");
char *database_path = workspace_path(root, "Enquete.sqlite");
char *manifest_path = workspace_path(root, ".labfy/runtime/workspace.json");
char *lock_path = workspace_path(root, ".labfy/lifecycle.lock");
char *jobs_path = workspace_path(root, ".labfy/runtime/jobs.sqlite");
char *snapshot_path = workspace_path(root, "core-snapshot.json");
char *correlation_path = workspace_path(root, "correlation-snapshot.json");
char *planner_path = workspace_path(root, "planner-snapshot.json");
gboolean database_existed = g_file_test(database_path, G_FILE_TEST_EXISTS);
gboolean manifest_existed = g_file_test(manifest_path, G_FILE_TEST_EXISTS);
gboolean jobs_existed = g_file_test(jobs_path, G_FILE_TEST_EXISTS);
gboolean snapshot_existed = g_file_test(snapshot_path, G_FILE_TEST_EXISTS);
gboolean correlation_existed = g_file_test(correlation_path, G_FILE_TEST_EXISTS);
gboolean planner_existed = g_file_test(planner_path, G_FILE_TEST_EXISTS);
gboolean database_owned = FALSE;
gboolean manifest_owned = FALSE;
gboolean jobs_owned = FALSE;
gboolean snapshot_owned = FALSE;
gboolean correlation_owned = FALSE;
gboolean planner_owned = FALSE;
int lock_fd = -1;
/* WHY: an obvious partial workspace is rejected before even creating the
* lifecycle-lock parent. The same predicate is evaluated again under the
* lock below to close the interprocess admission window. */
if (database_existed || manifest_existed || jobs_existed ||
snapshot_existed || correlation_existed || planner_existed) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_EXISTS,
"L’espace est déjà créé ou partiellement créé.");
g_free(planner_path);g_free(correlation_path);g_free(snapshot_path);
g_free(jobs_path);g_free(lock_path);g_free(manifest_path);
g_free(database_path);g_free(runtime);
return FALSE;
}
gboolean ok = g_mkdir_with_parents(runtime, 0700) == 0;
if (ok) lock_fd = open(lock_path, O_CREAT | O_RDWR, 0600);
if (lock_fd >= 0) (void)fcntl(lock_fd, F_SETFD, FD_CLOEXEC);
if (lock_fd < 0 || flock(lock_fd, LOCK_EX | LOCK_NB) != 0) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_BUSY,
"Un propriétaire utilise déjà le cycle de vie de cet espace.");
ok = FALSE;
}
/* CONTRACT: every managed artifact participates in the creation admission.
* An orphan store or export is evidence of a partial/foreign workspace, not
* scratch space that a new creation may open, truncate or replace. */
if (ok && (g_file_test(database_path, G_FILE_TEST_EXISTS) ||
g_file_test(manifest_path, G_FILE_TEST_EXISTS) ||
g_file_test(jobs_path, G_FILE_TEST_EXISTS) ||
g_file_test(snapshot_path, G_FILE_TEST_EXISTS) ||
g_file_test(correlation_path, G_FILE_TEST_EXISTS) ||
g_file_test(planner_path, G_FILE_TEST_EXISTS))) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_EXISTS,
"L’espace est déjà créé ou partiellement créé.");
ok = FALSE;
}
if (ok) {
ok = database_initialize(database_path, title, root);
database_owned = !database_existed &&
g_file_test(database_path, G_FILE_TEST_IS_REGULAR);
}
Database *database = ok ? database_open(database_path) : NULL;
InvestigationRecord *record = database != NULL
? investigation_dao_load(database) : NULL;
const char *id = record != NULL ? investigation_record_get_id(record) : NULL;
JsonBuilder *builder = ok ? json_builder_new() : NULL;
JsonGenerator *generator = NULL;
JsonNode *node = NULL;
char *data = NULL;
if (ok && database != NULL && id != NULL) {
json_builder_begin_object(builder);
json_builder_set_member_name(builder,"contract");json_builder_add_string_value(builder,"labfy.local_workspace.v1");
json_builder_set_member_name(builder,"version");json_builder_add_int_value(builder,1);
json_builder_set_member_name(builder,"investigation_id");json_builder_add_string_value(builder,id);
json_builder_set_member_name(builder,"title");json_builder_add_string_value(builder,title);
json_builder_set_member_name(builder,"mode");json_builder_add_string_value(builder,"local_experimental");
json_builder_set_member_name(builder,"database");json_builder_add_string_value(builder,"Enquete.sqlite");
json_builder_end_object(builder);
generator=json_generator_new();node=json_builder_get_root(builder);
json_generator_set_root(generator,node);data=json_generator_to_data(generator,NULL);
ok = data != NULL && write_private_atomic(manifest_path, data, error);
manifest_owned = ok && !manifest_existed;
} else if (ok) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"La base créée ne fournit pas d’identité d’enquête.");
ok = FALSE;
}
LocalJobStore *store = ok ? local_job_store_create(jobs_path, id, error) : NULL;
jobs_owned = !jobs_existed && g_file_test(jobs_path, G_FILE_TEST_IS_REGULAR);
ok = ok && store != NULL;
if (ok) {
LocalCapabilityRegistry *registry = local_capability_registry_new(error);
CoreGraphLimits limits = {500U, 1000U, 1024U * 1024U};
CoreGraphSnapshot *snapshot = registry != NULL
? core_graph_projection_service_collect_operational(database, limits,
registry, error)
: NULL;
ok = snapshot != NULL && core_graph_snapshot_write_atomic(
snapshot, FALSE, snapshot_path, error) &&
export_correlations(root, database, id, error) &&
export_planner(root, database, store, registry, id, error);
snapshot_owned = !snapshot_existed &&
g_file_test(snapshot_path, G_FILE_TEST_IS_REGULAR);
correlation_owned = !correlation_existed &&
g_file_test(correlation_path, G_FILE_TEST_IS_REGULAR);
planner_owned = !planner_existed &&
g_file_test(planner_path, G_FILE_TEST_IS_REGULAR);
core_graph_snapshot_free(snapshot);
local_capability_registry_free(registry);
}
local_job_store_close(store); g_free(data);
if (node != NULL) json_node_free(node);
g_clear_object(&generator);g_clear_object(&builder);
investigation_record_free(record);
database_close(database);
/* OWNERSHIP: failure compensation may remove only artifacts created by this
* invocation. A refusal on an existing or partial workspace owns nothing. */
if (!ok && manifest_owned) (void)g_remove(manifest_path);
if (!ok && database_owned) (void)g_remove(database_path);
if (!ok && jobs_owned) (void)g_remove(jobs_path);
if (!ok && snapshot_owned) (void)g_remove(snapshot_path);
if (!ok && correlation_owned) (void)g_remove(correlation_path);
if (!ok && planner_owned) (void)g_remove(planner_path);
if (lock_fd >= 0) { (void)flock(lock_fd, LOCK_UN); close(lock_fd); }
g_free(planner_path);g_free(correlation_path);g_free(snapshot_path);
g_free(jobs_path);g_free(lock_path);g_free(manifest_path);
g_free(database_path);g_free(runtime);
return ok;
}
static gboolean write_private_atomic(const char *path, const char *contents,
GError **error) {
GFile *file = g_file_new_for_path(path);
gboolean ok = g_file_replace_contents(file, contents, strlen(contents), NULL,
FALSE, G_FILE_CREATE_PRIVATE, NULL, NULL, error);
if (ok && g_chmod(path, 0600) != 0) {
g_set_error(error, G_IO_ERROR, g_io_error_from_errno(errno),
"Permissions privées impossibles pour %s.", path);
ok = FALSE;
}
g_object_unref(file);
return ok;
}
static char *import_intent_json(const Manifest *manifest, const char *upload_id,
const char *key, const char *evidence_id, const char *original_name,
const char *type, guint64 size, const char *sha256, const char *source,
const char *description) {
JsonBuilder *builder = json_builder_new();
json_builder_begin_object(builder);
#define ADD_STRING(name, value) do { json_builder_set_member_name(builder, name); \
json_builder_add_string_value(builder, value); } while (0)
ADD_STRING("contract", "labfy.local_import.intent.v1");
json_builder_set_member_name(builder, "version");
json_builder_add_int_value(builder, 1);
ADD_STRING("investigation_id", manifest->investigation_id);
ADD_STRING("upload_id", upload_id);
ADD_STRING("idempotency_key", key);
ADD_STRING("evidence_id", evidence_id);
ADD_STRING("original_name", original_name);
ADD_STRING("type", type);
json_builder_set_member_name(builder, "size");
json_builder_add_int_value(builder, (gint64)size);
ADD_STRING("sha256", sha256);
ADD_STRING("source", source != NULL ? source : "");
ADD_STRING("description", description != NULL ? description : "");
#undef ADD_STRING
json_builder_end_object(builder);
JsonGenerator *generator = json_generator_new();
JsonNode *node = json_builder_get_root(builder);
json_generator_set_root(generator, node);
char *result = json_generator_to_data(generator, NULL);
json_node_free(node);
g_object_unref(generator);
g_object_unref(builder);
return result;
}
static gboolean receipt_matches(const char *path, const char *intent_hash,
const char *evidence_id, char **out_state,
GError **error) {
JsonParser *parser = json_parser_new();
gboolean ok = json_parser_load_from_file(parser, path, error);
JsonObject *object = ok ? json_node_get_object(json_parser_get_root(parser)) : NULL;
const char *contract = object != NULL
? json_object_get_string_member_with_default(object, "contract", NULL) : NULL;
const char *saved_hash = object != NULL
? json_object_get_string_member_with_default(object, "intent_sha256", NULL) : NULL;
const char *saved_id = object != NULL
? json_object_get_string_member_with_default(object, "evidence_id", NULL) : NULL;
const char *state = object != NULL
? json_object_get_string_member_with_default(object, "state", NULL) : NULL;
ok = ok && g_strcmp0(contract, "labfy.local_import.receipt.v1") == 0 &&
g_strcmp0(saved_hash, intent_hash) == 0 &&
g_strcmp0(saved_id, evidence_id) == 0 &&
(g_strcmp0(state, "CONFIRMING") == 0 ||
g_strcmp0(state, "IMPORTED") == 0);
if (!ok && error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"Reçu d’import tronqué, incohérent ou contradictoire.");
if (ok && out_state != NULL) *out_state = g_strdup(state);
g_object_unref(parser);
return ok;
}
static gboolean upload_intent_matches(const char *path,
const Manifest *manifest, const char *upload_id, const char *key,
const char *evidence_id, const char *original_name, const char *type,
guint64 expected_size, const char *sha256, const char *source,
const char *description, GError **error) {
if (g_file_test(path, G_FILE_TEST_IS_SYMLINK) ||
!g_file_test(path, G_FILE_TEST_IS_REGULAR)) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"Intention durable d’import absente ou non régulière.");
return FALSE;
}
JsonParser *parser = json_parser_new();
gboolean loaded = json_parser_load_from_file(parser, path, error);
JsonObject *object = loaded
? json_node_get_object(json_parser_get_root(parser)) : NULL;
JsonObject *confirmation = object != NULL &&
json_object_has_member(object, "confirmation") &&
JSON_NODE_HOLDS_OBJECT(json_object_get_member(object, "confirmation"))
? json_object_get_object_member(object, "confirmation") : NULL;
#define MEMBER(value, name) ((value) != NULL ? \
json_object_get_string_member_with_default((value), (name), NULL) : NULL)
const char *state = MEMBER(object, "state");
gint64 saved_size = object != NULL
? json_object_get_int_member_with_default(object, "expected_size", -1)
: -1;
gboolean ok = loaded && object != NULL && confirmation != NULL &&
g_strcmp0(MEMBER(object, "contract"), "labfy.local_upload.v1") == 0 &&
g_strcmp0(MEMBER(object, "upload_id"), upload_id) == 0 &&
g_strcmp0(MEMBER(object, "workspace_id"), manifest->investigation_id) == 0 &&
g_strcmp0(MEMBER(object, "evidence_id"), evidence_id) == 0 &&
g_strcmp0(MEMBER(object, "name"), original_name) == 0 &&
g_strcmp0(MEMBER(object, "recognized_type"), type) == 0 &&
saved_size >= 0 && (guint64)saved_size == expected_size &&
g_strcmp0(MEMBER(object, "sha256"), sha256) == 0 &&
(g_strcmp0(state, "CONFIRMING") == 0 ||
g_strcmp0(state, "IMPORTED") == 0) &&
g_strcmp0(MEMBER(confirmation, "contract"),
"labfy.local_import.intent.v1") == 0 &&
g_strcmp0(MEMBER(confirmation, "idempotency_key"), key) == 0 &&
g_strcmp0(MEMBER(confirmation, "source"), source != NULL ? source : "") == 0 &&
g_strcmp0(MEMBER(confirmation, "description"),
description != NULL ? description : "") == 0;
#undef MEMBER
if (!ok && error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"Intention durable d’import incohérente ou contradictoire.");
g_object_unref(parser);
return ok;
}
static gboolean validate_published_evidence(const char *root,
const EvidenceRecord *record, const char *evidence_id,
const char *original_name, const char *type, guint64 expected_size,
const char *sha256, const char *source, const char *description,
GError **error) {
const char *relative = evidence_record_get_relative_path(record);
const char *saved_source = evidence_record_get_source(record);
const char *saved_description = evidence_record_get_description(record);
gboolean metadata_ok =
g_strcmp0(evidence_record_get_identifier(record), evidence_id) == 0 &&
g_strcmp0(evidence_record_get_original_name(record), original_name) == 0 &&
g_strcmp0(evidence_record_get_type_identifier(record), type) == 0 &&
evidence_record_get_size_bytes(record) == expected_size &&
g_ascii_strcasecmp(evidence_record_get_sha256(record), sha256) == 0 &&
g_strcmp0(saved_source != NULL ? saved_source : "",
source != NULL ? source : "") == 0 &&
g_strcmp0(saved_description != NULL ? saved_description : "",
description != NULL ? description : "") == 0;
if (!metadata_ok || relative == NULL || g_path_is_absolute(relative) ||
strstr(relative, "..") != NULL) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"Record V20 incohérent avec l’intention durable.");
return FALSE;
}
char *published = g_build_filename(root, relative, NULL);
struct stat status = {0};
char *actual = NULL;
guint64 actual_size = 0U;
gboolean ok = !g_file_test(published, G_FILE_TEST_IS_SYMLINK) &&
stat(published, &status) == 0 && S_ISREG(status.st_mode) &&
file_hash_compute_sha256(published, NULL, &actual, &actual_size, error) &&
actual_size == expected_size && g_ascii_strcasecmp(actual, sha256) == 0;
if (!ok && error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"Original publié absent, spécial, symbolique ou altéré.");
g_free(actual);
g_free(published);
return ok;
}
static gboolean confirm_import(const char *root, const char *upload_id,
const char *key, const char *evidence_id, const char *original_name,
const char *type, const char *size_text, const char *sha256,
const char *source, const char *description, gboolean crash_after_commit,
GError **error) {
if (!g_uuid_string_is_valid(upload_id) || !g_uuid_string_is_valid(key) ||
!g_uuid_string_is_valid(evidence_id) || original_name == NULL ||
(g_strcmp0(type, "email") != 0 && g_strcmp0(type, "photo") != 0) ||
sha256 == NULL || strlen(sha256) != 64U) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"Intention d’import invalide."); return FALSE;
}
char *end = NULL; guint64 expected_size = g_ascii_strtoull(size_text, &end, 10);
if (end == size_text || *end != '\0' || expected_size > 4U * 1024U * 1024U) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"Taille d’import invalide."); return FALSE;
}
Manifest manifest = {0};
if (!manifest_read(root, &manifest, error)) return FALSE;
char *staged = g_build_filename(root, ".labfy", "uploads", upload_id,
"payload", NULL);
char *upload_intent = g_build_filename(root, ".labfy", "uploads", upload_id,
"intent.json", NULL);
char *database_path = workspace_path(root, "Enquete.sqlite");
char *receipt_dir = workspace_path(root, ".labfy/imports");
char *key_hash = g_compute_checksum_for_string(G_CHECKSUM_SHA256, key, -1);
char *receipt = g_build_filename(receipt_dir, key_hash, NULL);
char *canonical = import_intent_json(&manifest, upload_id, key, evidence_id,
original_name, type, expected_size, sha256, source, description);
char *intent = canonical != NULL
? g_compute_checksum_for_string(G_CHECKSUM_SHA256, canonical, -1) : NULL;
/* INVARIANT: deux confirmations concurrentes du même espace observent le
* reçu ou la preuve publiée par leur prédécesseur ; elles ne copient jamais
* simultanément le même UUID réservé. */
char *import_lock_path=workspace_path(root,".labfy/import.lock");
int import_lock=open(import_lock_path,O_CREAT|O_RDWR,0600);
if(import_lock<0||flock(import_lock,LOCK_EX)!=0){
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_BUSY,
"Verrou de publication indisponible.");
if (import_lock >= 0)
close(import_lock);
g_free(import_lock_path);
manifest_clear(&manifest);g_free(upload_intent);g_free(staged);
g_free(canonical);g_free(intent);
g_free(database_path);g_free(receipt_dir);g_free(key_hash);g_free(receipt);
return FALSE;
}
Database *database = database_open(database_path);
EvidenceDao *dao = database != NULL ? evidence_dao_new(database, error) : NULL;
InvestigationRecord *investigation = database != NULL
? investigation_dao_load(database) : NULL;
gboolean ok = canonical != NULL && intent != NULL && database != NULL &&
dao != NULL && investigation != NULL &&
g_strcmp0(investigation_record_get_id(investigation),
manifest.investigation_id) == 0 &&
g_mkdir_with_parents(receipt_dir, 0700) == 0;
if (!ok && error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"Identité métier de l’espace incohérente.");
/* INVARIANT: argv is only transport. The atomically published upload intent
* is the authority for every identifier and every free-text field across a
* server/process crash; no receipt or business row precedes this comparison. */
if (ok)
ok = upload_intent_matches(upload_intent, &manifest, upload_id, key,
evidence_id, original_name, type, expected_size, sha256, source,
description, error);
char *receipt_state = NULL;
if (ok && g_file_test(receipt, G_FILE_TEST_EXISTS))
ok = g_file_test(receipt, G_FILE_TEST_IS_REGULAR) &&
receipt_matches(receipt, intent, evidence_id, &receipt_state, error);
else if (ok) {
char *receipt_data = g_strdup_printf(
"{\"contract\":\"labfy.local_import.receipt.v1\","
"\"evidence_id\":\"%s\",\"intent_sha256\":\"%s\","
"\"state\":\"CONFIRMING\",\"version\":1}\n",
evidence_id, intent);
ok = write_private_atomic(receipt, receipt_data, error);
g_free(receipt_data);
if (ok) receipt_state = g_strdup("CONFIRMING");
}
/* INVARIANT: the record lookup occurs after acquiring the publication lock;
* no stale pre-lock object may decide whether an import already committed. */
EvidenceRecord *existing = ok
? evidence_dao_find_by_identifier(dao, evidence_id, error) : NULL;
if (ok && existing != NULL) {
ok = validate_published_evidence(root, existing, evidence_id, original_name,
type, expected_size, sha256, source, description, error);
} else if (ok && (error == NULL || *error == NULL)) {
char *actual = NULL;
guint64 actual_size = 0U;
ok = file_hash_compute_sha256(staged, NULL, &actual, &actual_size, error) &&
actual_size == expected_size &&
g_ascii_strcasecmp(actual, sha256) == 0;
if (!ok && error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"Empreinte du fichier préparé incohérente.");
g_free(actual);
}
if (ok && existing == NULL) {
char *folder = g_strcmp0(type, "email") == 0 ? "Emails" : "Images";
char *destination = g_build_filename(root, "01_Preuves_Originales", folder, NULL);
char *relative = g_build_filename("01_Preuves_Originales", folder, NULL);
EvidenceImporter *importer = evidence_importer_new(database, error);
EvidenceImportRequest request = {.source_path=staged,
.destination_directory=destination,.relative_directory=relative,
.type_identifier=type,.reserved_identifier=evidence_id,
.original_name=original_name,.collected_at=NULL,
.source=(source != NULL && source[0] != '\0') ? source : NULL,
.description=(description != NULL && description[0] != '\0') ? description : NULL};
ok = importer != NULL && g_mkdir_with_parents(destination, 0700) == 0;
EvidenceRecord *record = ok ? evidence_importer_import(importer,&request,NULL,error):NULL;
ok = record != NULL && validate_published_evidence(root, record, evidence_id,
original_name, type, expected_size, sha256, source, description, error);
evidence_record_free(record); evidence_importer_free(importer);
g_free(relative); g_free(destination);
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
if (ok && crash_after_commit)
_exit(87);
#else
(void)crash_after_commit;
#endif
}
if (ok) {
char *receipt_data = g_strdup_printf(
"{\"contract\":\"labfy.local_import.receipt.v1\","
"\"evidence_id\":\"%s\",\"intent_sha256\":\"%s\","
"\"state\":\"IMPORTED\",\"version\":1}\n",
evidence_id, intent);
ok = write_private_atomic(receipt, receipt_data, error);
g_free(receipt_data);
}
gboolean projection_ok = TRUE;
if (ok) {
LocalCapabilityRegistry *registry = local_capability_registry_new(error);
CoreGraphLimits limits={500U,1000U,1024U*1024U};
CoreGraphSnapshot *snapshot=registry!=NULL?core_graph_projection_service_collect_operational(database,limits,registry,error):NULL;
char *path=workspace_path(root,"core-snapshot.json");
Manifest store_manifest = {0};
LocalJobStore *store=open_store(root,&store_manifest,error);
projection_ok=snapshot!=NULL&&store!=NULL&&core_graph_snapshot_write_atomic(snapshot,FALSE,path,error)&&
export_correlations(root,database,manifest.investigation_id,error)&&
export_planner(root,database,store,registry,manifest.investigation_id,error);
local_job_store_close(store);manifest_clear(&store_manifest);
g_free(path);core_graph_snapshot_free(snapshot);
local_capability_registry_free(registry);
}
/* CONTRACT: snapshot export is retryable presentation work. Once V20 and the
* receipt agree, an export failure cannot turn the import back into pending. */
if (ok && !projection_ok) g_clear_error(error);
if (ok) printf("{\"contract\":\"labfy.local_import.result.v1\","
"\"state\":\"IMPORTED\",\"evidence_id\":\"%s\","
"\"projection_available\":%s}\n", evidence_id,
projection_ok ? "true" : "false");
evidence_record_free(existing);investigation_record_free(investigation);
evidence_dao_free(dao);database_close(database);
(void)flock(import_lock,LOCK_UN);close(import_lock);g_free(import_lock_path);
g_free(receipt_state);g_free(canonical);g_free(intent);g_free(receipt);
g_free(key_hash);g_free(receipt_dir);g_free(database_path);g_free(staged);
g_free(upload_intent);
manifest_clear(&manifest);
return ok;
}
static EvidenceRecord *import_file(Database *database, const char *root,
const char *source, const char *folder,
const char *type, GError **error) {
char *destination =
g_build_filename(root, "01_Preuves_Originales", folder, NULL);
EvidenceImporter *importer = evidence_importer_new(database, error);
EvidenceImportRequest request = {
.source_path = source,
.destination_directory = destination,
.relative_directory = g_strconcat("01_Preuves_Originales/", folder, NULL),
.type_identifier = type,
.collected_at = timestamp,
.source = "Générateur SPECIMEN J5",
.description = "Preuve fictive pour reprise locale bornée."};
EvidenceRecord *record = NULL;
if (importer != NULL && g_mkdir_with_parents(destination, 0700) == 0)
record = evidence_importer_import(importer, &request, NULL, error);
g_free((char *)request.relative_directory);
evidence_importer_free(importer);
g_free(destination);
return record;
}
static gboolean init_specimen(const char *root, GError **error) {
char *database_path = workspace_path(root, "Enquete.sqlite");
char *runtime = workspace_path(root, ".labfy/runtime");
char *input = workspace_path(root, "SPECIMEN_INPUT");
char *eml_path = g_build_filename(input, "message-j5.eml", NULL);
char *image_path = g_build_filename(input, "image-j5.png", NULL);
gboolean ok = !g_file_test(database_path, G_FILE_TEST_EXISTS) &&
g_mkdir_with_parents(runtime, 0700) == 0 &&
g_mkdir_with_parents(input, 0700) == 0 &&
g_file_set_contents(eml_path, eml_content, -1, error);
gsize png_size = 0;
guchar *png = g_base64_decode(png_base64, &png_size);
ok = ok && png != NULL &&
g_file_set_contents(image_path, (char *)png, (gssize)png_size, error);
g_free(png);
ok = ok && database_initialize(database_path, "SPECIMEN J5", root);
LocalCapabilityRegistry *registry =
ok ? local_capability_registry_new(error) : NULL;
const LocalCapabilityStatus *exif =
registry != NULL ? local_capability_registry_lookup(
registry, LOCAL_CAPABILITY_EXIF_METADATA)
: NULL;
const char *argv[] = {exif != NULL ? exif->executable : "",
"-config",
"",
"-overwrite_original",
"-XMP-dc:Creator=Alice SPECIMEN",
"--",
image_path,
NULL};
LocalToolRunnerResult *tool_result = NULL;
ok = ok && exif != NULL && exif->availability == LOCAL_CAPABILITY_READY &&
local_tool_runner_run(exif->executable, argv, input,
&exif->descriptor->limits, NULL, &tool_result,
error) &&
tool_result->state == LOCAL_TOOL_RUNNER_EXITED;
local_tool_runner_result_free(tool_result);
Database *database = ok ? database_open(database_path) : NULL;
EvidenceRecord *eml = database != NULL ? import_file(database, root, eml_path,
"Emails", "email", error)
: NULL;
EvidenceRecord *image = eml != NULL ? import_file(database, root, image_path,
"Images", "photo", error)
: NULL;
InvestigationRecord *investigation =
image != NULL ? investigation_dao_load(database) : NULL;
Manifest manifest = {0};
if (investigation != NULL) {
manifest.investigation_id =
g_strdup(investigation_record_get_id(investigation));
manifest.eml_id = g_strdup(evidence_record_get_identifier(eml));
manifest.image_id = g_strdup(evidence_record_get_identifier(image));
}
char *jobs_path = workspace_path(root, ".labfy/runtime/jobs.sqlite");
LocalJobStore *store =
investigation != NULL
? local_job_store_create(jobs_path, manifest.investigation_id, error)
: NULL;
ok = store != NULL && manifest_write(root, &manifest, error);
if (ok) {
CoreGraphLimits limits = {500U, 1000U, 1024U * 1024U};
CoreGraphSnapshot *snapshot =
core_graph_projection_service_collect_operational(database, limits,
registry, error);
char *snapshot_path = workspace_path(root, "core-snapshot.json");
ok = snapshot != NULL &&
core_graph_snapshot_write_atomic(snapshot, TRUE, snapshot_path, error);
g_free(snapshot_path);
core_graph_snapshot_free(snapshot);
if (ok)
ok = export_correlations(root, database, manifest.investigation_id, error);
}
local_job_store_close(store);
manifest_clear(&manifest);
investigation_record_free(investigation);
evidence_record_free(image);
evidence_record_free(eml);
database_close(database);
local_capability_registry_free(registry);
g_free(jobs_path);
g_free(image_path);
g_free(eml_path);
g_free(input);
g_free(runtime);
g_free(database_path);
return ok;
}
static gboolean init_j7_specimen(const char *root, GError **error) {
if (!init_specimen(root, error)) return FALSE;
char *input = workspace_path(root, "SPECIMEN_INPUT");
const char *names[] = {"message-j7-a.eml", "message-j7-b.eml",
"message-j7-c.eml", "message-j7-b-copy.eml"};
const char *contents[] = {j7_eml_a, j7_eml_b, j7_eml_c, j7_eml_b};
char *database_path = workspace_path(root, "Enquete.sqlite");
Database *database = database_open(database_path);
GPtrArray *identifiers = g_ptr_array_new_with_free_func(g_free);
gboolean ok = database != NULL;
for (guint i = 0; ok && i < G_N_ELEMENTS(names); i++) {
char *path = g_build_filename(input, names[i], NULL);
ok = g_file_set_contents(path, contents[i], -1, error);
EvidenceRecord *record = ok ? import_file(database, root, path, "Emails",
"email", error) : NULL;
ok = record != NULL;
if (record != NULL)
g_ptr_array_add(identifiers,
g_strdup(evidence_record_get_identifier(record)));
evidence_record_free(record); g_free(path);
}
Manifest manifest = {0};
if (ok) ok = manifest_read(root, &manifest, error);
if (ok) {
JsonBuilder *builder = json_builder_new();
json_builder_begin_object(builder);
json_builder_set_member_name(builder, "contract");
json_builder_add_string_value(builder, "labfy.j7.specimen.v1");
json_builder_set_member_name(builder, "synthetic");
json_builder_add_boolean_value(builder, TRUE);
json_builder_set_member_name(builder, "evidence_ids");
json_builder_begin_array(builder);
json_builder_add_string_value(builder, manifest.eml_id);
json_builder_add_string_value(builder, manifest.image_id);
for (guint i = 0; i < identifiers->len; i++)
json_builder_add_string_value(builder, g_ptr_array_index(identifiers, i));
json_builder_end_array(builder); json_builder_end_object(builder);
JsonGenerator *generator = json_generator_new();
JsonNode *node = json_builder_get_root(builder);
json_generator_set_root(generator, node);
char *data = json_generator_to_data(generator, NULL);
char *path = workspace_path(root, ".labfy/runtime/j7-specimen.json");
ok = data != NULL && g_file_set_contents(path, data, -1, error) &&
g_chmod(path, 0600) == 0;
g_free(path); g_free(data); json_node_free(node);
g_object_unref(generator); g_object_unref(builder);
}
if (ok) {
LocalCapabilityRegistry *registry = local_capability_registry_new(error);
CoreGraphLimits limits = {500U, 1000U, 1024U * 1024U};
CoreGraphSnapshot *graph = registry != NULL
? core_graph_projection_service_collect_operational(database, limits,
registry, error)
: NULL;
char *graph_path = workspace_path(root, "core-snapshot.json");
ok = graph != NULL && core_graph_snapshot_write_atomic(
graph, TRUE, graph_path, error) && export_correlations(
root, database, manifest.investigation_id, error);
g_free(graph_path); core_graph_snapshot_free(graph);
local_capability_registry_free(registry);
}
manifest_clear(&manifest); g_ptr_array_unref(identifiers);
database_close(database); g_free(database_path); g_free(input);
return ok;
}
static LocalJobStore *open_store(const char *root, Manifest *manifest,
GError **error) {
if (!manifest_read(root, manifest, error))
return NULL;
char *path = workspace_path(root, ".labfy/runtime/jobs.sqlite");
LocalJobStore *store =
local_job_store_open(path, manifest->investigation_id, FALSE, error);
g_free(path);
return store;
}
static gboolean enqueue_jobs(const char *root, GError **error) {
Manifest manifest = {0};
LocalJobStore *store = open_store(root, &manifest, error);
char *database_path = workspace_path(root, "Enquete.sqlite");
Database *database = store != NULL ? database_open(database_path) : NULL;
EvidenceDao *dao =
database != NULL ? evidence_dao_new(database, error) : NULL;
EvidenceRecord *eml =
dao != NULL ? evidence_dao_find_by_identifier(dao, manifest.eml_id, error)
: NULL;
EvidenceRecord *image =
dao != NULL
? evidence_dao_find_by_identifier(dao, manifest.image_id, error)
: NULL;
LocalJobSubmission eml_job = {EML_JOB,
EML_REQUEST,
manifest.eml_id,
EML_DERIVATIVE,
LOCAL_CAPABILITY_EML_HEADERS,
EML_ANALYSIS_TOOL_ID,
EML_ANALYSIS_TOOL_VERSION,
eml ? evidence_record_get_sha256(eml) : NULL,
eml ? evidence_record_get_size_bytes(eml) : 0,
timestamp,
"{\"mode\":\"headers\"}",
NULL,
3};
LocalJobSubmission exif_job = {
EXIF_JOB,
EXIF_REQUEST,
manifest.image_id,
EXIF_DERIVATIVE,
LOCAL_CAPABILITY_EXIF_METADATA,
"exiftool-json",
"1",
image ? evidence_record_get_sha256(image) : NULL,
image ? evidence_record_get_size_bytes(image) : 0,
timestamp,
"{\"groups\":\"G1\",\"numeric_values\":true}",
EML_JOB,
3};
gboolean reused = FALSE;
gboolean ok = eml != NULL && image != NULL &&
local_job_store_enqueue(store, &eml_job, &reused, error) &&
local_job_store_enqueue(store, &exif_job, &reused, error);
evidence_record_free(image);
evidence_record_free(eml);
evidence_dao_free(dao);
database_close(database);
local_job_store_close(store);
manifest_clear(&manifest);
g_free(database_path);
return ok;
}
static gboolean submit_job(const char *root, const char *evidence_id,
const char *capability_id, const char *key,
char **out_job_id, GError **error) {
if (!g_uuid_string_is_valid(evidence_id) || !g_uuid_string_is_valid(key) ||
(g_strcmp0(capability_id, LOCAL_CAPABILITY_EML_HEADERS) != 0 &&
g_strcmp0(capability_id, LOCAL_CAPABILITY_EXIF_METADATA) != 0)) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"Intention d'analyse inconnue ou invalide.");
return FALSE;
}
Manifest manifest = {0};
LocalJobStore *store = open_store(root, &manifest, error);
char *database_path = workspace_path(root, "Enquete.sqlite");
Database *database = store != NULL ? database_open(database_path) : NULL;
EvidenceDao *dao =
database != NULL ? evidence_dao_new(database, error) : NULL;
EvidenceRecord *evidence =
dao != NULL ? evidence_dao_find_by_identifier(dao, evidence_id, error)
: NULL;
LocalCapabilityRegistry *registry =
evidence != NULL ? local_capability_registry_new(error) : NULL;
const LocalCapabilityStatus *status =
registry != NULL
? local_capability_registry_lookup(registry, capability_id)
: NULL;
char *reason = NULL;
const char *type =
evidence != NULL ? evidence_record_get_type_identifier(evidence) : NULL;
const char *mime = g_strcmp0(type, "email") == 0 ? "message/rfc822"
: g_strcmp0(type, "photo") == 0
? "image/unknown"
: "application/octet-stream";
gboolean applicable =
status != NULL &&
local_capability_status_applies(
status, mime, evidence_record_get_relative_path(evidence), &reason);
if (!applicable || status->availability != LOCAL_CAPABILITY_READY) {
g_set_error(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
"Capability indisponible pour cette preuve : %s",
reason != NULL ? reason
: status != NULL ? status->reason
: "capability absente");
}
char *request_id = derived_uuid(key, "request");
char *derivative_id = derived_uuid(key, "derivative");
char *requested_at = now_iso();
LocalJobSubmission submission = {
.job_id = key,
.request_id = request_id,
.source_evidence_id = evidence_id,
.derivative_evidence_id = derivative_id,
.capability_id = capability_id,
.adapter_id = g_strcmp0(capability_id, LOCAL_CAPABILITY_EML_HEADERS) == 0
? EML_ANALYSIS_TOOL_ID
: "exiftool-json",
.adapter_version = "1",
.source_sha256 =
evidence != NULL ? evidence_record_get_sha256(evidence) : NULL,
.source_size =
evidence != NULL ? evidence_record_get_size_bytes(evidence) : 0,
.requested_at = requested_at,
.parameters_json =
g_strcmp0(capability_id, LOCAL_CAPABILITY_EML_HEADERS) == 0
? "{\"mode\":\"headers\"}"
: "{\"groups\":\"G1\",\"numeric_values\":true}",
.max_attempts = 3};
gboolean reused = FALSE;
gboolean ok = evidence != NULL && applicable &&
status->availability == LOCAL_CAPABILITY_READY &&
local_job_store_enqueue(store, &submission, &reused, error);
if (ok && out_job_id != NULL)
*out_job_id = g_strdup(key);
g_free(requested_at);
g_free(derivative_id);
g_free(request_id);
g_free(reason);
local_capability_registry_free(registry);
evidence_record_free(evidence);
evidence_dao_free(dao);
database_close(database);
g_free(database_path);
local_job_store_close(store);
manifest_clear(&manifest);
return ok;
}
static gboolean submit_plan(const char *root, const char *revision,
const char *profile, const char *key,
const char *ids_csv, gboolean crash_after_admission,
GError **error) {
if (!revision || !profile || !g_uuid_string_is_valid(key) || !ids_csv) {
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_ARGUMENT,"Intention de plan invalide."); return FALSE;
}
char *planner_path=workspace_path(root,"planner-snapshot.json"); JsonParser *parser=json_parser_new();
gboolean ok=json_parser_load_from_file(parser,planner_path,error); g_free(planner_path);
JsonObject *root_object=ok?json_node_get_object(json_parser_get_root(parser)):NULL;
if(ok && g_strcmp0(json_object_get_string_member(root_object,"input_revision"),revision)!=0){
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_DATA,"Projection planner périmée.");ok=FALSE;}
gchar **ids=g_strsplit(ids_csv,",",9); gsize count=g_strv_length(ids);
guint max_analyses=g_strcmp0(profile,"SPECIMEN_SMALL")==0?2U:g_strcmp0(profile,"LOCAL_PRUDENT")==0?8U:0U;
guint max_attempts=g_strcmp0(profile,"SPECIMEN_SMALL")==0?3U:16U;
guint64 max_bytes=g_strcmp0(profile,"SPECIMEN_SMALL")==0?8U*1024U*1024U:256U*1024U*1024U;
guint64 max_ms=g_strcmp0(profile,"SPECIMEN_SMALL")==0?30000U:240000U;
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
const char *test_max_ms = g_getenv("LABFY_TEST_PLAN_ACTIVE_MS");
if (test_max_ms != NULL && test_max_ms[0] != '\0')
max_ms = g_ascii_strtoull(test_max_ms, NULL, 10);
#endif
if(ok && (count==0||count>max_analyses)){g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_NO_SPACE,"Profil de budget insuffisant.");ok=FALSE;}
Manifest manifest={0}; LocalJobStore *store=ok?open_store(root,&manifest,error):NULL;
char *database_path=workspace_path(root,"Enquete.sqlite"); Database *database=store?database_open(database_path):NULL;
EvidenceDao *dao=database?evidence_dao_new(database,error):NULL; LocalCapabilityRegistry *registry=dao?local_capability_registry_new(error):NULL;
LocalJobSubmission *jobs=g_new0(LocalJobSubmission,count); GPtrArray *owned=g_ptr_array_new_with_free_func(g_free);
JsonArray *recommendations=ok?json_object_get_array_member(root_object,"recommendations"):NULL;
for(gsize i=0;ok&&i<count;i++){
JsonObject *match=NULL; for(guint j=0;j<json_array_get_length(recommendations);j++){JsonObject *candidate=json_array_get_object_element(recommendations,j);if(g_strcmp0(json_object_get_string_member(candidate,"id"),ids[i])==0){match=candidate;break;}}
if(!match||!json_object_get_boolean_member(match,"available")){g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_NOT_FOUND,"Recommandation absente ou indisponible.");ok=FALSE;break;}
const char *evidence_id=json_object_get_string_member(match,"object_id"); const char *cap=json_object_get_string_member(match,"capability_id"); EvidenceRecord *evidence=evidence_dao_find_by_identifier(dao,evidence_id,error); const LocalCapabilityStatus *status=local_capability_registry_lookup(registry,cap);
if(!evidence||!status||status->availability!=LOCAL_CAPABILITY_READY||g_strcmp0(evidence_record_get_sha256(evidence),json_object_get_string_member(match,"source_sha256"))!=0){evidence_record_free(evidence);g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_DATA,"Précondition de recommandation modifiée.");ok=FALSE;break;}
char *seed=g_strconcat(key,":",ids[i],NULL);char *job=derived_uuid(seed,"job"),*request=derived_uuid(seed,"request"),*derivative=derived_uuid(seed,"derivative");g_free(seed);char *at=now_iso();g_ptr_array_add(owned,job);g_ptr_array_add(owned,request);g_ptr_array_add(owned,derivative);g_ptr_array_add(owned,at);
jobs[i]=(LocalJobSubmission){job,request,evidence_id,derivative,cap,status->descriptor->adapter_id,status->descriptor->adapter_version,evidence_record_get_sha256(evidence),evidence_record_get_size_bytes(evidence),at,g_strcmp0(cap,LOCAL_CAPABILITY_EML_HEADERS)==0?"{\"mode\":\"headers\"}":"{\"groups\":\"G1\",\"numeric_values\":true}",NULL,g_strcmp0(profile,"SPECIMEN_SMALL")==0?1U:2U};
/* Les champs empruntés au record doivent survivre jusqu'à l'admission. */
jobs[i].source_evidence_id=g_strdup(evidence_id);jobs[i].source_sha256=g_strdup(evidence_record_get_sha256(evidence));g_ptr_array_add(owned,(gpointer)jobs[i].source_evidence_id);g_ptr_array_add(owned,(gpointer)jobs[i].source_sha256);evidence_record_free(evidence);
}
char *plan_id=derived_uuid(key,"plan");GString *intent=g_string_new(revision);g_string_append(intent,profile);for(gsize i=0;i<count;i++)g_string_append(intent,ids[i]);char *intent_hash=g_compute_checksum_for_string(G_CHECKSUM_SHA256,intent->str,-1);char *created=now_iso();LocalPlanAdmission plan={plan_id,key,intent_hash,revision,profile,created,max_analyses,max_attempts,max_bytes,max_ms};gboolean reused=FALSE;
if(ok)ok=local_job_store_admit_plan(store,&plan,jobs,count,&reused,error);
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
if (ok && crash_after_admission) {
/* TEST CONTRACT: le marqueur est émis seulement après le COMMIT durable;
* _exit reproduit une réponse perdue sans exécuter le premier claim. */
(void)write(STDERR_FILENO, "J8_BARRIER_ADMISSION_COMMITTED\n", 31);
_exit(85);
}
#else
(void)crash_after_admission;
#endif
if(ok){printf("{\"contract\":\"labfy.local_plan.v1\",\"accepted\":true,\"reused\":%s,\"plan_id\":\"%s\",\"jobs\":[",reused?"true":"false",plan_id);for(gsize i=0;i<count;i++)printf("%s\"%s\"",i?",":"",jobs[i].job_id);printf("]}\n");}
g_free(created);g_free(intent_hash);g_string_free(intent,TRUE);g_free(plan_id);g_free(jobs);g_ptr_array_unref(owned);local_capability_registry_free(registry);evidence_dao_free(dao);database_close(database);g_free(database_path);local_job_store_close(store);manifest_clear(&manifest);g_strfreev(ids);g_object_unref(parser);return ok;
}
static gboolean lookup_plan(const char *root,const char *revision,
const char *profile,const char *key,
const char *ids_csv,GError **error) {
Manifest manifest={0};LocalJobStore *store=open_store(root,&manifest,error);
if (!store)
return FALSE;
GString *intent=g_string_new(revision);g_string_append(intent,profile);
gchar **ids=g_strsplit(ids_csv,",",9);for(guint i=0;ids[i];i++)g_string_append(intent,ids[i]);
char *hash=g_compute_checksum_for_string(G_CHECKSUM_SHA256,intent->str,-1),*plan_id=NULL;
gboolean ok=local_job_store_find_plan_intention(store,key,hash,&plan_id,error);
if(ok&&plan_id)printf("{\"contract\":\"labfy.local_plan.v1\",\"found\":true,\"reused\":true,\"plan_id\":\"%s\"}\n",plan_id);
else if(ok)printf("{\"contract\":\"labfy.local_plan.v1\",\"found\":false}\n");
g_free(plan_id);g_free(hash);g_strfreev(ids);g_string_free(intent,TRUE);local_job_store_close(store);manifest_clear(&manifest);return ok;
}
static gboolean execute_job(const char *root, Database *database,
LocalCapabilityRegistry *registry,
LocalJobRecord *job, GCancellable *cancellable,
GError **error) {
if (g_strcmp0(job->capability_id, LOCAL_CAPABILITY_EML_HEADERS) == 0) {
EmlAnalysisPersistenceService *service =
eml_analysis_persistence_service_new(database, root, error);
EmlAnalysisPersistenceRequest request = {
job->request_id, job->source_evidence_id, job->derivative_evidence_id,
job->requested_at};
EmlAnalysisPrepared *prepared =
service != NULL ? eml_analysis_persistence_service_prepare(
service, &request, cancellable, error)
: NULL;
EmlAnalysisPublicationResult *result =
prepared != NULL ? eml_analysis_persistence_service_publish(
service, prepared, cancellable, error)
: NULL;
gboolean ok = result != NULL;
eml_analysis_publication_result_free(result);
eml_analysis_prepared_free(prepared);
eml_analysis_persistence_service_free(service);
return ok;
}
if (g_strcmp0(job->capability_id, LOCAL_CAPABILITY_EXIF_METADATA) != 0) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
"Capability de job inconnue ; aucune exécution.");
return FALSE;
}
ExiftoolPersistenceService *service =
exiftool_persistence_service_new(database, root, registry, error);
ExiftoolPersistenceRequest request = {
job->request_id, job->source_evidence_id, job->derivative_evidence_id,
job->requested_at};
ExiftoolPublicationResult *result =
service != NULL
? exiftool_persistence_service_execute(service, &request, cancellable, error)
: NULL;
gboolean ok = result != NULL;
exiftool_publication_result_free(result);
exiftool_persistence_service_free(service);
return ok;
}
static gboolean run_worker(const char *root, guint crash_point,
GError **error) {
struct sigaction stop_action = {0};
struct sigaction previous_term = {0};
struct sigaction previous_int = {0};
stop_action.sa_handler = worker_stop_handler;
sigemptyset(&stop_action.sa_mask);
worker_stop_requested = 0;
local_tool_runner_reset_process_cancel();
(void)sigaction(SIGTERM, &stop_action, &previous_term);
(void)sigaction(SIGINT, &stop_action, &previous_int);
Manifest manifest = {0};
LocalJobStore *store = open_store(root, &manifest, error);
char *lock_path = workspace_path(root, ".labfy/runtime/worker.lock");
int lock_fd = open(lock_path, O_CREAT | O_RDWR, 0600);
if (lock_fd >= 0)
(void)fcntl(lock_fd, F_SETFD, FD_CLOEXEC);
if (store == NULL || lock_fd < 0 || flock(lock_fd, LOCK_EX | LOCK_NB) != 0) {
if (error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_BUSY,
"Un worker possède déjà cet espace.");
local_job_store_close(store);
if (lock_fd >= 0)
close(lock_fd);
g_free(lock_path);
manifest_clear(&manifest);
return FALSE;
}
char *database_path = workspace_path(root, "Enquete.sqlite");
Database *database = database_open(database_path);
LocalCapabilityRegistry *registry = local_capability_registry_new(error);
GPtrArray *jobs = local_job_store_list(store, error);
for (guint index = 0; jobs != NULL && index < jobs->len; index++) {
LocalJobRecord *job = g_ptr_array_index(jobs, index);
if (job->state == LOCAL_JOB_RUNNING) {
ExtractionDao *extraction_dao = extraction_dao_new(database, NULL);
ExtractionRecord *published =
extraction_dao != NULL ? extraction_dao_find_by_identifier(
extraction_dao, job->request_id, NULL)
: NULL;
extraction_dao_free(extraction_dao);
GError *replay_error = NULL;
if (published != NULL &&
execute_job(root, database, registry, job, NULL, &replay_error)) {
char *now = now_iso();
local_job_store_reconcile_interrupted(
store, job->job_id, LOCAL_JOB_COMPLETED, "recovered_after_publish",
"published", now, error);
g_free(now);
} else if (published == NULL && job->cancel_requested) {
char *now = now_iso();
local_job_store_reconcile_interrupted(
store, job->job_id, LOCAL_JOB_CANCELLED,
"cancelled_after_worker_stop", "cancelled", now, error);
g_free(now);
} else if (published == NULL) {
char *now = now_iso();
local_job_store_requeue_interrupted(
store, job->job_id, "interrupted_before_publish", now, error);
g_free(now);
} else {
char *now = now_iso();
local_job_store_reconcile_interrupted(
store, job->job_id, LOCAL_JOB_RECOVERY_REQUIRED,
replay_error != NULL ? replay_error->message : "résultat ambigu",
"blocked", now, error);
g_free(now);
}
extraction_record_free(published);
g_clear_error(&replay_error);
}
}
g_clear_pointer(&jobs, g_ptr_array_unref);
while ((error == NULL || *error == NULL) && !worker_stop_requested) {
char *owner = g_uuid_string_random();
char *attempt = g_uuid_string_random();
char *now = now_iso();
LocalJobRecord *job = NULL;
guint reserved_active_ms = 0;
guint requested_active_ms = 30000U;
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
const char *test_active_ms = g_getenv("LABFY_TEST_PLAN_ACTIVE_MS");
if (test_active_ms != NULL && test_active_ms[0] != '\0')
requested_active_ms = (guint)g_ascii_strtoull(test_active_ms, NULL, 10);
#endif
gboolean ok =
local_job_store_claim_next_budgeted(store, owner, attempt, now,
requested_active_ms, &reserved_active_ms, &job, error);
g_free(now);
g_free(attempt);
if (!ok || job == NULL) {
g_free(owner);
break;
}
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
if (crash_point == 1U) {
(void)write(STDERR_FILENO, "J8_BARRIER_CLAIM_COMMITTED\n", 27);
_exit(84);
}
#endif
GError *execution_error = NULL;
GCancellable *cancellable = g_cancellable_new();
BudgetTimer timer = {.finished=FALSE,.timeout_ms=reserved_active_ms,
.cancellable=cancellable};
g_mutex_init(&timer.mutex);g_cond_init(&timer.condition);
GThread *timer_thread=g_thread_new("plan-budget",budget_timer_run,&timer);
gint64 started_us=g_get_monotonic_time();
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
const char *test_delay = g_getenv("LABFY_TEST_TOOL_DELAY_MS");
if (test_delay != NULL && test_delay[0] != '\0') {
char *marker = workspace_path(root, ".labfy/runtime/test-tool-started");
char *pid = g_strdup_printf("%ld\n", (long)getpid());
(void)g_file_set_contents(marker, pid, -1, NULL);
guint delay_ms = (guint)g_ascii_strtoull(test_delay, NULL, 10);
for (guint waited = 0U; waited < delay_ms &&
!g_cancellable_is_cancelled(cancellable) &&
!worker_stop_requested; waited += 10U)
g_usleep(10U * 1000U);
g_free(pid); g_free(marker);
}
#endif
if (g_cancellable_is_cancelled(cancellable) || worker_stop_requested) {
g_set_error_literal(&execution_error, G_IO_ERROR, G_IO_ERROR_CANCELLED,
"Outil synthétique interrompu dans sa limite.");
ok = FALSE;
} else
ok = execute_job(root, database, registry, job, cancellable, &execution_error);
gint64 elapsed_us=g_get_monotonic_time()-started_us;
g_mutex_lock(&timer.mutex);timer.finished=TRUE;g_cond_signal(&timer.condition);
g_mutex_unlock(&timer.mutex);g_thread_join(timer_thread);
g_cond_clear(&timer.condition);g_mutex_clear(&timer.mutex);
guint elapsed_ms=(guint)MIN((guint64)G_MAXUINT,
(guint64)MAX((gint64)1,(elapsed_us+999)/1000));
#ifdef LOCAL_JOBS_ENABLE_CRASH_HOOK
if (ok && crash_point == 2U)
_exit(86);
#else
(void)crash_point;
#endif
now = now_iso();
const char *diagnostic = ok ? "published"
: execution_error != NULL
? execution_error->message
: "execution_failed";
GError *finish_error = NULL;
gboolean budget_expired = !worker_stop_requested &&
g_cancellable_is_cancelled(cancellable);
LocalJobState terminal_state = worker_stop_requested && !ok
? LOCAL_JOB_CANCELLED : budget_expired ? LOCAL_JOB_BLOCKED
: ok ? LOCAL_JOB_COMPLETED : LOCAL_JOB_FAILED;
gboolean recorded = local_job_store_finish_budgeted(
store, job->job_id, owner, terminal_state,
worker_stop_requested && !ok ? "cancelled_after_tool_cleanup"
: budget_expired ? "active_time_limit" : diagnostic,
worker_stop_requested && !ok ? "cancelled"
: budget_expired ? "limit_reached" : ok ? "published" : "failed",
now, elapsed_ms, &finish_error);
g_free(now);
local_job_record_free(job);
g_free(owner);
if (!recorded && error != NULL && *error == NULL)
g_propagate_error(error, finish_error);
else
g_clear_error(&finish_error);
g_clear_error(&execution_error);
g_object_unref(cancellable);
if (!recorded)
break;
char *jobs_export = workspace_path(root, "jobs-snapshot.json");
local_job_store_export_atomic(store, jobs_export, NULL);
g_free(jobs_export);
if (ok) {
CoreGraphLimits limits = {500U, 1000U, 1024U * 1024U};
CoreGraphSnapshot *current =
core_graph_projection_service_collect_operational(database, limits,
registry, NULL);
char *graph_export = workspace_path(root, "core-snapshot.json");
if (current != NULL)
(void)core_graph_snapshot_write_atomic(current, manifest.synthetic, graph_export,
NULL);
g_free(graph_export);
core_graph_snapshot_free(current);
(void)export_correlations(root, database, manifest.investigation_id, NULL);
(void)export_planner(root, database, store, registry,
manifest.investigation_id, NULL);
}
}
jobs = local_job_store_list(store, NULL);
gboolean complete = TRUE;
for (guint i = 0; jobs != NULL && i < jobs->len; i++)
complete =
complete && ((LocalJobRecord *)g_ptr_array_index(jobs, i))->state ==
LOCAL_JOB_COMPLETED;
g_clear_pointer(&jobs, g_ptr_array_unref);
char *export_path = workspace_path(root, "jobs-snapshot.json");
local_job_store_export_atomic(store, export_path, NULL);
g_free(export_path);
if (complete) {
CoreGraphLimits limits = {500U, 1000U, 1024U * 1024U};
CoreGraphSnapshot *snapshot =
core_graph_projection_service_collect_operational(database, limits,
registry, error);
char *snapshot_path = workspace_path(root, "core-snapshot.json");
if (snapshot == NULL ||
!core_graph_snapshot_write_atomic(snapshot, manifest.synthetic, snapshot_path, error))
complete = FALSE;
g_free(snapshot_path);
core_graph_snapshot_free(snapshot);
}
local_capability_registry_free(registry);
database_close(database);
g_free(database_path);
flock(lock_fd, LOCK_UN);
close(lock_fd);
g_free(lock_path);
local_job_store_close(store);
manifest_clear(&manifest);
(void)sigaction(SIGTERM, &previous_term, NULL);
(void)sigaction(SIGINT, &previous_int, NULL);
return complete && (error == NULL || *error == NULL);
}
static gboolean status_or_export(const char *root, gboolean print,
GError **error) {
Manifest manifest = {0};
LocalJobStore *store = open_store(root, &manifest, error);
GPtrArray *jobs = store != NULL ? local_job_store_list(store, error) : NULL;
for (guint i = 0; print && jobs != NULL && i < jobs->len; i++) {
LocalJobRecord *job = g_ptr_array_index(jobs, i);
printf("%s %s tentatives=%u %s\n", job->job_id,
local_job_state_code(job->state), job->attempt_count,
job->diagnostic != NULL ? job->diagnostic : "");
}
char *path = workspace_path(root, "jobs-snapshot.json");
gboolean ok =
jobs != NULL && local_job_store_export_atomic(store, path, error);
char *database_path = workspace_path(root, "Enquete.sqlite");
Database *database = ok ? database_open(database_path) : NULL;
LocalCapabilityRegistry *registry = ok ? local_capability_registry_new(error) : NULL;
if (ok)
ok = database != NULL && registry != NULL && export_correlations(
root, database, manifest.investigation_id, error) &&
export_planner(root, database, store, registry,
manifest.investigation_id, error);
local_capability_registry_free(registry);
database_close(database);
g_free(database_path);
g_free(path);
g_clear_pointer(&jobs, g_ptr_array_unref);
local_job_store_close(store);
manifest_clear(&manifest);
return ok;
}
static Database *open_workspace_database(const char *root, Manifest *manifest,
GError **error) {
if (!manifest_read(root, manifest, error)) return NULL;
char *path = workspace_path(root, "Enquete.sqlite");
Database *database = database_open(path);
g_free(path);
if (database == NULL) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED,
"Impossible d'ouvrir la base de l'espace.");
manifest_clear(manifest);
}
return database;
}
static gboolean print_evidence_preview(const char *root,
const char *evidence_id,
GError **error) {
Manifest manifest = {0};
Database *database = NULL;
EvidenceDao *dao = NULL;
EvidenceRecord *evidence = NULL;
EvidencePreviewRequest *request = NULL;
EvidencePreviewResult *preview = NULL;
gboolean ok = FALSE;
if (!g_uuid_string_is_valid(evidence_id)) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"UUID de preuve invalide.");
return FALSE;
}
database = open_workspace_database(root, &manifest, error);
dao = database != NULL ? evidence_dao_new(database, error) : NULL;
evidence = dao != NULL
? evidence_dao_find_by_identifier(dao, evidence_id, error) : NULL;
if (evidence == NULL && error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND,
"Preuve inconnue dans cet espace.");
if (evidence != NULL) request = evidence_preview_request_new(root, evidence_id,
evidence_record_get_relative_path(evidence),
evidence_record_get_sha256(evidence), NULL, 1U);
preview = request != NULL ? evidence_preview_load(request, NULL, error) : NULL;
if (preview != NULL) {
const char *kind = preview->kind == EVIDENCE_PREVIEW_KIND_EMAIL ? "email" :
preview->kind == EVIDENCE_PREVIEW_KIND_IMAGE ? "image" : "unsupported";
char *cache_input = g_strdup_printf("%s:%s:%s:web-v1:preview-v1",
manifest.investigation_id, evidence_id,
evidence_record_get_sha256(evidence));
char *cache_id = g_compute_checksum_for_string(G_CHECKSUM_SHA256,
cache_input, -1);
char *image = preview->png_bytes != NULL
? g_base64_encode(g_bytes_get_data(preview->png_bytes, NULL),
g_bytes_get_size(preview->png_bytes)) : NULL;
JsonBuilder *builder = json_builder_new();
json_builder_begin_object(builder);
json_add_nullable(builder, "contract", "labfy.evidence_preview.v1");
json_add_nullable(builder, "evidence_id", evidence_id);
json_add_nullable(builder, "display_name",
evidence_record_get_original_name(evidence));
json_add_nullable(builder, "type", evidence_record_get_type_identifier(evidence));
json_add_nullable(builder, "sha256", evidence_record_get_sha256(evidence));
json_builder_set_member_name(builder, "size_bytes");
json_builder_add_int_value(builder, (gint64) evidence_record_get_size_bytes(evidence));
json_add_nullable(builder, "kind", kind);
json_add_nullable(builder, "cache_id", cache_id);
json_add_nullable(builder, "renderer_version", "preview-v1");
json_add_nullable(builder, "text", preview->text);
json_add_nullable(builder, "image_png_base64", image);
json_builder_set_member_name(builder, "width");
json_builder_add_int_value(builder, preview->width);
json_builder_set_member_name(builder, "height");
json_builder_add_int_value(builder, preview->height);
json_builder_set_member_name(builder, "truncated");
json_builder_add_boolean_value(builder, preview->truncated);
json_builder_set_member_name(builder, "integrity_valid");
json_builder_add_boolean_value(builder, preview->integrity_valid);
json_builder_end_object(builder);
ok = print_json_builder(builder, error);
g_object_unref(builder); g_free(image); g_free(cache_id); g_free(cache_input);
}
evidence_preview_result_free(preview);
evidence_preview_request_free(request);
evidence_record_free(evidence); evidence_dao_free(dao);
database_close(database); manifest_clear(&manifest);
return ok;
}
static guint64 observation_revision(Database *database, const char *id) {
DatabaseStatement *statement = database_statement_prepare(database,
"SELECT COUNT(*) FROM journal WHERE action='observation.review.v1' "
"AND objet_type='evidence_observation' AND objet_id=?;");
int64_t count = 0;
gboolean ok = statement != NULL &&
database_statement_bind_text(statement, 1, id) &&
database_statement_step(statement) == DATABASE_STATEMENT_STEP_ROW &&
database_statement_column_int64(statement, 0, &count) && count >= 0;
database_statement_finalize(statement);
return ok ? (guint64) count : 0U;
}
static gboolean print_observations(const char *root, const char *evidence_id,
const char *extraction_id, GError **error) {
Manifest manifest = {0};
Database *database = NULL; EvidenceDao *evidence_dao = NULL;
ExtractionDao *extraction_dao = NULL; ExtractionRecord *extraction = NULL;
EvidenceEntityDao *dao = NULL; EvidenceRecord *evidence = NULL;
GPtrArray *items = NULL; gboolean ok = FALSE;
if (!g_uuid_string_is_valid(evidence_id) ||
(extraction_id != NULL && !g_uuid_string_is_valid(extraction_id))) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"UUID de preuve ou d'extraction invalide.");
return FALSE;
}
database = open_workspace_database(root, &manifest, error);
evidence_dao = database != NULL ? evidence_dao_new(database, error) : NULL;
evidence = evidence_dao != NULL
? evidence_dao_find_by_identifier(evidence_dao, evidence_id, error) : NULL;
if (evidence == NULL && error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND,
"Preuve inconnue dans cet espace.");
extraction_dao = evidence != NULL && extraction_id != NULL
? extraction_dao_new(database, error) : NULL;
extraction = extraction_dao != NULL
? extraction_dao_find_by_identifier(extraction_dao, extraction_id, error)
: NULL;
if (extraction_id != NULL && extraction == NULL &&
error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND,
"Extraction inconnue dans cet espace.");
if (extraction != NULL &&
g_strcmp0(extraction->source_identifier, evidence_id) != 0 &&
g_strcmp0(extraction->evidence_identifier, evidence_id) != 0)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_PERMISSION_DENIED,
"L'extraction n'appartient pas à la preuve demandée.");
if (error != NULL && *error != NULL) {
evidence_record_free(evidence);
evidence = NULL;
}
dao = evidence != NULL ? evidence_entity_dao_new(database, error) : NULL;
items = dao != NULL
? evidence_entity_dao_list_observations(dao, evidence_id, error) : NULL;
if (items != NULL) {
JsonBuilder *builder = json_builder_new();
json_builder_begin_object(builder);
json_add_nullable(builder, "contract", "labfy.evidence_observations.v1");
json_add_nullable(builder, "evidence_id", evidence_id);
json_builder_set_member_name(builder, "observations");
json_builder_begin_array(builder);
for (guint i = 0; i < items->len; i++) {
EvidenceObservation *item = g_ptr_array_index(items, i);
if (extraction_id != NULL &&
g_strcmp0(item->extraction_identifier, extraction_id) != 0) continue;
json_builder_begin_object(builder);
json_add_nullable(builder, "id", item->identifier);
json_add_nullable(builder, "extraction_id", item->extraction_identifier);
json_add_nullable(builder, "entity_type", item->type_identifier);
json_add_nullable(builder, "value_raw", item->value_raw);
json_add_nullable(builder, "value_normalized", item->value_normalized);
json_add_nullable(builder, "value_corrected", item->value_corrected);
json_add_nullable(builder, "role", item->role);
json_add_nullable(builder, "source_header", item->source_header);
json_add_nullable(builder, "provenance_kind", item->provenance_kind);
json_add_nullable(builder, "verification_status", item->verification_status);
json_add_nullable(builder, "entity_id", item->entity_identifier);
json_builder_set_member_name(builder, "revision");
json_builder_add_int_value(builder,
(gint64) observation_revision(database, item->identifier));
json_builder_end_object(builder);
}
json_builder_end_array(builder); json_builder_end_object(builder);
ok = print_json_builder(builder, error); g_object_unref(builder);
}
g_clear_pointer(&items, g_ptr_array_unref); evidence_entity_dao_free(dao);
extraction_record_free(extraction); extraction_dao_free(extraction_dao);
evidence_record_free(evidence); evidence_dao_free(evidence_dao);
database_close(database); manifest_clear(&manifest); return ok;
}
static gboolean republish_after_review(const char *root, Database *database,
const Manifest *manifest,
GError **error) {
char *jobs_path = workspace_path(root, ".labfy/runtime/jobs.sqlite");
LocalJobStore *store = local_job_store_open(
jobs_path, manifest->investigation_id, FALSE, error);
g_free(jobs_path);
LocalCapabilityRegistry *registry = store != NULL
? local_capability_registry_new(error) : NULL;
CoreGraphLimits limits = {500U, 1000U, 1024U * 1024U};
CoreGraphSnapshot *snapshot = registry != NULL
? core_graph_projection_service_collect_operational(database, limits,
registry, error) : NULL;
char *path = workspace_path(root, "core-snapshot.json");
gboolean ok = snapshot != NULL && core_graph_snapshot_write_atomic(
snapshot, manifest->synthetic, path, error) && export_correlations(
root, database, manifest->investigation_id, error) && export_planner(
root, database, store, registry, manifest->investigation_id, error);
g_free(path); core_graph_snapshot_free(snapshot);
local_capability_registry_free(registry); local_job_store_close(store);
return ok;
}
static gboolean review_observation(const char *root, const char *evidence_id,
const char *observation_id, const char *operation_id, const char *revision,
const char *action, const char *status, const char *corrected,
const char *entity_id, const char *author, const char *reason,
GError **error) {
char *end = NULL; guint64 expected = 0U;
ObservationReviewAction mapped;
if (revision == NULL) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"Révision invalide."); return FALSE;
}
expected = g_ascii_strtoull(revision, &end, 10);
if (end == revision || *end != '\0') {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"Révision invalide."); return FALSE;
}
if (g_strcmp0(action, "decide") == 0) mapped = OBSERVATION_REVIEW_ACTION_DECIDE;
else if (g_strcmp0(action, "correct") == 0) mapped = OBSERVATION_REVIEW_ACTION_CORRECT;
else if (g_strcmp0(action, "promote_create") == 0) mapped = OBSERVATION_REVIEW_ACTION_PROMOTE_CREATE;
else if (g_strcmp0(action, "promote_attach") == 0) mapped = OBSERVATION_REVIEW_ACTION_PROMOTE_ATTACH;
else if (g_strcmp0(action, "withdraw") == 0) mapped = OBSERVATION_REVIEW_ACTION_WITHDRAW_PROMOTION;
else { g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"Action de revue invalide."); return FALSE; }
Manifest manifest = {0}; Database *database = open_workspace_database(
root, &manifest, error); ObservationReviewService *service = database != NULL
? observation_review_service_new(database, error) : NULL;
char *occurred_at = now_iso(); ObservationReviewResult result = {0};
ObservationReviewRequest request = {operation_id, evidence_id, observation_id,
expected, mapped, status, corrected, entity_id, author, reason, occurred_at};
gboolean ok = service != NULL && observation_review_service_apply(
service, &request, &result, error);
/* CONTRACT: publication refreshes local projections only; no analysis job,
* network access, budget admission or EML parsing is triggered here. */
if (ok) ok = republish_after_review(root, database, &manifest, error);
if (ok) {
JsonBuilder *builder = json_builder_new(); json_builder_begin_object(builder);
json_add_nullable(builder, "contract", "labfy.observation_review.command.v1");
json_builder_set_member_name(builder, "replayed");
json_builder_add_boolean_value(builder, result.replayed);
json_builder_set_member_name(builder, "revision");
json_builder_add_int_value(builder, (gint64) result.revision);
json_add_nullable(builder, "entity_id", result.entity_identifier);
json_builder_set_member_name(builder, "entity_created");
json_builder_add_boolean_value(builder, result.entity_created);
json_builder_set_member_name(builder, "projections_refreshed");
json_builder_add_boolean_value(builder, TRUE); json_builder_end_object(builder);
ok = print_json_builder(builder, error); g_object_unref(builder);
}
observation_review_result_clear(&result); g_free(occurred_at);
observation_review_service_free(service); database_close(database);
manifest_clear(&manifest); return ok;
}
static char *research_hash(const char *value) {
return g_compute_checksum_for_string(G_CHECKSUM_SHA256, value, -1);
}
static gboolean research_open_db(const char *root, sqlite3 **out,
GError **error) {
char *path = workspace_path(root, ".labfy/runtime/jobs.sqlite");
int result = sqlite3_open_v2(path, out, SQLITE_OPEN_READONLY, NULL);
g_free(path);
if (result == SQLITE_OK) return TRUE;
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED,
"Snapshot de recherche indisponible.");
if (*out != NULL) sqlite3_close(*out);
*out = NULL; return FALSE;
}
static gboolean research_provider(char **out, GError **error) {
const char *authority = g_getenv("LABFY_RESEARCH_FIXTURE_AUTHORITY");
if (authority == NULL || !g_regex_match_simple(
"^127\\.0\\.0\\.1:([1-9][0-9]{0,4})$", authority, 0, 0)) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
"Fournisseur de laboratoire local non configuré."); return FALSE;
}
const char *separator = strrchr(authority, ':');
guint64 port = g_ascii_strtoull(separator + 1, NULL, 10);
if (port > 65535U || port == 8080U || port == 8081U) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_PERMISSION_DENIED,
"Port fournisseur de laboratoire interdit."); return FALSE;
}
*out = g_strdup(authority); return TRUE;
}
static gboolean research_load_selection(const char *root, gchar **ids,
GPtrArray *subjects, guint64 *snapshot_revision, GError **error) {
char *path = workspace_path(root, "core-snapshot.json");
JsonParser *parser = json_parser_new();
gboolean ok = json_parser_load_from_file(parser, path, error); g_free(path);
JsonObject *root_object = ok ? json_node_get_object(
json_parser_get_root(parser)) : NULL;
JsonArray *nodes = root_object != NULL ? json_object_get_array_member(
root_object, "nodes") : NULL;
if (nodes == NULL) ok = FALSE;
if (ok) *snapshot_revision = (guint64)json_object_get_int_member(
root_object, "revision");
for (gsize i = 0; ok && ids[i] != NULL; i++) {
const char *label = NULL;
for (guint j = 0; j < json_array_get_length(nodes); j++) {
JsonObject *node = json_array_get_object_element(nodes, j);
if (g_strcmp0(json_object_get_string_member(node, "id"), ids[i]) == 0)
label = json_object_get_string_member(node, "label");
}
if (label == NULL || !g_utf8_validate(label, -1, NULL)) ok = FALSE;
else g_ptr_array_add(subjects, g_strdup(label));
}
g_object_unref(parser);
if (!ok && error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"Sélection absente du snapshot cœur.");
return ok;
}
static gboolean research_prepare_json(const char *root, const char *selection,
const char *question, const char *exclusions, const char *revision,
const char *key, GError **error) {
if (selection == NULL || question == NULL || revision == NULL || key == NULL ||
!g_uuid_string_is_valid(key) || strlen(question) > 512U) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"Intention de recherche invalide."); return FALSE;
}
gchar **ids = g_strsplit(selection, ",", 9);
gsize count = g_strv_length(ids); char *end = NULL;
guint64 requested_revision = g_ascii_strtoull(revision, &end, 10);
GPtrArray *subjects = g_ptr_array_new_with_free_func(g_free);
guint64 current_revision = 0U; char *authority = NULL;
gboolean ok = count > 0U && count <= 8U && end != revision && *end == '\0' &&
research_load_selection(root, ids, subjects, &current_revision, error) &&
current_revision == requested_revision && research_provider(&authority, error);
if (!ok && error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED,
"Révision de sélection périmée.");
/* CONTRACT: every selected identifier must resolve exactly against the core
* snapshot before a plan or seed can be constructed. WHY: continuing after
* rejection indexes an empty subject array and turns invalid input into a
* process crash instead of the explicit bridge error. */
if (!ok) {
g_free(authority);
g_ptr_array_unref(subjects);
g_strfreev(ids);
return FALSE;
}
Manifest manifest = {0}; LocalJobStore *store = ok ? open_store(
root, &manifest, error) : NULL;
char *plan_id = derived_uuid(key, "research-plan");
char *action_ids[3] = {derived_uuid(key, "wave-1"),
derived_uuid(key, "wave-2"), derived_uuid(key, "refused")};
char *endpoints[3] = {NULL, NULL, NULL};
for (guint i = 0; authority != NULL && i < 3U; i++)
endpoints[i] = g_strdup_printf("http://%s/specimen/%s", authority,
i == 0 ? "wave-1" : i == 1 ? "wave-2" : "refused");
const char *subject = subjects->len > 0 ? g_ptr_array_index(subjects, 0) : NULL;
ResearchSeed *seeds = g_new0(ResearchSeed, count + 1U);
for (gsize i = 0; i < count; i++) seeds[i] = (ResearchSeed){
RESEARCH_SEED_SEARCH_TERM, g_ptr_array_index(subjects, i)};
seeds[count] = (ResearchSeed){RESEARCH_SEED_SEARCH_TERM, question};
ResearchAction actions[3] = {
{action_ids[0],"labfy.research.fixture.lookup.v1","fixture-provider",
endpoints[0],subject,RESEARCH_CONTACT_THIRD_PARTY,
"Le sujet exact est communiqué au fournisseur local SPECIMEN.",1U,4096U,2000U},
{action_ids[1],"labfy.research.fixture.followup.v1","fixture-provider",
endpoints[1],subject,RESEARCH_CONTACT_THIRD_PARTY,
"Le sujet exact est communiqué au second contact local SPECIMEN.",1U,4096U,2000U},
{action_ids[2],"labfy.research.fixture.optional.v1","fixture-provider",
endpoints[2],subject,RESEARCH_CONTACT_THIRD_PARTY,
"Action optionnelle : le sujet exact serait communiqué.",1U,4096U,2000U}};
char *material = g_strdup_printf("%s|%s|%s|%s|%s", manifest.investigation_id,
selection, question, exclusions != NULL ? exclusions : "",
authority != NULL ? authority : "");
char *fingerprint = research_hash(material); char *created = now_iso();
ResearchPlan plan = {RESEARCH_PLAN_CONTRACT,plan_id,key,fingerprint,fingerprint,
requested_revision,created,seeds,count+1U,actions,3U}; gboolean reused=FALSE;
ok = store != NULL && research_store_admit_plan(store,&plan,&reused,error);
if (ok) ok = research_snapshot_json(root,error);
g_free(created);g_free(fingerprint);g_free(material);g_free(seeds);
for(guint i=0;i<3U;i++){g_free(action_ids[i]);g_free(endpoints[i]);}
g_free(plan_id);g_free(authority);local_job_store_close(store);
manifest_clear(&manifest);g_ptr_array_unref(subjects);g_strfreev(ids);return ok;
}
static gboolean research_query_plan(sqlite3 *db, const char *plan_id,
char **fingerprint, guint64 *revision, GError **error) {
sqlite3_stmt *s=NULL; gboolean ok=sqlite3_prepare_v2(db,
"SELECT content_fingerprint,input_revision FROM research_plans WHERE plan_id=?1;",
-1,&s,NULL)==SQLITE_OK&&sqlite3_bind_text(s,1,plan_id,-1,SQLITE_TRANSIENT)==SQLITE_OK&&
sqlite3_step(s)==SQLITE_ROW;
if(ok){*fingerprint=g_strdup((const char*)sqlite3_column_text(s,0));
*revision=(guint64)sqlite3_column_int64(s,1);} sqlite3_finalize(s);
if(!ok)g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_NOT_FOUND,"Plan de recherche introuvable.");
return ok;
}
static gboolean research_grant_json(const char *root, const char *plan_id,
const char *revision, const char *actions_csv, const char *decisions_csv,
const char *exclusions_csv, const char *key, GError **error) {
if(plan_id==NULL||revision==NULL||actions_csv==NULL||decisions_csv==NULL||key==NULL||
!g_uuid_string_is_valid(plan_id)||!g_uuid_string_is_valid(key))return FALSE;
sqlite3 *db=NULL;char *plan_fp=NULL;guint64 stored_revision=0U;char *end=NULL;
guint64 expected=g_ascii_strtoull(revision,&end,10);
gboolean ok=end!=revision&&*end=='\0'&&research_open_db(root,&db,error)&&
research_query_plan(db,plan_id,&plan_fp,&stored_revision,error)&&expected==stored_revision;
sqlite3_close(db);gchar **actions=g_strsplit(actions_csv,",",9);
gchar **decision_items=g_strsplit(decisions_csv,",",9);
gchar **excluded=g_strsplit(exclusions_csv!=NULL?exclusions_csv:"",",",9);
gsize action_count=g_strv_length(actions),excluded_count=
exclusions_csv!=NULL&&*exclusions_csv!='\0'?g_strv_length(excluded):0U;
gsize decision_count=g_strv_length(decision_items);
ResearchActionDecision *decisions=g_new0(ResearchActionDecision,decision_count);
for(gsize i=0;ok&&i<decision_count;i++){
char *separator=strchr(decision_items[i],'=');
ok=separator!=NULL&&separator!=decision_items[i]&&separator[1]!='\0';
if(ok){*separator='\0';decisions[i].action_id=decision_items[i];
if(g_strcmp0(separator+1,"AUTHORIZE")==0)decisions[i].code=RESEARCH_ACTION_AUTHORIZE;
else if(g_strcmp0(separator+1,"DEFER")==0)decisions[i].code=RESEARCH_ACTION_DEFER;
else if(g_strcmp0(separator+1,"REFUSE")==0)decisions[i].code=RESEARCH_ACTION_REFUSE;
else ok=FALSE;}
}
Manifest manifest={0};LocalJobStore *store=ok?open_store(root,&manifest,error):NULL;
char *grant_id=derived_uuid(key,"research-grant");char *created=now_iso();
GDateTime *date=g_date_time_new_now_utc();GDateTime *future=g_date_time_add_hours(date,1);
char *expires=g_date_time_format(future,"%Y-%m-%dT%H:%M:%SZ");
char *material=g_strdup_printf("%s|%s|%s|%s",plan_fp!=NULL?plan_fp:"",actions_csv,
decisions_csv,exclusions_csv!=NULL?exclusions_csv:"");
char *fingerprint=research_hash(material);ScopeGrant grant={RESEARCH_GRANT_CONTRACT,
grant_id,manifest.investigation_id,key,fingerprint,plan_fp,created,expires,
(const char*const*)actions,action_count,(const char*const*)excluded,excluded_count,
(guint)action_count,4096U*action_count,2000U*action_count};gboolean reused=FALSE;
ok=ok&&store!=NULL&&action_count>0U&&action_count<=8U&&decision_count>0U&&
decision_count<=8U&&
research_store_admit_grant(store,&grant,decisions,decision_count,created,
&reused,error);
if(ok)ok=research_snapshot_json(root,error);
g_date_time_unref(future);g_date_time_unref(date);g_free(fingerprint);g_free(material);
g_free(expires);g_free(created);g_free(grant_id);g_free(plan_fp);g_strfreev(actions);
g_free(decisions);g_strfreev(decision_items);
g_strfreev(excluded);local_job_store_close(store);manifest_clear(&manifest);return ok;
}
static gboolean research_http_fixture(const char *endpoint, const char *action_id,
guint64 max_response_bytes, char **body, GError **error) {
GUri *uri=g_uri_parse(endpoint,G_URI_FLAGS_NONE,error);if(uri==NULL)return FALSE;
const char *host=g_uri_get_host(uri);gint port=g_uri_get_port(uri);
if(g_strcmp0(host,"127.0.0.1")!=0||port<=0||port==8080||port==8081){
g_uri_unref(uri);g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_PERMISSION_DENIED,
"Seul le fournisseur loopback SPECIMEN est autorisé.");return FALSE;}
GSocketClient *client=g_socket_client_new();g_socket_client_set_timeout(client,2U);
/* INVARIANT fixture: le proxy de l'hôte ne peut jamais détourner ce
* transport synthétique hors de l'autorité 127.0.0.1 validée ci-dessus. */
g_socket_client_set_enable_proxy(client,FALSE);
GSocketConnection *connection=g_socket_client_connect_to_host(client,host,(guint16)port,NULL,error);
gboolean ok=connection!=NULL;GString *request=g_string_new(NULL);
g_string_printf(request,"GET %s HTTP/1.1\r\nHost: %s:%d\r\nX-Labfy-Action: %s\r\nConnection: close\r\n\r\n",
g_uri_get_path(uri),host,port,action_id);
GOutputStream *out=ok?g_io_stream_get_output_stream(G_IO_STREAM(connection)):NULL;
ok=ok&&g_output_stream_write_all(out,request->str,request->len,NULL,NULL,error);
GInputStream *in=ok?g_io_stream_get_input_stream(G_IO_STREAM(connection)):NULL;
const guint64 max_header_bytes=4096U;
GByteArray *bytes=g_byte_array_new();guint8 buffer[1024];gssize got=0;
while(ok&&(got=g_input_stream_read(in,buffer,sizeof(buffer),NULL,error))>0){
g_byte_array_append(bytes,buffer,(guint)got);
g_byte_array_append(bytes,(const guint8 *)"",1U);
char *separator=strstr((char*)bytes->data,"\r\n\r\n");
g_byte_array_set_size(bytes,bytes->len-1U);
guint64 header_size=separator!=NULL?(guint64)(separator-(char*)bytes->data)+4U:
(guint64)bytes->len;
guint64 body_size=separator!=NULL?(guint64)bytes->len-header_size:0U;
if(header_size>max_header_bytes||body_size>max_response_bytes){ok=FALSE;
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_NO_SPACE,
"Réponse SPECIMEN trop volumineuse.");break;}}
if (got < 0)
ok = FALSE;
g_byte_array_append(bytes, (const guint8 *)"", 1U);
char *separator=ok?strstr((char*)bytes->data,"\r\n\r\n"):NULL;
ok=ok&&g_str_has_prefix((char*)bytes->data,"HTTP/1.1 200")&&separator!=NULL;
if(ok)*body=g_strdup(separator+4);else if(error!=NULL&&*error==NULL)
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_FAILED,"Réponse fournisseur SPECIMEN invalide.");
g_byte_array_unref(bytes);g_string_free(request,TRUE);g_clear_object(&connection);
g_object_unref(client);g_uri_unref(uri);return ok;
}
static gboolean research_result_exists(sqlite3 *db, const char *result_id,
gboolean *out_exists, GError **error) {
sqlite3_stmt *query=NULL;gboolean ok=sqlite3_prepare_v2(db,
"SELECT 1 FROM research_results WHERE result_id=?1;",-1,&query,NULL)==
SQLITE_OK&&sqlite3_bind_text(query,1,result_id,-1,SQLITE_TRANSIENT)==
SQLITE_OK;int step=ok?sqlite3_step(query):SQLITE_ERROR;
if(ok&&step!=SQLITE_ROW&&step!=SQLITE_DONE)ok=FALSE;
if(ok)
*out_exists=step==SQLITE_ROW;
sqlite3_finalize(query);
if(!ok)g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_FAILED,
"Lecture de l'état du résultat de recherche impossible.");
return ok;
}
static gboolean research_private_regular(const char *path, guint64 max_size,
guint64 *out_size) {
GFile *file=g_file_new_for_path(path);
GFileInfo *info=g_file_query_info(file,
G_FILE_ATTRIBUTE_STANDARD_TYPE "," G_FILE_ATTRIBUTE_STANDARD_SIZE,
G_FILE_QUERY_INFO_NOFOLLOW_SYMLINKS,NULL,NULL);
gboolean valid=info!=NULL&&g_file_info_get_file_type(info)==
G_FILE_TYPE_REGULAR&&g_file_info_get_size(info)>=0&&
(guint64)g_file_info_get_size(info)<=max_size;
if(valid&&out_size!=NULL)
*out_size=(guint64)g_file_info_get_size(info);
g_clear_object(&info);g_object_unref(file);return valid;
}
static gboolean research_marker_write(const char *path, const char *result_id,
const char *sha, GError **error) {
char *data=g_strdup_printf("labfy.research_publish.v1\n%s\n%s\n",result_id,sha);
gboolean ok=write_private_atomic(path,data,error);g_free(data);return ok;
}
static gboolean research_marker_matches(const char *path,
const char *result_id, const char *sha) {
if(!research_private_regular(path,256U,NULL))return FALSE;
char *data=NULL;gsize length=0U;
if(!g_file_get_contents(path,&data,&length,NULL))return FALSE;
char *expected=g_strdup_printf("labfy.research_publish.v1\n%s\n%s\n",
result_id,sha);gboolean matches=length==strlen(expected)&&
memcmp(data,expected,length)==0;g_free(expected);g_free(data);
return matches;
}
static gboolean research_reconcile_publication(sqlite3 *db,
LocalJobStore *store, gboolean reused, const ResearchAction *action,
const char *campaign_id, const char *grant_id, const char *created,
const char *result_id, const char *receipt_id, const char *relative,
const char *absolute, const char *staging, const char *marker,
const char *status, gboolean *out_handled, GError **error) {
gboolean exists=FALSE;*out_handled=FALSE;
if(!research_result_exists(db,result_id,&exists,error))return FALSE;
if(exists){*out_handled=TRUE;(void)g_remove(marker);(void)g_remove(staging);
return TRUE;}
if(!reused)return TRUE;
*out_handled=TRUE;
if(!g_file_test(marker,G_FILE_TEST_EXISTS)){
if(g_file_test(absolute,G_FILE_TEST_EXISTS)){
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_DATA,
"Artefact final sans journal de publication possédé.");return FALSE;}
return TRUE;
}
guint64 body_size=0U;char *body=NULL;gsize loaded=0U;
gboolean valid=research_private_regular(absolute,action->max_response_bytes,
&body_size)&&g_file_get_contents(absolute,&body,&loaded,NULL)&&
loaded==body_size;char *sha=valid?g_compute_checksum_for_data(
G_CHECKSUM_SHA256,(const guchar*)body,loaded):NULL;
valid=valid&&research_marker_matches(marker,result_id,sha);
if(valid){ResearchResult result={RESEARCH_RESULT_CONTRACT,result_id,
campaign_id,action->action_id,relative,sha,status};ResearchReceipt receipt={
RESEARCH_RECEIPT_CONTRACT,receipt_id,result_id,campaign_id,grant_id,
action->action_id,created,"ALLOW"};
valid=research_store_record_result(store,&result,&receipt,error);}
if(!valid){(void)g_remove(absolute);(void)g_remove(staging);(void)g_remove(marker);
if(error!=NULL&&*error==NULL)g_set_error_literal(error,G_IO_ERROR,
G_IO_ERROR_INVALID_DATA,"Publication de recherche interrompue incohérente.");}
else (void)g_remove(marker);
g_free(sha);g_free(body);return valid;
}
static gboolean research_campaign_json(const char *root, const char *grant_id,
const char *revision, const char *actions_csv, const char *key,
GError **error) {
if(grant_id==NULL||revision==NULL||actions_csv==NULL||key==NULL||
!g_uuid_string_is_valid(grant_id)||!g_uuid_string_is_valid(key))return FALSE;
gchar **requested=g_strsplit(actions_csv,",",9);gsize count=g_strv_length(requested);
char *end=NULL;guint64 expected=g_ascii_strtoull(revision,&end,10);
sqlite3 *db=NULL;sqlite3_stmt *meta=NULL;char *plan_fp=NULL;guint64 stored=0U;
gboolean ok=count>0U&&count<=8U&&end!=revision&&*end=='\0'&&
research_open_db(root,&db,error)&&sqlite3_prepare_v2(db,
"SELECT g.plan_fingerprint,p.input_revision FROM scope_grants g JOIN research_plans p ON p.content_fingerprint=g.plan_fingerprint WHERE g.grant_id=?1;",
-1,&meta,NULL)==SQLITE_OK&&sqlite3_bind_text(meta,1,grant_id,-1,SQLITE_TRANSIENT)==SQLITE_OK&&sqlite3_step(meta)==SQLITE_ROW;
if(ok){plan_fp=g_strdup((const char*)sqlite3_column_text(meta,0));stored=(guint64)sqlite3_column_int64(meta,1);ok=stored==expected;}
sqlite3_finalize(meta);if(!ok&&error!=NULL&&*error==NULL)g_set_error_literal(error,
G_IO_ERROR,G_IO_ERROR_FAILED,"Grant absent ou révision périmée.");
Manifest manifest={0};LocalJobStore *store=ok?open_store(root,&manifest,error):NULL;
char *campaign_id=derived_uuid(key,"research-campaign");char *created=now_iso();
char *material=g_strdup_printf("%s|%s|%s",grant_id,revision,actions_csv);
char *fingerprint=research_hash(material);ResearchCampaign campaign={
RESEARCH_CAMPAIGN_CONTRACT,campaign_id,manifest.investigation_id,grant_id,key,
fingerprint,created};gboolean reused=FALSE;
ok=store!=NULL&&research_store_admit_campaign(store,&campaign,&reused,error);
/* INVARIANT: un rejeu idempotent réconcilie une publication interrompue
* avant toute possibilité de reproduire le contact fournisseur. */
for(gsize i=0;ok&&i<count;i++){
sqlite3_stmt *a=NULL;ResearchAction action={0};
ok=sqlite3_prepare_v2(db,"SELECT a.capability_id,a.provider_id,a.endpoint,a.subject,a.contact_class,a.disclosure,a.max_requests,a.max_response_bytes,a.max_active_ms FROM research_actions a JOIN scope_grant_actions ga ON ga.action_id=a.action_id WHERE ga.grant_id=?1 AND a.action_id=?2;",-1,&a,NULL)==SQLITE_OK&&
sqlite3_bind_text(a,1,grant_id,-1,SQLITE_TRANSIENT)==SQLITE_OK&&
sqlite3_bind_text(a,2,requested[i],-1,SQLITE_TRANSIENT)==SQLITE_OK&&sqlite3_step(a)==SQLITE_ROW;
if(!ok){sqlite3_finalize(a);g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_PERMISSION_DENIED,"Action non autorisée par le grant.");break;}
action=(ResearchAction){requested[i],(const char*)sqlite3_column_text(a,0),
(const char*)sqlite3_column_text(a,1),(const char*)sqlite3_column_text(a,2),
(const char*)sqlite3_column_text(a,3),
g_strcmp0((const char*)sqlite3_column_text(a,4),"THIRD_PARTY")==0?
RESEARCH_CONTACT_THIRD_PARTY:RESEARCH_CONTACT_NONE,
(const char*)sqlite3_column_text(a,5),(guint)sqlite3_column_int64(a,6),
(guint64)sqlite3_column_int64(a,7),(guint)sqlite3_column_int64(a,8)};
ResearchPolicyRequest policy={manifest.investigation_id,plan_fp,&action,created,
action.max_requests,action.max_response_bytes,action.max_active_ms};
ResearchPolicyDecision decision=RESEARCH_POLICY_DENY_ACTION;
if(!reused)ok=research_store_policy_decide(store,grant_id,&policy,
&decision,error)&&decision==RESEARCH_POLICY_ALLOW;
char *endpoint=g_strdup(action.endpoint);sqlite3_finalize(a);
char *result_id=derived_uuid(key,requested[i]);char *receipt_id=
derived_uuid(result_id,"receipt");char *relative=g_strdup_printf(
".labfy/research/%s.txt",result_id);char *directory=workspace_path(root,
".labfy/research");char *absolute=workspace_path(root,relative);
char *staging_name=g_strdup_printf(".staging-%s-%s",result_id,key);
char *staging=g_build_filename(directory,staging_name,NULL);
char *marker_name=g_strdup_printf(".publishing-%s",result_id);
char *marker=g_build_filename(directory,marker_name,NULL);
const char *status=strstr(endpoint,"wave-1")?"NEW":
strstr(endpoint,"wave-2")?"CONTRADICTION":"MISSING";
gboolean handled=FALSE;
if(ok)ok=research_reconcile_publication(db,store,reused,&action,campaign_id,
grant_id,created,result_id,receipt_id,relative,absolute,staging,marker,
status,&handled,error);
char *body=NULL;
if(ok&&!handled)ok=research_http_fixture(endpoint,requested[i],
action.max_response_bytes,&body,error);
if(ok&&!handled){
gboolean staging_owned=FALSE,final_owned=FALSE;
if(g_mkdir_with_parents(directory,0700)!=0){ok=FALSE;g_set_error(error,
G_IO_ERROR,g_io_error_from_errno(errno),
"Création du staging de recherche impossible.");}
if(ok&&g_file_test(absolute,G_FILE_TEST_EXISTS)){ok=FALSE;
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_EXISTS,
"L'artefact final de recherche existe déjà.");}
if(ok){ok=write_private_atomic(staging,body,error);
staging_owned=g_file_test(staging,G_FILE_TEST_IS_REGULAR);}
char *sha=research_hash(body);
if(ok)ok=research_marker_write(marker,result_id,sha,error);
/* CONTRACT: le journal privé précède le renommage. Après un crash, il
* prouve la propriété et l'empreinte nécessaires à une réconciliation
* sans nouveau contact; il est retiré après le résultat/reçu atomique. */
if(ok&&g_rename(staging,absolute)!=0){ok=FALSE;g_set_error(error,G_IO_ERROR,
g_io_error_from_errno(errno),"Publication de l'artefact impossible.");}
else if(ok){staging_owned=FALSE;final_owned=TRUE;}
if(ok&&g_strcmp0(g_getenv("LABFY_TEST_RESEARCH_CRASH_AFTER_RENAME"),"1")==0)
_exit(86);
ResearchResult result={RESEARCH_RESULT_CONTRACT,result_id,campaign_id,
requested[i],relative,sha,status};ResearchReceipt receipt={
RESEARCH_RECEIPT_CONTRACT,receipt_id,result_id,campaign_id,grant_id,
requested[i],created,"ALLOW"};
if(ok)ok=research_store_record_result(store,&result,&receipt,error);
if(!ok&&final_owned)(void)g_remove(absolute);
if(staging_owned)(void)g_remove(staging);
(void)g_remove(marker);g_free(sha);}
g_free(body);g_free(marker);g_free(marker_name);g_free(staging);
g_free(staging_name);g_free(absolute);g_free(directory);g_free(relative);
g_free(receipt_id);g_free(result_id);g_free(endpoint);
}
sqlite3_close(db);if(ok)ok=research_snapshot_json(root,error);
g_free(fingerprint);g_free(material);g_free(created);g_free(campaign_id);
g_free(plan_fp);g_strfreev(requested);local_job_store_close(store);
manifest_clear(&manifest);return ok;
}
static gboolean research_snapshot_json(const char *root, GError **error) {
sqlite3 *db=NULL;if(!research_open_db(root,&db,error))return FALSE;
sqlite3_stmt *plan=NULL;gboolean has=sqlite3_prepare_v2(db,
"SELECT plan_id,content_fingerprint,input_revision,created_at,(SELECT subject FROM research_seeds s WHERE s.plan_id=p.plan_id ORDER BY rank DESC LIMIT 1) FROM research_plans p ORDER BY rowid DESC LIMIT 1;",
-1,&plan,NULL)==SQLITE_OK&&sqlite3_step(plan)==SQLITE_ROW;
JsonBuilder *b=json_builder_new();json_builder_begin_object(b);
json_builder_set_member_name(b,"contract");json_builder_add_string_value(b,"labfy.research.snapshot.v1");
json_builder_set_member_name(b,"state");json_builder_add_string_value(b,has?"PREPARED":"EMPTY");
if(has){const char *plan_id=(const char*)sqlite3_column_text(plan,0);
json_add_nullable(b,"plan_id",plan_id);json_add_nullable(b,"plan_fingerprint",(const char*)sqlite3_column_text(plan,1));
json_builder_set_member_name(b,"input_revision");json_builder_add_int_value(b,sqlite3_column_int64(plan,2));
json_add_nullable(b,"created_at",(const char*)sqlite3_column_text(plan,3));
json_add_nullable(b,"question",(const char*)sqlite3_column_text(plan,4));
sqlite3_stmt *a=NULL;sqlite3_prepare_v2(db,
"SELECT a.action_id,a.capability_id,a.provider_id,a.subject,a.contact_class,a.disclosure,a.max_requests,a.max_response_bytes,a.max_active_ms,EXISTS(SELECT 1 FROM research_receipts r WHERE r.action_id=a.action_id),COALESCE((SELECT rr.status FROM research_results rr WHERE rr.action_id=a.action_id ORDER BY rowid DESC LIMIT 1),''),COALESCE((SELECT d.decision_code FROM research_action_decisions d WHERE d.action_id=a.action_id),'DEFER'),a.rank FROM research_actions a WHERE a.plan_id=?1 ORDER BY a.rank;",-1,&a,NULL);
sqlite3_bind_text(a,1,plan_id,-1,SQLITE_TRANSIENT);json_builder_set_member_name(b,"actions");json_builder_begin_array(b);
while(sqlite3_step(a)==SQLITE_ROW){json_builder_begin_object(b);
const char *names[]={"action_id","capability_id","provider_id","subject","contact","disclosure"};
for(guint i=0;i<6U;i++)json_add_nullable(b,names[i],(const char*)sqlite3_column_text(a,(int)i));
json_builder_set_member_name(b,"max_requests");json_builder_add_int_value(b,sqlite3_column_int64(a,6));
json_builder_set_member_name(b,"max_response_bytes");json_builder_add_int_value(b,sqlite3_column_int64(a,7));
json_builder_set_member_name(b,"max_active_ms");json_builder_add_int_value(b,sqlite3_column_int64(a,8));
json_builder_set_member_name(b,"contacted");json_builder_add_boolean_value(b,sqlite3_column_int(a,9)!=0);
json_add_nullable(b,"result_status",(const char*)sqlite3_column_text(a,10));
json_add_nullable(b,"decision",(const char*)sqlite3_column_text(a,11));
json_builder_set_member_name(b,"wave");json_builder_add_int_value(b,sqlite3_column_int64(a,12)+1);
json_builder_end_object(b);}sqlite3_finalize(a);json_builder_end_array(b);
sqlite3_stmt *g=NULL;sqlite3_prepare_v2(db,"SELECT grant_id,created_at,expires_at,revoked_at FROM scope_grants WHERE plan_fingerprint=?1 ORDER BY rowid DESC;",-1,&g,NULL);
sqlite3_bind_text(g,1,(const char*)sqlite3_column_text(plan,1),-1,SQLITE_TRANSIENT);
json_builder_set_member_name(b,"grants");json_builder_begin_array(b);while(sqlite3_step(g)==SQLITE_ROW){json_builder_begin_object(b);json_add_nullable(b,"grant_id",(const char*)sqlite3_column_text(g,0));json_add_nullable(b,"created_at",(const char*)sqlite3_column_text(g,1));json_add_nullable(b,"expires_at",(const char*)sqlite3_column_text(g,2));json_add_nullable(b,"revoked_at",(const char*)sqlite3_column_text(g,3));json_builder_end_object(b);}sqlite3_finalize(g);json_builder_end_array(b);
} else {json_builder_set_member_name(b,"actions");json_builder_begin_array(b);json_builder_end_array(b);json_builder_set_member_name(b,"grants");json_builder_begin_array(b);json_builder_end_array(b);}
sqlite3_finalize(plan);json_builder_end_object(b);gboolean ok=print_json_builder(b,error);
g_object_unref(b);sqlite3_close(db);return ok;
}
static gboolean research_revoke_json(const char *root, const char *grant_id,
GError **error){Manifest manifest={0};LocalJobStore *store=open_store(root,&manifest,error);
char *now=now_iso();gboolean ok=store!=NULL&&research_store_revoke_grant(store,grant_id,now,error);
if (ok)
ok = research_snapshot_json(root, error);
g_free(now);
local_job_store_close(store);
manifest_clear(&manifest);return ok;}
static gboolean control(const char *root, const char *command,
const char *job_id, GError **error) {
Manifest manifest = {0};
LocalJobStore *store = open_store(root, &manifest, error);
char *now = now_iso();
gboolean ok = FALSE;
gboolean was_running = FALSE;
if (store != NULL && strcmp(command, "cancel") == 0) {
LocalJobRecord *target = local_job_store_find(store, job_id, error);
was_running = target != NULL && target->state == LOCAL_JOB_RUNNING;
local_job_record_free(target);
ok = (error == NULL || *error == NULL) &&
local_job_store_request_cancel(store, job_id, now, error);
if (ok)
printf("{\"contract\":\"labfy.local_jobs.command.v1\","
"\"accepted\":true,\"was_running\":%s}\n",
was_running ? "true" : "false");
}
else if (store != NULL && strcmp(command, "pause") == 0)
ok = local_job_store_set_paused(store, TRUE, now, error);
else if (store != NULL && strcmp(command, "resume") == 0)
ok = local_job_store_set_paused(store, FALSE, now, error) &&
local_job_store_set_stopped(store, FALSE, now, error);
else if (store != NULL && strcmp(command, "stop") == 0)
ok = local_job_store_request_stop(store, now, error);
g_free(now);
local_job_store_close(store);
manifest_clear(&manifest);
return ok;
}
static int demonstrate(const char *root, const char *self, GError **error) {
if (!init_specimen(root, error) || !enqueue_jobs(root, error))
return 1;
/* Deux processus réellement interrompus couvrent successivement les deux
* publications. Le successeur réouvre les mêmes fichiers et réconcilie le
* résultat typé avant de réclamer la demande suivante. */
for (guint crash = 0; crash < 2; crash++) {
pid_t child = fork();
if (child == 0) {
execl(self, self, "__test-crash", "--workspace", root, NULL);
_exit(127);
}
int status = 0;
if (child < 0 || waitpid(child, &status, 0) != child ||
!WIFEXITED(status) || WEXITSTATUS(status) != 86) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED,
"Le point de crash contrôlé n'a pas été atteint.");
return 1;
}
}
if (!run_worker(root, 0U, error))
return 1;
printf("SPECIMEN J5 repris sur le même espace : %s\n", root);
return status_or_export(root, TRUE, error) ? 0 : 1;
}
int main(int argc, char **argv) {
const char *workspace = NULL;
for (int i = 2; i + 1 < argc; i++)
if (strcmp(argv[i], "--workspace") == 0)
workspace = argv[i + 1];
if (argc < 2 || workspace == NULL) {
fprintf(stderr, "Usage: %s COMMAND --workspace DIR [JOB_ID]\n", argv[0]);
return 2;
}
GError *error = NULL;
gboolean ok = FALSE;
if (strcmp(argv[1], "init-specimen") == 0)
ok = init_specimen(workspace, &error);
else if (strcmp(argv[1], "validate-workspace-json") == 0)
ok = validate_workspace_json(workspace, &error);
else if (strcmp(argv[1], "create-workspace") == 0) {
const char *title = NULL;
for (int i = 4; i + 1 < argc; i += 2)
if (strcmp(argv[i], "--title") == 0) title = argv[i + 1];
ok = create_workspace(workspace, title, &error);
} else if (strcmp(argv[1], "confirm-import-json") == 0 ||
strcmp(argv[1], "__test-crash-import-after-commit") == 0) {
const char *upload=NULL,*key=NULL,*evidence=NULL,*name=NULL,*type=NULL;
const char *size=NULL,*hash=NULL,*source=NULL,*description=NULL;
for(int i=4;i+1<argc;i+=2){
if(strcmp(argv[i],"--upload")==0)upload=argv[i+1];
else if(strcmp(argv[i],"--key")==0)key=argv[i+1];
else if(strcmp(argv[i],"--evidence")==0)evidence=argv[i+1];
else if(strcmp(argv[i],"--name")==0)name=argv[i+1];
else if(strcmp(argv[i],"--type")==0)type=argv[i+1];
else if(strcmp(argv[i],"--size")==0)size=argv[i+1];
else if(strcmp(argv[i],"--sha256")==0)hash=argv[i+1];
else if(strcmp(argv[i],"--source")==0)source=argv[i+1];
else if(strcmp(argv[i],"--description")==0)description=argv[i+1];}
ok=confirm_import(workspace,upload,key,evidence,name,type,size,hash,
source,description,
strcmp(argv[1],"__test-crash-import-after-commit")==0,
&error);
}
else if (strcmp(argv[1], "init-j7-specimen") == 0)
ok = init_j7_specimen(workspace, &error);
else if (strcmp(argv[1], "evidence-preview-json") == 0) {
const char *evidence = NULL;
for (int i = 4; i + 1 < argc; i += 2)
if (strcmp(argv[i], "--evidence") == 0) evidence = argv[i + 1];
ok = print_evidence_preview(workspace, evidence, &error);
} else if (strcmp(argv[1], "observations-json") == 0) {
const char *evidence = NULL, *extraction = NULL;
for (int i = 4; i + 1 < argc; i += 2) {
if (strcmp(argv[i], "--evidence") == 0) evidence = argv[i + 1];
else if (strcmp(argv[i], "--extraction") == 0) extraction = argv[i + 1];
}
ok = print_observations(workspace, evidence, extraction, &error);
} else if (strcmp(argv[1], "review-observation-json") == 0) {
const char *evidence=NULL,*observation=NULL,*operation=NULL,*revision=NULL;
const char *action=NULL,*status=NULL,*corrected=NULL,*entity=NULL;
const char *author=NULL,*reason=NULL;
for (int i=4;i+1<argc;i+=2) {
if(strcmp(argv[i],"--evidence")==0)evidence=argv[i+1];
else if(strcmp(argv[i],"--observation")==0)observation=argv[i+1];
else if(strcmp(argv[i],"--operation")==0)operation=argv[i+1];
else if(strcmp(argv[i],"--revision")==0)revision=argv[i+1];
else if(strcmp(argv[i],"--action")==0)action=argv[i+1];
else if(strcmp(argv[i],"--status")==0)status=argv[i+1];
else if(strcmp(argv[i],"--corrected")==0)corrected=argv[i+1];
else if(strcmp(argv[i],"--entity")==0)entity=argv[i+1];
else if(strcmp(argv[i],"--author")==0)author=argv[i+1];
else if(strcmp(argv[i],"--reason")==0)reason=argv[i+1];
}
ok=review_observation(workspace,evidence,observation,operation,revision,
action,status,corrected,entity,author,reason,&error);
}
else if (strcmp(argv[1], "enqueue") == 0)
ok = enqueue_jobs(workspace, &error);
else if (strcmp(argv[1], "submit-json") == 0 && argc >= 9) {
const char *evidence = NULL, *capability = NULL, *key = NULL;
for (int i = 4; i + 1 < argc; i += 2) {
if (strcmp(argv[i], "--evidence") == 0)
evidence = argv[i + 1];
else if (strcmp(argv[i], "--capability") == 0)
capability = argv[i + 1];
else if (strcmp(argv[i], "--key") == 0)
key = argv[i + 1];
}
char *job_id = NULL;
ok = submit_job(workspace, evidence, capability, key, &job_id, &error);
if (ok)
printf("{\"contract\":\"labfy.local_jobs.command.v1\","
"\"accepted\":true,\"job_id\":\"%s\"}\n",
job_id);
g_free(job_id);
} else if (strcmp(argv[1], "submit-plan-json") == 0) {
const char *revision=NULL,*profile=NULL,*key=NULL,*ids=NULL;
for(int i=4;i+1<argc;i+=2){if(strcmp(argv[i],"--revision")==0)revision=argv[i+1];else if(strcmp(argv[i],"--profile")==0)profile=argv[i+1];else if(strcmp(argv[i],"--key")==0)key=argv[i+1];else if(strcmp(argv[i],"--recommendations")==0)ids=argv[i+1];}
ok=submit_plan(workspace,revision,profile,key,ids,FALSE,&error);
} else if (strcmp(argv[1], "__test-crash-after-admission") == 0) {
const char *revision=NULL,*profile=NULL,*key=NULL,*ids=NULL;
for(int i=4;i+1<argc;i+=2){if(strcmp(argv[i],"--revision")==0)revision=argv[i+1];else if(strcmp(argv[i],"--profile")==0)profile=argv[i+1];else if(strcmp(argv[i],"--key")==0)key=argv[i+1];else if(strcmp(argv[i],"--recommendations")==0)ids=argv[i+1];}
ok=submit_plan(workspace,revision,profile,key,ids,TRUE,&error);
} else if (strcmp(argv[1], "lookup-plan-json") == 0) {
const char *revision=NULL,*profile=NULL,*key=NULL,*ids=NULL;
for(int i=4;i+1<argc;i+=2){if(strcmp(argv[i],"--revision")==0)revision=argv[i+1];else if(strcmp(argv[i],"--profile")==0)profile=argv[i+1];else if(strcmp(argv[i],"--key")==0)key=argv[i+1];else if(strcmp(argv[i],"--recommendations")==0)ids=argv[i+1];}
ok=lookup_plan(workspace,revision,profile,key,ids,&error);
} else if (strcmp(argv[1], "prepare-report-json") == 0) {
const char *objects=NULL,*title=NULL,*comment=NULL,*generated=NULL,*sections=NULL;
for(int i=4;i+1<argc;i+=2){if(strcmp(argv[i],"--objects")==0)objects=argv[i+1];else if(strcmp(argv[i],"--title")==0)title=argv[i+1];else if(strcmp(argv[i],"--comment")==0)comment=argv[i+1];else if(strcmp(argv[i],"--generated-at")==0)generated=argv[i+1];else if(strcmp(argv[i],"--sections")==0)sections=argv[i+1];}
ok=prepare_report(workspace,objects,title,comment,generated,sections,&error);
} else if (strcmp(argv[1], "research-prepare-json") == 0) {
const char *selection=NULL,*question=NULL,*exclusions=NULL,*revision=NULL,*key=NULL;
for(int i=4;i+1<argc;i+=2){if(strcmp(argv[i],"--selection")==0)selection=argv[i+1];else if(strcmp(argv[i],"--question")==0)question=argv[i+1];else if(strcmp(argv[i],"--exclusions")==0)exclusions=argv[i+1];else if(strcmp(argv[i],"--revision")==0)revision=argv[i+1];else if(strcmp(argv[i],"--key")==0)key=argv[i+1];}
ok=research_prepare_json(workspace,selection,question,exclusions,revision,key,&error);
} else if (strcmp(argv[1], "research-grant-json") == 0) {
const char *plan=NULL,*revision=NULL,*actions=NULL,*decisions=NULL,*exclusions=NULL,*key=NULL;
for(int i=4;i+1<argc;i+=2){if(strcmp(argv[i],"--plan")==0)plan=argv[i+1];else if(strcmp(argv[i],"--revision")==0)revision=argv[i+1];else if(strcmp(argv[i],"--actions")==0)actions=argv[i+1];else if(strcmp(argv[i],"--decisions")==0)decisions=argv[i+1];else if(strcmp(argv[i],"--exclusions")==0)exclusions=argv[i+1];else if(strcmp(argv[i],"--key")==0)key=argv[i+1];}
ok=research_grant_json(workspace,plan,revision,actions,decisions,exclusions,key,&error);
} else if (strcmp(argv[1], "research-campaign-json") == 0) {
const char *grant=NULL,*revision=NULL,*actions=NULL,*key=NULL;
for(int i=4;i+1<argc;i+=2){if(strcmp(argv[i],"--grant")==0)grant=argv[i+1];else if(strcmp(argv[i],"--revision")==0)revision=argv[i+1];else if(strcmp(argv[i],"--actions")==0)actions=argv[i+1];else if(strcmp(argv[i],"--key")==0)key=argv[i+1];}
ok=research_campaign_json(workspace,grant,revision,actions,key,&error);
} else if (strcmp(argv[1], "research-snapshot-json") == 0)
ok=research_snapshot_json(workspace,&error);
else if (strcmp(argv[1], "research-revoke-json") == 0) {
const char *grant=NULL;for(int i=4;i+1<argc;i+=2)if(strcmp(argv[i],"--grant")==0)grant=argv[i+1];
ok=research_revoke_json(workspace,grant,&error);
} else if (strcmp(argv[1], "run") == 0)
ok = run_worker(workspace, 0U, &error);
else if (strcmp(argv[1], "__test-crash-after-claim") == 0)
ok = run_worker(workspace, 1U, &error);
else if (strcmp(argv[1], "__test-crash") == 0)
ok = run_worker(workspace, 2U, &error);
else if (strcmp(argv[1], "status") == 0)
ok = status_or_export(workspace, TRUE, &error);
else if (strcmp(argv[1], "export") == 0)
ok = status_or_export(workspace, FALSE, &error);
else if (strcmp(argv[1], "demo") == 0)
return demonstrate(workspace, argv[0], &error);
else if (strcmp(argv[1], "cancel") == 0 && argc >= 5)
ok = control(workspace, argv[1], argv[4], &error);
else if (strcmp(argv[1], "pause") == 0 || strcmp(argv[1], "resume") == 0 ||
strcmp(argv[1], "stop") == 0)
ok = control(workspace, argv[1], NULL, &error);
if (!ok) {
fprintf(stderr, "local-jobs: %s\n",
error != NULL ? error->message : "commande invalide");
g_clear_error(&error);
return 1;
}
return 0;
}