From 7597a167f8e7f17b523e32da4193182adc5a8bbc Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 16 Sep 2026 09:37:48 +0200 Subject: [PATCH] feat(web): add local HTTP infrastructure --- README.md | 10 +- docs/architecture.md | 24 +++ docs/current_state.md | 18 ++- docs/roadmap.md | 21 ++- docs/tests.md | 14 ++ tui/include/trainlog/cli.h | 27 ++++ tui/include/trainlog/paths.h | 11 ++ tui/include/trainlog/web_server.h | 17 ++ tui/meson.build | 32 ++++ tui/src/cli.c | 118 ++++++++++++++ tui/src/main.c | 164 ++++++++++---------- tui/src/paths.c | 74 +++++++++ tui/src/web_server.c | 249 ++++++++++++++++++++++++++++++ tui/tests/test_cli.c | 68 ++++++++ tui/tests/test_web_headers.c | 10 ++ tui/tests/test_web_server.c | 186 ++++++++++++++++++++++ 16 files changed, 936 insertions(+), 107 deletions(-) create mode 100644 tui/include/trainlog/cli.h create mode 100644 tui/include/trainlog/paths.h create mode 100644 tui/include/trainlog/web_server.h create mode 100644 tui/src/cli.c create mode 100644 tui/src/paths.c create mode 100644 tui/src/web_server.c create mode 100644 tui/tests/test_cli.c create mode 100644 tui/tests/test_web_headers.c create mode 100644 tui/tests/test_web_server.c diff --git a/README.md b/README.md index f7023fa..f9290e3 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ over direct MTP; it never copies SQLite database files between devices. | Local Web (planned) | Analyze and visualize canonical data; prepare programs and sessions through typed Trainlog Core services. | No interface reconstructs business truth from SQLite tables. The local Web is -a sibling adapter, not an extension of the TUI; `TRAINLOG_WEB_V1` is currently -contract-only and has no implementation. +a sibling adapter, not an extension of the TUI. Its loopback-only CLI/HTTP +infrastructure is implemented; its frontend and business API are not. ## Releases @@ -137,6 +137,7 @@ requires compatible versions of: - libudev; - libmtp; - Notcurses Core; +- GNU libmicrohttpd; - the standard math library. Distribution packages may pull additional transitive libraries, including @@ -164,7 +165,8 @@ Desktop builds require: - a C17 compiler toolchain; - `pkg-config`; - Notcurses Core development headers; -- SQLite, libuuid, utf8proc, libudev, and libmtp development headers; +- SQLite, libuuid, utf8proc, libudev, libmtp, and GNU libmicrohttpd development + headers; - Python 3 for generated sources, validators, import/export helpers, and tests. Android builds require JDK 17, an Android SDK supporting the configured API @@ -229,7 +231,7 @@ As of 2026-09-15: - `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`; - `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus Android-triggered bidirectional smoke test. -- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`. +- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`. The latest executable result belongs in [current state](docs/current_state.md), not in multiple README narratives. diff --git a/docs/architecture.md b/docs/architecture.md index d357371..290374e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -193,6 +193,30 @@ opened and migrated once before HTTP listening, used without concurrent direct SQLite-handle access, and closed by the process owner after server shutdown. A single-threaded event loop is the preferred server architecture. +`WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN` implements that owner. CLI parsing +precedes all path, database, terminal and HTTP initialization. The common owner +resolves the private XDG database path, opens/migrates it exactly once, dispatches +to either the unchanged TUI or Web adapter, then closes it exactly once. +`--help`, `--version` and CLI errors stop before path or database access. + +The HTTP adapter uses GNU libmicrohttpd in external-select mode with +`MHD_USE_NO_THREAD_SAFETY`: libmicrohttpd creates no polling or request thread. +All callbacks, future Core calls and SQLite access therefore run serially on +the process event-loop thread. A concrete IPv4 `sockaddr_in` binds only +`127.0.0.1`; port zero remains unavailable through the CLI. SIGINT/SIGTERM +handlers only set a `sig_atomic_t` flag. The loop then stops libmicrohttpd, +returns to the process owner and lets it close the database. + +The only implemented route is read-only `GET /api/v1/health`; `/` and every +unknown route return 404, and other methods on health return 405. The adapter +bounds concurrent/per-IP connections at 32, listen backlog at 32, per-connection +memory at 16 KiB, decoded request headers at 8 KiB, request bodies at 4 KiB and +idle time at 10 seconds. It validates `Host` against `127.0.0.1` and the active +port, emits explicit JSON content type, `nosniff`, `no-store`, a deny-all CSP +and no permissive CORS header. Assets, telemetry and remote resources are +absent. Before the first mutation route, origin validation and explicit +CSRF protection remain mandatory in addition to these transport bounds. + Every Web page retains one application shell: - Header navigation: Dashboard, Analyse, Programmes, Séances, Exercices; diff --git a/docs/current_state.md b/docs/current_state.md index 5b264fe..9638057 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -39,7 +39,8 @@ parallel implementation of its rules. | Dashboard characterization | `WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN` | | Dashboard Core read model | `WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` | | TUI Dashboard adoption | `WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` | -| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` | +| Web CLI/HTTP infrastructure | `WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN` | +| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` | Desktop and Android schema numbers are independent. Neither changes the frozen Trainlog JSON V1 contract. @@ -136,8 +137,10 @@ the result. No Dashboard HTTP endpoint exists yet. `TRAINLOG_WEB_V1` architecture, API independence, local-network boundary, browser shell, Dashboard, layout ownership, build/runtime separation, and -security invariants are now canonical. No Web server, frontend, CLI option, -HTTP endpoint, embedded asset, or layout persistence is implemented yet. +security invariants are canonical. The CLI and local HTTP infrastructure are +implemented: `-w`/`--web`, optional `--port`, exact IPv4 loopback binding and +the technical `/api/v1/health` route. No frontend, embedded asset, Dashboard +route, business mutation, browser launch or layout persistence exists yet. ## Data semantics @@ -169,6 +172,10 @@ The Dashboard Core extraction tranche passes **62/62 normal Meson tests** and **62/62 ASan/UBSan Meson tests**, including the standalone public-header and Core characterization targets, plus both canonical JSON validators. +The CLI/HTTP infrastructure tranche passes **67/67 normal Meson tests**, adding +pure CLI parsing, process-level help/version checks and an isolated loopback +HTTP lifecycle/security suite. The same suite is required under ASan/UBSan. + `TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence, formatting, layout-invariance and source-derived text-boundary tests, plus Android resource-parity, language-owner, typed sync-presentation, and @@ -181,9 +188,8 @@ smoke test are not automated. The latter is why ## Active limitations -- `TRAINLOG_WEB_V1` remains contract-only; Dashboard characterization, Core - extraction and TUI adoption are frozen. The next implementation cursor is - CLI/local HTTP infrastructure, not React. +- `TRAINLOG_WEB_V1` has only its Core prerequisite and CLI/HTTP infrastructure; + the next cursor is the frontend shell. No Dashboard HTTP route exists. - `APP_SHELL_V1` still awaits the recorded human visual/accessibility review. - AI proposal exchange still awaits one real Drive plus Android-triggered bidirectional smoke test. diff --git a/docs/roadmap.md b/docs/roadmap.md index 0eb6c8f..a4d3360 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -24,23 +24,20 @@ Desktop schema v18, Android schema v17, Notcurses, direct Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session Generator V1 is hidden pending V2. AI session-draft exchange and APP_SHELL_V1 retain their explicit manual validation/review gates. -`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`; its local-only -architecture is canonical, but no Web code exists yet. +`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only +CLI/HTTP infrastructure exists, but no frontend or business endpoint exists. ## Current cursor ```text -CURRENT_OPERATIONAL_CURSOR=WEB_CLI_HTTP_INFRASTRUCTURE_V1 +CURRENT_OPERATIONAL_CURSOR=WEB_FRONTEND_SHELL_V1 ``` -`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` and -`WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN`. The characterized Dashboard SQL, -facts, limits and calculations now live in the typed Core service -`trainlog_dashboard_load()`; the TUI supplies the selected period and current -time, then presents the returned snapshot. Adoption was necessary to prove -parity and was therefore completed in the extraction tranche rather than left -as an empty roadmap step. The next authorized slice is local CLI/HTTP -infrastructure; it does not yet authorize React or Dashboard Web metrics. +`WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN`. `trainlog -w` and `--web` now +dispatch through the common process owner to a bounded, single-threaded +loopback HTTP adapter after one database open. Only `/api/v1/health` exists. +The next slice is the frontend shell and embedded production assets; it does +not authorize Dashboard business routes or new metrics. ## TRAINLOG_WEB_V1 implementation gate @@ -50,7 +47,7 @@ Implementation order is fixed as: WEB_DASHBOARD_CHARACTERIZATION_V1 -> WEB_DASHBOARD_CORE_READ_MODEL_V1 [PASS/FROZEN] -> WEB_TUI_READ_MODEL_ADOPTION_V1 [PASS/FROZEN] - -> WEB_CLI_HTTP_INFRASTRUCTURE_V1 + -> WEB_CLI_HTTP_INFRASTRUCTURE_V1 [PASS/FROZEN] -> WEB_FRONTEND_SHELL_V1 -> WEB_DASHBOARD_V1 ``` diff --git a/docs/tests.md b/docs/tests.md index b4f1bb7..d3b9f08 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -13,6 +13,20 @@ A Trainlog feature is not complete without relevant validation. Frozen formats, persistence migrations, synchronization semantics, and user-data mutations require executable coverage where practical. +`WEB_CLI_HTTP_INFRASTRUCTURE_V1` adds pure CLI tests for the default TUI mode, +both Web switches, help, version, unknown and repeated options, positional +arguments, Web-only `--port`, missing/non-numeric/negative/zero/out-of-range +ports, 65535, and option ordering. Process-level tests execute `--help` and +`--version` without initializing the database, TUI or server. + +The isolated HTTP test opens one in-memory database, starts the production +single-threaded adapter on an explicitly reserved loopback port, and verifies +health JSON/version, content type, `nosniff`, CSP, absence of CORS, 404, 405 +with `Allow: GET`, invalid Host, oversized body, oversized-header rejection, +SIGTERM shutdown and explicit failure on an already occupied port. It never +uses the Internet. A separate real-launch smoke uses an isolated XDG directory, +performs the health request and stops the production binary with SIGINT. + ## 2. Frozen Trainlog JSON v1 Run: diff --git a/tui/include/trainlog/cli.h b/tui/include/trainlog/cli.h new file mode 100644 index 0000000..394250c --- /dev/null +++ b/tui/include/trainlog/cli.h @@ -0,0 +1,27 @@ +#ifndef TRAINLOG_CLI_H +#define TRAINLOG_CLI_H + +#include +#include + +typedef enum TrainlogRunMode { + TRAINLOG_RUN_TUI = 0, + TRAINLOG_RUN_WEB, + TRAINLOG_RUN_HELP, + TRAINLOG_RUN_VERSION +} TrainlogRunMode; + +typedef struct TrainlogCliOptions { + TrainlogRunMode mode; + uint16_t port; +} TrainlogCliOptions; + +/* CONTRACT: parsing is side-effect free apart from getopt's process-local + * state, which is reset for every call. No database, terminal or server is + * initialized. diagnostic is always NUL-terminated when capacity is nonzero. */ +int trainlog_cli_parse(int argc, char *const argv[], TrainlogCliOptions *options, + char *diagnostic, size_t diagnostic_capacity); + +void trainlog_cli_print_usage(const char *program_name); + +#endif diff --git a/tui/include/trainlog/paths.h b/tui/include/trainlog/paths.h new file mode 100644 index 0000000..0e8dfe6 --- /dev/null +++ b/tui/include/trainlog/paths.h @@ -0,0 +1,11 @@ +#ifndef TRAINLOG_PATHS_H +#define TRAINLOG_PATHS_H + +#include + +/* Resolve and create the private XDG data directory, then return trainlog.db. + * The caller owns output; truncation and non-directory collisions fail. */ +int trainlog_paths_database(char *output, size_t output_capacity, + char *diagnostic, size_t diagnostic_capacity); + +#endif diff --git a/tui/include/trainlog/web_server.h b/tui/include/trainlog/web_server.h new file mode 100644 index 0000000..efef344 --- /dev/null +++ b/tui/include/trainlog/web_server.h @@ -0,0 +1,17 @@ +#ifndef TRAINLOG_WEB_SERVER_H +#define TRAINLOG_WEB_SERVER_H + +#include +#include +#include + +#include "trainlog/database.h" + +/* Blocking, single-threaded local HTTP adapter. Port zero is reserved for + * controlled test harnesses and is rejected by the CLI. The database remains + * borrowed and process-owned; this infrastructure route never reads it. */ +int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port, + const volatile sig_atomic_t *stop_requested, + char *diagnostic, size_t diagnostic_capacity); + +#endif diff --git a/tui/meson.build b/tui/meson.build index 7001346..2b7101f 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -6,6 +6,7 @@ udev_dep = dependency('libudev', required: true) mtp_dep = dependency('libmtp', required: true) notcurses_dep = dependency('notcurses-core', required: true) +microhttpd_dep = dependency('libmicrohttpd', required: true) utf8proc_dep = dependency('libutf8proc', required: false) if not utf8proc_dep.found() @@ -83,6 +84,7 @@ strict_c_args = [ '-Wconversion', '-Wformat=2', '-Wshadow', + '-DTRAINLOG_VERSION="@0@"'.format(meson.project_version()), ] trainlog_core_sources = files( @@ -140,7 +142,9 @@ trainlog_core_dep = declare_dependency( ) trainlog_tui_sources = files( + 'src/cli.c', 'src/main.c', + 'src/paths.c', 'src/app_shell.c', 'src/presentation.c', 'src/chart.c', @@ -148,6 +152,7 @@ trainlog_tui_sources = files( 'src/terminal.c', 'src/theme.c', 'src/tui.c', + 'src/web_server.c', ) trainlog_exe = executable( @@ -156,6 +161,7 @@ trainlog_exe = executable( dependencies: [ trainlog_core_dep, notcurses_dep, + microhttpd_dep, ], c_args: strict_c_args, install: true, @@ -217,6 +223,32 @@ test_dashboard_header = executable( ) test('dashboard_header', test_dashboard_header) +test_cli = executable( + 'test_cli', + ['tests/test_cli.c', 'src/cli.c'], + include_directories: trainlog_include, + c_args: strict_c_args, +) +test('cli', test_cli) + +test_web_server = executable( + 'test_web_server', + ['tests/test_web_server.c', 'src/web_server.c'], + dependencies: [trainlog_core_dep, microhttpd_dep], + c_args: strict_c_args, +) +test('web_server', test_web_server, timeout: 30) +test('cli_help', trainlog_exe, args: ['--help']) +test('cli_version', trainlog_exe, args: ['--version']) + +test_web_headers = executable( + 'test_web_headers', + 'tests/test_web_headers.c', + include_directories: trainlog_include, + c_args: strict_c_args, +) +test('web_headers', test_web_headers) + test_training_knowledge = executable( 'test_training_knowledge', 'tests/test_training_knowledge.c', diff --git a/tui/src/cli.c b/tui/src/cli.c new file mode 100644 index 0000000..b413330 --- /dev/null +++ b/tui/src/cli.c @@ -0,0 +1,118 @@ +#include "trainlog/cli.h" + +#include +#include +#include +#include +#include +#include +#include + +#define TRAINLOG_WEB_DEFAULT_PORT 8080U + +static void cli_diagnostic(char *output, size_t capacity, const char *message) +{ + if (output != NULL && capacity > 0U) + (void)snprintf(output, capacity, "%s", message); +} + +static int parse_port(const char *value, uint16_t *port) +{ + char *end = NULL; + unsigned long parsed; + if (value == NULL || value[0] == '\0' || value[0] == '-') return -1; + errno = 0; + parsed = strtoul(value, &end, 10); + if (errno != 0 || end == value || *end != '\0' || parsed == 0UL || + parsed > 65535UL) return -1; + *port = (uint16_t)parsed; + return 0; +} + +int trainlog_cli_parse(int argc, char *const argv[], TrainlogCliOptions *options, + char *diagnostic, size_t diagnostic_capacity) +{ + static const struct option long_options[] = { + {"web", no_argument, NULL, 'w'}, + {"port", required_argument, NULL, 1}, + {"help", no_argument, NULL, 'h'}, + {"version", no_argument, NULL, 'V'}, + {NULL, 0, NULL, 0} + }; + bool web_seen = false; + bool port_seen = false; + bool help_seen = false; + bool version_seen = false; + int option; + if (options == NULL || argc < 1 || argv == NULL) return -1; + options->mode = TRAINLOG_RUN_TUI; + options->port = TRAINLOG_WEB_DEFAULT_PORT; + cli_diagnostic(diagnostic, diagnostic_capacity, ""); + opterr = 0; + optind = 0; + while ((option = getopt_long(argc, argv, "whV", long_options, NULL)) != -1) { + switch (option) { + case 'w': + if (web_seen) { + cli_diagnostic(diagnostic, diagnostic_capacity, + "l'option Web est répétée"); + return -1; + } + web_seen = true; + break; + case 1: + if (port_seen) { + cli_diagnostic(diagnostic, diagnostic_capacity, + "l'option --port est répétée"); + return -1; + } + if (parse_port(optarg, &options->port) != 0) { + cli_diagnostic(diagnostic, diagnostic_capacity, + "--port attend un entier compris entre 1 et 65535"); + return -1; + } + port_seen = true; + break; + case 'h': help_seen = true; break; + case 'V': version_seen = true; break; + case '?': + default: + cli_diagnostic(diagnostic, diagnostic_capacity, + "option inconnue ou argument manquant"); + return -1; + } + } + if (optind != argc) { + cli_diagnostic(diagnostic, diagnostic_capacity, + "aucun argument positionnel n'est accepté"); + return -1; + } + if ((help_seen && version_seen) || + ((help_seen || version_seen) && (web_seen || port_seen))) { + cli_diagnostic(diagnostic, diagnostic_capacity, + "--help et --version doivent être utilisés seuls"); + return -1; + } + if (port_seen && !web_seen) { + cli_diagnostic(diagnostic, diagnostic_capacity, + "--port n'est valide qu'avec -w ou --web"); + return -1; + } + if (help_seen) options->mode = TRAINLOG_RUN_HELP; + else if (version_seen) options->mode = TRAINLOG_RUN_VERSION; + else if (web_seen) options->mode = TRAINLOG_RUN_WEB; + return 0; +} + +void trainlog_cli_print_usage(const char *program_name) +{ + const char *name = program_name != NULL && program_name[0] != '\0' + ? program_name : "trainlog"; + (void)printf( + "Usage : %s [--help | --version | -w|--web [--port PORT]]\n" + " sans option lancer la TUI\n" + " -w, --web lancer le serveur Web local\n" + " --port PORT port Web explicite (1..65535, défaut 8080)\n" + " --help afficher cette aide\n" + " --version afficher la version\n", name); +} diff --git a/tui/src/main.c b/tui/src/main.c index 8f55121..5c4faec 100644 --- a/tui/src/main.c +++ b/tui/src/main.c @@ -1,118 +1,112 @@ -/** - * @file main.c - * @brief Trainlog TUI process entry point. - */ +/** @file main.c @brief Trainlog process owner and interface dispatcher. */ -#include #include +#include #include -#include #include #include +#include "trainlog/cli.h" #include "trainlog/database.h" +#include "trainlog/paths.h" #include "trainlog/tui.h" +#include "trainlog/web_server.h" -static int ensure_directory(const char *path) +static volatile sig_atomic_t web_stop_requested; + +static void request_web_stop(int signal_number) { - if (mkdir(path, 0700) == 0 || errno == EEXIST) { - return 0; - } - - return -1; + (void)signal_number; + web_stop_requested = 1; } -static int build_database_path(char *output, size_t output_size) +static int run_web(TrainlogDatabase *database, uint16_t port) { - const char *xdg = getenv("XDG_DATA_HOME"); - const char *home = getenv("HOME"); - char base[PATH_MAX]; - char trainlog_dir[PATH_MAX]; - int written; - - if (xdg != NULL && xdg[0] != '\0') { - written = snprintf(base, sizeof(base), "%s", xdg); - } else if (home != NULL && home[0] != '\0') { - written = snprintf(base, sizeof(base), "%s/.local/share", home); - } else { - return -1; + struct sigaction action; + struct sigaction previous_interrupt; + struct sigaction previous_terminate; + char diagnostic[256]; + int result; + (void)memset(&action, 0, sizeof(action)); + action.sa_handler = request_web_stop; + (void)sigemptyset(&action.sa_mask); + web_stop_requested = 0; + if (sigaction(SIGINT, &action, &previous_interrupt) != 0) { + (void)fprintf(stderr, + "trainlog: impossible d'installer les gestionnaires de signaux\n"); + return 1; } - - if (written < 0 || (size_t)written >= sizeof(base)) { - return -1; + if (sigaction(SIGTERM, &action, &previous_terminate) != 0) { + (void)sigaction(SIGINT, &previous_interrupt, NULL); + (void)fprintf(stderr, + "trainlog: impossible d'installer les gestionnaires de signaux\n"); + return 1; } - - /* - * Creating ~/.local and ~/.local/share recursively would normally require - * a generic mkdir -p helper. On Arch these parents already exist for a - * desktop user; XDG_DATA_HOME custom paths are likewise expected to have - * their parent created by the user. - */ - if (ensure_directory(base) != 0 && errno != EEXIST) { - /* Continue only when the base already exists. */ - struct stat info; - if (stat(base, &info) != 0 || !S_ISDIR(info.st_mode)) { - return -1; - } + result = trainlog_web_server_run(database, port, &web_stop_requested, + diagnostic, sizeof(diagnostic)); + (void)sigaction(SIGINT, &previous_interrupt, NULL); + (void)sigaction(SIGTERM, &previous_terminate, NULL); + if (result != 0) { + (void)fprintf(stderr, "trainlog: serveur Web : %s\n", + diagnostic[0] == '\0' ? "échec non détaillé" : diagnostic); + return 1; } - - written = snprintf( - trainlog_dir, - sizeof(trainlog_dir), - "%s/trainlog", - base - ); - if (written < 0 || (size_t)written >= sizeof(trainlog_dir)) { - return -1; - } - - if (ensure_directory(trainlog_dir) != 0) { - return -1; - } - - written = snprintf( - output, - output_size, - "%s/trainlog.db", - trainlog_dir - ); - - return written >= 0 && (size_t)written < output_size ? 0 : -1; + return 0; } -int main(void) +int main(int argc, char *argv[]) { char database_path[PATH_MAX]; + char path_diagnostic[256]; char database_diagnostic[256]; + char cli_diagnostic[256]; + TrainlogCliOptions options; TrainlogDatabase *database = NULL; TrainlogStatus status; int result; - if (build_database_path(database_path, sizeof(database_path)) != 0) { - (void)fprintf(stderr, "trainlog: unable to create data directory\n"); + if (trainlog_cli_parse(argc, argv, &options, cli_diagnostic, + sizeof(cli_diagnostic)) != 0) { + (void)fprintf(stderr, "trainlog: %s\n", cli_diagnostic); + trainlog_cli_print_usage(argv[0]); + return 2; + } + if (options.mode == TRAINLOG_RUN_HELP) { + trainlog_cli_print_usage(argv[0]); + return 0; + } + if (options.mode == TRAINLOG_RUN_VERSION) { + (void)printf("trainlog %s\n", TRAINLOG_VERSION); + return 0; + } + /* CONTRACT: newly created data directories and SQLite files are private + * to the local user, independently of the invoking shell's umask. */ + (void)umask(0077); + if (trainlog_paths_database(database_path, sizeof(database_path), + path_diagnostic, sizeof(path_diagnostic)) != 0) { + (void)fprintf(stderr, "trainlog: chemin de données : %s\n", + path_diagnostic); return 1; } - - status = trainlog_database_open_with_diagnostic( - database_path, - &database, - database_diagnostic, - sizeof(database_diagnostic) - ); + status = trainlog_database_open_with_diagnostic(database_path, &database, + database_diagnostic, sizeof(database_diagnostic)); if (status != TRAINLOG_STATUS_OK) { - (void)fprintf( - stderr, - "trainlog: unable to open database '%s' (status %d): %s\n", - database_path, - (int)status, - database_diagnostic[0] != '\0' - ? database_diagnostic - : "no SQLite diagnostic available" - ); + (void)fprintf(stderr, + "trainlog: impossible d'ouvrir la base '%s' (statut %d) : %s\n", + database_path, (int)status, + database_diagnostic[0] == '\0' + ? "diagnostic SQLite indisponible" : database_diagnostic); return 1; } - - result = trainlog_tui_run(database); + if (chmod(database_path, 0600) != 0) { + (void)fprintf(stderr, + "trainlog: impossible de protéger les permissions de la base\n"); + trainlog_database_close(database); + return 1; + } + result = options.mode == TRAINLOG_RUN_WEB + ? run_web(database, options.port) + : trainlog_tui_run(database); trainlog_database_close(database); return result; } diff --git a/tui/src/paths.c b/tui/src/paths.c new file mode 100644 index 0000000..add7e85 --- /dev/null +++ b/tui/src/paths.c @@ -0,0 +1,74 @@ +#include "trainlog/paths.h" + +#include +#include +#include +#include +#include + +static void path_diagnostic(char *output, size_t capacity, const char *message) +{ + if (output != NULL && capacity > 0U) + (void)snprintf(output, capacity, "%s", message); +} + +static int ensure_private_directory(const char *path) +{ + struct stat info; + if (mkdir(path, 0700) == 0) return 0; + if (errno != EEXIST || stat(path, &info) != 0 || !S_ISDIR(info.st_mode)) + return -1; + return 0; +} + +int trainlog_paths_database(char *output, size_t output_capacity, + char *diagnostic, size_t diagnostic_capacity) +{ + const char *xdg = getenv("XDG_DATA_HOME"); + const char *home = getenv("HOME"); + char base[PATH_MAX]; + char trainlog_dir[PATH_MAX]; + int written; + if (output == NULL || output_capacity == 0U) return -1; + output[0] = '\0'; + path_diagnostic(diagnostic, diagnostic_capacity, ""); + if (xdg != NULL && xdg[0] != '\0') + written = snprintf(base, sizeof(base), "%s", xdg); + else if (home != NULL && home[0] != '\0') + written = snprintf(base, sizeof(base), "%s/.local/share", home); + else { + path_diagnostic(diagnostic, diagnostic_capacity, + "XDG_DATA_HOME et HOME sont absents"); + return -1; + } + if (written < 0 || (size_t)written >= sizeof(base)) { + path_diagnostic(diagnostic, diagnostic_capacity, + "chemin de données trop long"); + return -1; + } + if (ensure_private_directory(base) != 0) { + path_diagnostic(diagnostic, diagnostic_capacity, + "répertoire de données inaccessible"); + return -1; + } + written = snprintf(trainlog_dir, sizeof(trainlog_dir), "%s/trainlog", base); + if (written < 0 || (size_t)written >= sizeof(trainlog_dir) || + ensure_private_directory(trainlog_dir) != 0) { + path_diagnostic(diagnostic, diagnostic_capacity, + "répertoire Trainlog inaccessible ou trop long"); + return -1; + } + if (chmod(trainlog_dir, 0700) != 0) { + path_diagnostic(diagnostic, diagnostic_capacity, + "permissions du répertoire Trainlog impossibles à garantir"); + return -1; + } + written = snprintf(output, output_capacity, "%s/trainlog.db", trainlog_dir); + if (written < 0 || (size_t)written >= output_capacity) { + output[0] = '\0'; + path_diagnostic(diagnostic, diagnostic_capacity, + "chemin de base de données trop long"); + return -1; + } + return 0; +} diff --git a/tui/src/web_server.c b/tui/src/web_server.c new file mode 100644 index 0000000..55dbeaf --- /dev/null +++ b/tui/src/web_server.c @@ -0,0 +1,249 @@ +#include "trainlog/web_server.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define TRAINLOG_HTTP_CONNECTION_LIMIT 32U +#define TRAINLOG_HTTP_PER_IP_LIMIT 32U +#define TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT (16U * 1024U) +#define TRAINLOG_HTTP_HEADER_LIMIT (8U * 1024U) +#define TRAINLOG_HTTP_BODY_LIMIT (4U * 1024U) +#define TRAINLOG_HTTP_TIMEOUT_SECONDS 10U +#define TRAINLOG_HTTP_BACKLOG 32U +#define TRAINLOG_HTTP_POLL_MAX_MS 250ULL + +typedef struct TrainlogWebContext { + uint16_t port; +} TrainlogWebContext; + +typedef struct TrainlogHttpRequestState { + size_t body_size; + bool body_too_large; +} TrainlogHttpRequestState; + +static void web_diagnostic(char *output, size_t capacity, const char *message) +{ + if (output != NULL && capacity > 0U) + (void)snprintf(output, capacity, "%s", message); +} + +static enum MHD_Result queue_json(struct MHD_Connection *connection, + unsigned int status, const char *body) +{ + struct MHD_Response *response; + enum MHD_Result result; + response = MHD_create_response_from_buffer(strlen(body), (void *)body, + MHD_RESPMEM_PERSISTENT); + if (response == NULL) return MHD_NO; + if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE, + "application/json; charset=utf-8") != MHD_YES || + MHD_add_response_header(response, + MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, + "no-store") != MHD_YES || + MHD_add_response_header(response, "Content-Security-Policy", + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'") != + MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, + "close") != MHD_YES) { + MHD_destroy_response(response); + return MHD_NO; + } + if (status == MHD_HTTP_METHOD_NOT_ALLOWED && + MHD_add_response_header(response, MHD_HTTP_HEADER_ALLOW, "GET") != + MHD_YES) { + MHD_destroy_response(response); + return MHD_NO; + } + result = MHD_queue_response(connection, status, response); + MHD_destroy_response(response); + return result; +} + +static bool valid_host(const TrainlogWebContext *context, const char *host) +{ + char expected[32]; + int written; + if (host == NULL) return false; + if (strcmp(host, "127.0.0.1") == 0) return true; + written = snprintf(expected, sizeof(expected), "127.0.0.1:%u", + (unsigned int)context->port); + return written > 0 && (size_t)written < sizeof(expected) && + strcmp(host, expected) == 0; +} + +static bool declared_body_too_large(struct MHD_Connection *connection) +{ + const char *value = MHD_lookup_connection_value(connection, + MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_LENGTH); + char *end = NULL; + unsigned long long size; + if (value == NULL) return false; + errno = 0; + size = strtoull(value, &end, 10); + return errno != 0 || end == value || *end != '\0' || + size > TRAINLOG_HTTP_BODY_LIMIT; +} + +static enum MHD_Result handle_request(void *closure, + struct MHD_Connection *connection, const char *url, const char *method, + const char *version, const char *upload_data, size_t *upload_data_size, + void **request_closure) +{ + static const char HEALTH[] = + "{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\"," + "\"version\":\"" TRAINLOG_VERSION "\"}\n"; + TrainlogWebContext *context = closure; + TrainlogHttpRequestState *state = *request_closure; + const union MHD_ConnectionInfo *header_info; + const char *host; + (void)version; + if (state == NULL) { + state = calloc(1U, sizeof(*state)); + if (state == NULL) return MHD_NO; + state->body_too_large = declared_body_too_large(connection); + *request_closure = state; + return MHD_YES; + } + if (*upload_data_size > 0U) { + if (*upload_data_size > TRAINLOG_HTTP_BODY_LIMIT - + (state->body_size > TRAINLOG_HTTP_BODY_LIMIT + ? TRAINLOG_HTTP_BODY_LIMIT : state->body_size)) + state->body_too_large = true; + else state->body_size += *upload_data_size; + (void)upload_data; + *upload_data_size = 0U; + return MHD_YES; + } + header_info = MHD_get_connection_info(connection, + MHD_CONNECTION_INFO_REQUEST_HEADER_SIZE); + if (header_info == NULL || header_info->header_size > + TRAINLOG_HTTP_HEADER_LIMIT) + return queue_json(connection, MHD_HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE, + "{\"error\":\"headers_too_large\"}\n"); + if (state->body_too_large) + return queue_json(connection, MHD_HTTP_CONTENT_TOO_LARGE, + "{\"error\":\"body_too_large\"}\n"); + host = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, + MHD_HTTP_HEADER_HOST); + if (!valid_host(context, host)) + return queue_json(connection, MHD_HTTP_BAD_REQUEST, + "{\"error\":\"invalid_host\"}\n"); + if (strcmp(url, "/api/v1/health") != 0) + return queue_json(connection, MHD_HTTP_NOT_FOUND, + "{\"error\":\"not_found\"}\n"); + if (strcmp(method, MHD_HTTP_METHOD_GET) != 0) + return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n"); + return queue_json(connection, MHD_HTTP_OK, HEALTH); +} + +static void request_completed(void *closure, struct MHD_Connection *connection, + void **request_closure, enum MHD_RequestTerminationCode termination) +{ + (void)closure; + (void)connection; + (void)termination; + free(*request_closure); + *request_closure = NULL; +} + +static enum MHD_Result accept_loopback_only(void *closure, + const struct sockaddr *address, socklen_t address_length) +{ + const struct sockaddr_in *ipv4; + (void)closure; + if (address == NULL || address_length < (socklen_t)sizeof(*ipv4) || + address->sa_family != AF_INET) return MHD_NO; + ipv4 = (const struct sockaddr_in *)address; + return ipv4->sin_addr.s_addr == htonl(INADDR_LOOPBACK) ? MHD_YES : MHD_NO; +} + +int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port, + const volatile sig_atomic_t *stop_requested, + char *diagnostic, size_t diagnostic_capacity) +{ + struct sockaddr_in bind_address; + struct MHD_Daemon *daemon; + TrainlogWebContext context; + int result = 0; + if (database == NULL || stop_requested == NULL) return -1; + web_diagnostic(diagnostic, diagnostic_capacity, ""); + (void)memset(&bind_address, 0, sizeof(bind_address)); + bind_address.sin_family = AF_INET; + bind_address.sin_port = htons(port); + bind_address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + context.port = port; + errno = 0; + daemon = MHD_start_daemon(MHD_USE_ERROR_LOG | MHD_USE_NO_THREAD_SAFETY, + 0U, accept_loopback_only, NULL, handle_request, &context, + MHD_OPTION_SOCK_ADDR_LEN, (socklen_t)sizeof(bind_address), &bind_address, + MHD_OPTION_CONNECTION_LIMIT, TRAINLOG_HTTP_CONNECTION_LIMIT, + MHD_OPTION_PER_IP_CONNECTION_LIMIT, TRAINLOG_HTTP_PER_IP_LIMIT, + MHD_OPTION_CONNECTION_MEMORY_LIMIT, + (size_t)TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT, + MHD_OPTION_CONNECTION_TIMEOUT, TRAINLOG_HTTP_TIMEOUT_SECONDS, + MHD_OPTION_LISTEN_BACKLOG_SIZE, TRAINLOG_HTTP_BACKLOG, + MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL, + MHD_OPTION_END); + if (daemon == NULL) { + char message[256]; + (void)snprintf(message, sizeof(message), + "impossible d'écouter sur 127.0.0.1:%u%s%s", + (unsigned int)port, errno == 0 ? "" : " : ", + errno == 0 ? "" : strerror(errno)); + web_diagnostic(diagnostic, diagnostic_capacity, message); + return -1; + } + (void)printf("Trainlog Web : http://127.0.0.1:%u/\n", (unsigned int)port); + (void)fflush(stdout); + while (*stop_requested == 0) { + fd_set read_set; + fd_set write_set; + fd_set except_set; + struct timeval timeout; + unsigned long long timeout_ms = TRAINLOG_HTTP_POLL_MAX_MS; + int maximum_fd = 0; + int selected; + FD_ZERO(&read_set); + FD_ZERO(&write_set); + FD_ZERO(&except_set); + if (MHD_get_fdset(daemon, &read_set, &write_set, &except_set, + &maximum_fd) != MHD_YES) { + web_diagnostic(diagnostic, diagnostic_capacity, + "impossible de préparer la boucle HTTP"); + result = -1; + break; + } + if (MHD_get_timeout(daemon, &timeout_ms) != MHD_YES || + timeout_ms > TRAINLOG_HTTP_POLL_MAX_MS) + timeout_ms = TRAINLOG_HTTP_POLL_MAX_MS; + timeout.tv_sec = (time_t)(timeout_ms / 1000ULL); + timeout.tv_usec = (suseconds_t)((timeout_ms % 1000ULL) * 1000ULL); + selected = select(maximum_fd + 1, &read_set, &write_set, &except_set, + &timeout); + if (selected < 0) { + if (errno == EINTR) continue; + web_diagnostic(diagnostic, diagnostic_capacity, + "échec de la boucle HTTP locale"); + result = -1; + break; + } + if (*stop_requested == 0 && MHD_run_from_select(daemon, &read_set, + &write_set, &except_set) != MHD_YES) { + web_diagnostic(diagnostic, diagnostic_capacity, + "échec du traitement HTTP local"); + result = -1; + break; + } + } + MHD_stop_daemon(daemon); + return result; +} diff --git a/tui/tests/test_cli.c b/tui/tests/test_cli.c new file mode 100644 index 0000000..3b808de --- /dev/null +++ b/tui/tests/test_cli.c @@ -0,0 +1,68 @@ +#include +#include +#include + +#include "trainlog/cli.h" + +#define CHECK(value) do { if (!(value)) { \ + (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \ + __FILE__, __LINE__, #value); return false; } } while (0) + +static bool parses(int argc, char **argv, TrainlogRunMode mode, + unsigned int port) +{ + TrainlogCliOptions options; + char diagnostic[128]; + CHECK(trainlog_cli_parse(argc, argv, &options, diagnostic, + sizeof(diagnostic)) == 0); + CHECK(diagnostic[0] == '\0'); + CHECK(options.mode == mode); + CHECK((unsigned int)options.port == port); + return true; +} + +static bool rejects(int argc, char **argv) +{ + TrainlogCliOptions options; + char diagnostic[128]; + CHECK(trainlog_cli_parse(argc, argv, &options, diagnostic, + sizeof(diagnostic)) != 0); + CHECK(diagnostic[0] != '\0'); + return true; +} + +int main(void) +{ + char *plain[] = {"trainlog", NULL}; + char *short_web[] = {"trainlog", "-w", NULL}; + char *long_web[] = {"trainlog", "--web", NULL}; + char *port[] = {"trainlog", "-w", "--port", "65535", NULL}; + char *port_first[] = {"trainlog", "--port", "8081", "--web", NULL}; + char *help[] = {"trainlog", "--help", NULL}; + char *version[] = {"trainlog", "--version", NULL}; + char *unknown[] = {"trainlog", "--unknown", NULL}; + char *port_alone[] = {"trainlog", "--port", "8080", NULL}; + char *port_missing[] = {"trainlog", "-w", "--port", NULL}; + char *port_text[] = {"trainlog", "-w", "--port", "abc", NULL}; + char *port_negative[] = {"trainlog", "-w", "--port", "-1", NULL}; + char *port_zero[] = {"trainlog", "-w", "--port", "0", NULL}; + char *port_large[] = {"trainlog", "-w", "--port", "65536", NULL}; + char *duplicate_web[] = {"trainlog", "-w", "--web", NULL}; + char *duplicate_port[] = {"trainlog", "-w", "--port", "80", + "--port", "81", NULL}; + char *mixed_help[] = {"trainlog", "--help", "--web", NULL}; + char *positional[] = {"trainlog", "extra", NULL}; + return parses(1, plain, TRAINLOG_RUN_TUI, 8080U) && + parses(2, short_web, TRAINLOG_RUN_WEB, 8080U) && + parses(2, long_web, TRAINLOG_RUN_WEB, 8080U) && + parses(4, port, TRAINLOG_RUN_WEB, 65535U) && + parses(4, port_first, TRAINLOG_RUN_WEB, 8081U) && + parses(2, help, TRAINLOG_RUN_HELP, 8080U) && + parses(2, version, TRAINLOG_RUN_VERSION, 8080U) && + rejects(2, unknown) && rejects(3, port_alone) && + rejects(3, port_missing) && rejects(4, port_text) && + rejects(4, port_negative) && rejects(4, port_zero) && + rejects(4, port_large) && rejects(3, duplicate_web) && + rejects(6, duplicate_port) && rejects(3, mixed_help) && + rejects(2, positional) ? 0 : 1; +} diff --git a/tui/tests/test_web_headers.c b/tui/tests/test_web_headers.c new file mode 100644 index 0000000..a3a220f --- /dev/null +++ b/tui/tests/test_web_headers.c @@ -0,0 +1,10 @@ +/* Standalone public-header compilation contract for the Web infrastructure. */ +#include "trainlog/cli.h" +#include "trainlog/paths.h" +#include "trainlog/web_server.h" + +int main(void) +{ + TrainlogCliOptions options = {TRAINLOG_RUN_TUI, 8080U}; + return options.mode == TRAINLOG_RUN_TUI && options.port == 8080U ? 0 : 1; +} diff --git a/tui/tests/test_web_server.c b/tui/tests/test_web_server.c new file mode 100644 index 0000000..8e71c54 --- /dev/null +++ b/tui/tests/test_web_server.c @@ -0,0 +1,186 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "trainlog/database.h" +#include "trainlog/web_server.h" + +#define CHECK(value) do { if (!(value)) { \ + (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \ + __FILE__, __LINE__, #value); return false; } } while (0) + +static volatile sig_atomic_t child_stop; + +static void stop_child(int signal_number) +{ + (void)signal_number; + child_stop = 1; +} + +static int reserve_port(uint16_t *port, int keep_open) +{ + struct sockaddr_in address; + socklen_t length = (socklen_t)sizeof(address); + int socket_fd = socket(AF_INET, SOCK_STREAM, 0); + if (socket_fd < 0) return -1; + (void)memset(&address, 0, sizeof(address)); + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + if (bind(socket_fd, (const struct sockaddr *)&address, sizeof(address)) != 0 || + getsockname(socket_fd, (struct sockaddr *)&address, &length) != 0 || + (keep_open != 0 && listen(socket_fd, 1) != 0)) { + (void)close(socket_fd); + return -1; + } + *port = ntohs(address.sin_port); + if (keep_open == 0) { + (void)close(socket_fd); + return -2; + } + return socket_fd; +} + +static int connect_with_retry(uint16_t port) +{ + struct sockaddr_in address; + struct timespec delay = {0, 10000000L}; + size_t attempt; + (void)memset(&address, 0, sizeof(address)); + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = htons(port); + for (attempt = 0U; attempt < 200U; ++attempt) { + int socket_fd = socket(AF_INET, SOCK_STREAM, 0); + if (socket_fd >= 0 && connect(socket_fd, + (const struct sockaddr *)&address, sizeof(address)) == 0) + return socket_fd; + if (socket_fd >= 0) (void)close(socket_fd); + (void)nanosleep(&delay, NULL); + } + return -1; +} + +static bool exchange(uint16_t port, const char *request, char *response, + size_t capacity) +{ + size_t sent = 0U; + size_t used = 0U; + size_t length = strlen(request); + int socket_fd = connect_with_retry(port); + CHECK(socket_fd >= 0); + while (sent < length) { + ssize_t count = send(socket_fd, request + sent, length - sent, 0); + CHECK(count > 0); + sent += (size_t)count; + } + (void)shutdown(socket_fd, SHUT_WR); + while (used + 1U < capacity) { + ssize_t count = recv(socket_fd, response + used, capacity - used - 1U, 0); + if (count == 0) break; + CHECK(count > 0); + used += (size_t)count; + } + response[used] = '\0'; + (void)close(socket_fd); + return true; +} + +static bool test_http_contract(TrainlogDatabase *database) +{ + char response[32768]; + char large_request[12000]; + uint16_t port; + pid_t child; + int status; + struct sigaction action; + CHECK(reserve_port(&port, 0) == -2); + child = fork(); + CHECK(child >= 0); + if (child == 0) { + char diagnostic[256]; + (void)memset(&action, 0, sizeof(action)); + action.sa_handler = stop_child; + (void)sigemptyset(&action.sa_mask); + (void)sigaction(SIGTERM, &action, NULL); + child_stop = 0; + _exit(trainlog_web_server_run(database, port, &child_stop, + diagnostic, sizeof(diagnostic)) == 0 ? 0 : 1); + } + CHECK(exchange(port, + "GET /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + response, sizeof(response))); + CHECK(strstr(response, "HTTP/1.1 200") != NULL); + CHECK(strstr(response, "Content-Type: application/json; charset=utf-8") != NULL); + CHECK(strstr(response, "X-Content-Type-Options: nosniff") != NULL); + CHECK(strstr(response, "Content-Security-Policy: default-src 'none'") != NULL); + CHECK(strstr(response, "Access-Control-Allow-Origin") == NULL); + CHECK(strstr(response, + "{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\"," + "\"version\":\"0.1.2\"}") != NULL); + CHECK(exchange(port, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + response, sizeof(response)) && strstr(response, "HTTP/1.1 404") != NULL); + CHECK(exchange(port, + "POST /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n", + response, sizeof(response)) && strstr(response, "HTTP/1.1 405") != NULL); + CHECK(strstr(response, "Allow: GET") != NULL); + CHECK(exchange(port, + "GET /api/v1/health HTTP/1.1\r\nHost: foreign.example\r\n\r\n", + response, sizeof(response)) && strstr(response, "HTTP/1.1 400") != NULL); + (void)snprintf(large_request, sizeof(large_request), + "POST /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\n" + "Content-Length: 5000\r\n\r\n%05000d", 0); + CHECK(exchange(port, large_request, response, sizeof(response)) && + strstr(response, "HTTP/1.1 413") != NULL); + (void)memset(large_request, 'a', sizeof(large_request)); + (void)snprintf(large_request, 128U, + "GET /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Large: "); + { + size_t prefix = strlen(large_request); + (void)memset(large_request + prefix, 'a', 8500U); + (void)memcpy(large_request + prefix + 8500U, "\r\n\r\n", 5U); + } + CHECK(exchange(port, large_request, response, sizeof(response)) && + (strstr(response, "HTTP/1.1 431") != NULL || + strstr(response, "HTTP/1.1 400") != NULL)); + CHECK(kill(child, SIGTERM) == 0); + CHECK(waitpid(child, &status, 0) == child); + CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0); + return true; +} + +static bool test_port_in_use(TrainlogDatabase *database) +{ + volatile sig_atomic_t stop = 0; + char diagnostic[256]; + uint16_t port; + int occupied = reserve_port(&port, 1); + CHECK(occupied >= 0); + CHECK(trainlog_web_server_run(database, port, &stop, diagnostic, + sizeof(diagnostic)) != 0); + CHECK(strstr(diagnostic, "127.0.0.1") != NULL); + (void)close(occupied); + return true; +} + +int main(void) +{ + TrainlogDatabase *database = NULL; + bool passed; + if (trainlog_database_open(":memory:", &database) != TRAINLOG_STATUS_OK) + return 1; + passed = test_http_contract(database) && test_port_in_use(database); + trainlog_database_close(database); + return passed ? 0 : 1; +}