diff --git a/studio/host/client/CMakeLists.txt b/studio/host/client/CMakeLists.txt new file mode 100644 index 0000000..40dc110 --- /dev/null +++ b/studio/host/client/CMakeLists.txt @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# Brique client du Studio host : client C++ du protocole Studio - Core 0.2 +# (docs/specs/protocole-studio-core-0.2.md §2, §3, §4 ; ADR 0006). +# Rôle, API et licences : voir README.md. + +add_library(sequa_host_client + src/client.cpp + src/wire.cpp) +add_library(sequa::host_client ALIAS sequa_host_client) + +target_include_directories(sequa_host_client + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_compile_features(sequa_host_client PUBLIC cxx_std_20) + +# nlohmann_json est PUBLIC : les charges utiles de l'API sont des nlohmann::json. +# ixwebsocket reste PRIVÉ : le transport ne fuit pas dans l'interface publique. +target_link_libraries(sequa_host_client + PUBLIC nlohmann_json::nlohmann_json + PRIVATE ixwebsocket::ixwebsocket) + +sequa_target_defaults(sequa_host_client) + +# --- Tests : faux serveur WebSocket local, sans dépendre de sequa_core_server ------------- +if(SEQUA_BUILD_TESTS) + add_executable(sequa_host_client_tests + tests/main.cpp + tests/test_wire.cpp + tests/test_client.cpp) + target_link_libraries(sequa_host_client_tests + PRIVATE sequa::host_client doctest::doctest ixwebsocket::ixwebsocket) + sequa_target_defaults(sequa_host_client_tests) + doctest_discover_tests(sequa_host_client_tests) +endif() diff --git a/studio/host/client/README.md b/studio/host/client/README.md new file mode 100644 index 0000000..1e84b54 --- /dev/null +++ b/studio/host/client/README.md @@ -0,0 +1,69 @@ +# Studio host — brique `client` + +Client C++ du **protocole Studio - Core 0.2** côté Studio host +(`docs/specs/protocole-studio-core-0.2.md` §2, §3, §4 ; ADR 0006). + +Bibliothèque statique `sequa_host_client` (alias `sequa::host_client`), en-têtes publics dans +`include/sequa/studio/host/`, espace de noms `sequa::studio::host`. Aucune exception ne remonte +à l'appelant : les opérations renvoient un type résultat (`Result`) portant soit une valeur, +soit une `Error` (code + message). + +## Rôle + +L'hôte n'accède au Core que par ce client : + +- se connecter (`hello` / `welcome`), s'attacher ou se détacher (`core/attach`, `core/detach`) ; +- piloter la session (`session/start`, `session/pause`, `session/resume`, `session/input`, + `session/status`) ; +- exporter/importer un enregistrement et **comparer les empreintes** tick par tick lors du + rechargement par rejeu (§7.6). + +Il ne fait **pas** : lancer le Core, gérer le processus, le fichier d'exécution, ni le service +HTTP (autres briques de `studio/host/`). + +## API publique + +`wire.hpp` — représentations du protocole (§2) : + +- `std::string to_hex64(std::uint64_t)` / `std::optional from_hex64(std::string_view)` : + entiers 64 bits en 16 caractères hexadécimaux minuscules ; analyse stricte. +- `std::string base64_encode(std::span)` / `(std::string_view)` et + `std::optional> base64_decode(std::string_view)` : base64 standard + RFC 4648, décodage strict. +- `std::optional> decode_hashes(std::string_view)` : base64 -> tableau + d'empreintes `uint64` petit-boutiste. +- `std::optional first_divergence(hashesA, hashesB)` : premier tick divergent, ou + `nullopt` si identiques. + +`client.hpp` — client : + +```cpp +struct Welcome { std::string protocol_version, core_version, instance_id; + std::int64_t pid; bool linked; }; + +ConnectResult connect(const std::string& url, std::chrono::milliseconds timeout); +RequestResult request(const std::string& type, const nlohmann::json& payload, + std::chrono::milliseconds timeout); +bool connected() const noexcept; +void close(); +void on_disconnect(std::function callback); +std::uint64_t ignored_message_count() const noexcept; +``` + +`request()` est sans état partagé : plusieurs fils peuvent l'appeler en parallèle, chacun avec +un `id` unique et reçoit **sa** réponse (les messages sans `id` ou d'`id` inconnu sont ignorés +et comptés). Les délais dépassés et les déconnexions sont signalés par `Error::code` +(`Timeout`, `ConnectFailed`, `ServerError`, `Closed`, `MalformedResponse`, +`IncompatibleVersion`, `NotConnected`). + +## Dépendances + +- **IXWebSocket** — BSD-3-Clause (lien PRIVÉ : non exposé dans l'interface publique). +- **nlohmann/json** — MIT (lien PUBLIC : les charges utiles de l'API sont des `nlohmann::json`). +- Le code de cette brique et les tests sont sous **MPL 2.0**. + +## Tests + +`sequa_host_client_tests` (doctest) s'exécute contre un **faux serveur WebSocket minimal** qui +suit la spec (welcome, réponses avec `id` recopié, `error`, requête lente, fermeture +volontaire). Il ne dépend pas de `sequa_core_server`. diff --git a/studio/host/client/include/sequa/studio/host/client.hpp b/studio/host/client/include/sequa/studio/host/client.hpp new file mode 100644 index 0000000..9faac22 --- /dev/null +++ b/studio/host/client/include/sequa/studio/host/client.hpp @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +// Client du protocole Studio - Core 0.2 côté Studio host +// (docs/specs/protocole-studio-core-0.2.md §2, §3, §4 ; ADR 0006). +// +// Le client ne lève jamais d'exception vers l'appelant : connect() et request() renvoient un +// type résultat (Result) portant soit une valeur, soit une Error (code + message). +namespace sequa::studio::host { + +// Version du protocole parlée par ce client et nom de client annoncé dans `hello` (spec §2, §3). +inline constexpr std::string_view protocol_version = "0.2.0"; +inline constexpr std::string_view host_client_name = "sequa-studio-host"; + +// Charge utile de la réponse `welcome` (spec §3). +struct Welcome { + std::string protocol_version; // `protocolVersion` du Core + std::string core_version; // `coreVersion` + std::string instance_id; // `instanceId` : 32 caractères hexadécimaux + std::int64_t pid = 0; // `pid` du processus Core + bool linked = false; // `linked` : état du bail (§5) +}; + +// Catégorie d'erreur renvoyée par le client. +enum class ErrorCode { + NotConnected, // aucune connexion ouverte au moment de la requête + Timeout, // délai dépassé (connexion ou réponse) + ConnectFailed, // connexion réseau impossible (hôte/port injoignable) + ServerError, // le Core a répondu un message `error` (voir Error::server_code) + Closed, // connexion fermée (voir Error::close_code) + MalformedResponse, // réponse illisible ou inattendue + IncompatibleVersion // version du protocole incompatible (règle 0.x, spec §2) +}; + +[[nodiscard]] std::string_view to_string(ErrorCode code) noexcept; + +// Erreur détaillée. `server_code` n'est renseigné que pour ErrorCode::ServerError, +// `close_code` que pour ErrorCode::Closed. +struct Error { + ErrorCode code = ErrorCode::NotConnected; + std::string message; + std::string server_code; + std::uint16_t close_code = 0; +}; + +// Résultat d'une opération : soit une valeur, soit une erreur. +template +struct Result { + std::optional value; + std::optional error; + + [[nodiscard]] bool ok() const noexcept { return value.has_value(); } + explicit operator bool() const noexcept { return ok(); } +}; + +using ConnectResult = Result; +using RequestResult = Result; + +// Règle de compatibilité 0.x (spec §2) : même majeure et, tant que la majeure vaut 0, +// même mineure. Une version illisible est incompatible. +[[nodiscard]] bool is_compatible_protocol_version(std::string_view remote) noexcept; + +// Client WebSocket du protocole Studio - Core. Une instance = une connexion. +// +// Thread-safety : request() et connected() peuvent être appelés depuis plusieurs fils ; +// chaque requête reçoit la réponse portant SON id. connect()/close() ne sont pas destinés à +// être appelés depuis plusieurs fils à la fois. +class CoreClient { +public: + // Rappel de déconnexion : appelé au plus une fois par déconnexion, avec le code et la + // raison de fermeture (code 0 si la connexion n'a jamais abouti). + using DisconnectCallback = std::function; + + CoreClient(); + ~CoreClient(); + + CoreClient(const CoreClient&) = delete; + CoreClient& operator=(const CoreClient&) = delete; + CoreClient(CoreClient&&) = delete; + CoreClient& operator=(CoreClient&&) = delete; + + // Ouvre `url` (URL complète, jeton inclus), envoie `hello` et attend `welcome`. + // Sans reconnexion automatique ni per-message deflate. + [[nodiscard]] ConnectResult connect(const std::string& url, std::chrono::milliseconds timeout); + + // Envoie une requête typée avec un id unique et attend la réponse portant le même id. + // Renvoie la charge utile si le type de réponse correspond, sinon une Error. + // Une déconnexion pendant l'attente est signalée immédiatement (sans attendre le délai). + [[nodiscard]] RequestResult request(const std::string& type, const nlohmann::json& payload, + std::chrono::milliseconds timeout); + + [[nodiscard]] bool connected() const noexcept; + + // Ferme la connexion. Idempotent ; sans effet si aucune connexion n'a été ouverte. + void close(); + + // Définit (ou remplace) le rappel de déconnexion. Il s'exécute sur le fil interne + // d'IXWebSocket : il ne doit ni appeler close(), ni détruire le client, ni bloquer + // (ce fil attendrait sa propre fin). Signaler l'événement et le traiter ailleurs. + void on_disconnect(DisconnectCallback callback); + + // Diagnostic : nombre de messages reçus sans id, d'id inconnu, ou illisibles. + [[nodiscard]] std::uint64_t ignored_message_count() const noexcept; + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace sequa::studio::host diff --git a/studio/host/client/include/sequa/studio/host/wire.hpp b/studio/host/client/include/sequa/studio/host/wire.hpp new file mode 100644 index 0000000..e074372 --- /dev/null +++ b/studio/host/client/include/sequa/studio/host/wire.hpp @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +// Petites aides de représentation du protocole Studio - Core 0.2 +// (docs/specs/protocole-studio-core-0.2.md §2) : entiers 64 bits en hexadécimal, +// données binaires en base64 standard, comparaison d'empreintes (§7.6). +namespace sequa::studio::host { + +// --- Entiers 64 bits (spec §2) ---------------------------------------------------------- +// Les entiers 64 bits ne sont jamais transmis en nombre JSON (perte de précision au-delà de +// 2^53 en JavaScript) mais en chaîne hexadécimale minuscule de 16 caractères. + +inline constexpr std::size_t hex64_length = 16; + +// Formate en 16 caractères hexadécimaux minuscules (complétés par des zéros à gauche). +[[nodiscard]] std::string to_hex64(std::uint64_t value); + +// Analyse stricte : exactement 16 caractères hexadécimaux MINUSCULES, sinon nullopt. +[[nodiscard]] std::optional from_hex64(std::string_view text) noexcept; + +// --- Base64 standard, RFC 4648, avec remplissage '=' (spec §2) -------------------------- +// Encodage des octets bruts ou d'un texte ; décodage strict (alphabet, longueur, remplissage +// et bits de queue nuls). +[[nodiscard]] std::string base64_encode(std::span bytes); +[[nodiscard]] std::string base64_encode(std::string_view text); +[[nodiscard]] std::optional> base64_decode(std::string_view text); + +// --- Empreintes (spec §4 session/export, §7.6 rechargement) ----------------------------- +// Tableau d'empreintes décodées : chaque empreinte est un uint64 transmis en petit-boutiste +// (8 octets) ; la longueur du bloc doit être un multiple de 8. +[[nodiscard]] std::optional> decode_hashes(std::string_view base64); + +// Compare deux suites d'empreintes tick par tick. Renvoie l'indice du premier tick divergent, +// ou nullopt si elles sont identiques (y compris même longueur et mêmes valeurs). Si l'une est +// un préfixe de l'autre, l'indice renvoyé est la longueur de la plus courte. +[[nodiscard]] std::optional first_divergence( + std::span expected, std::span actual) noexcept; + +} // namespace sequa::studio::host diff --git a/studio/host/client/src/client.cpp b/studio/host/client/src/client.cpp new file mode 100644 index 0000000..f4f1b2e --- /dev/null +++ b/studio/host/client/src/client.cpp @@ -0,0 +1,573 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/studio/host/client.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace sequa::studio::host { +namespace { + +// Version de l'enveloppe (spec §2 : `v` reste 1). +constexpr int envelope_version = 1; + +// Extrait (majeure, mineure) d'une version "x.y.z" ; renvoie false si illisible. +bool parse_major_minor(std::string_view text, int& major, int& minor) +{ + const std::size_t first = text.find('.'); + if (first == std::string_view::npos || first == 0U) { + return false; + } + const std::size_t second = text.find('.', first + 1U); + if (second == std::string_view::npos || second == first + 1U) { + return false; + } + + const auto to_int = [](std::string_view digits, int& out) { + if (digits.empty()) { + return false; + } + int value = 0; + for (const char c : digits) { + if (c < '0' || c > '9') { + return false; + } + value = value * 10 + (c - '0'); + } + out = value; + return true; + }; + return to_int(text.substr(0U, first), major) && + to_int(text.substr(first + 1U, second - first - 1U), minor); +} + +template +Result failure(ErrorCode code, std::string message, std::string server_code = {}, + std::uint16_t close_code = 0) +{ + Result result; + Error error; + error.code = code; + error.message = std::move(message); + error.server_code = std::move(server_code); + error.close_code = close_code; + result.error = std::move(error); + return result; +} + +template +Result success(T value) +{ + Result result; + result.value = std::move(value); + return result; +} + +} // namespace + +std::string_view to_string(ErrorCode code) noexcept +{ + switch (code) { + case ErrorCode::NotConnected: + return "not-connected"; + case ErrorCode::Timeout: + return "timeout"; + case ErrorCode::ConnectFailed: + return "connect-failed"; + case ErrorCode::ServerError: + return "server-error"; + case ErrorCode::Closed: + return "closed"; + case ErrorCode::MalformedResponse: + return "malformed-response"; + case ErrorCode::IncompatibleVersion: + return "incompatible-version"; + } + return "unknown"; +} + +bool is_compatible_protocol_version(std::string_view remote) noexcept +{ + int remote_major = 0; + int remote_minor = 0; + int local_major = 0; + int local_minor = 0; + if (!parse_major_minor(remote, remote_major, remote_minor) || + !parse_major_minor(protocol_version, local_major, local_minor)) { + return false; + } + if (remote_major != local_major) { + return false; + } + // Tant que la majeure vaut 0 (API instable), la mineure doit aussi correspondre. + return local_major != 0 || remote_minor == local_minor; +} + +struct CoreClient::Impl { + // Attente d'UNE réponse (requête identifiée ou message de poignée de main). + struct Inbox { + std::mutex mutex; + std::condition_variable cv; + bool done = false; + bool disconnected = false; + ErrorCode disconnect_kind = ErrorCode::Closed; + std::uint16_t close_code = 0; + std::string close_reason; + bool malformed = false; + bool is_error = false; + std::string error_code; + std::string error_message; + std::string response_type; + nlohmann::json response_payload; + }; + + Impl() = default; + ~Impl() + { + shutdown_socket(); + uninit_net(); + } + + Impl(const Impl&) = delete; + Impl& operator=(const Impl&) = delete; + + // --- Transport --------------------------------------------------------------------- + std::unique_ptr socket; + bool net_initialized = false; + + // --- État de connexion (protégé par mutex) ----------------------------------------- + mutable std::mutex mutex; + std::condition_variable cv; + bool open = false; + bool closed = false; + ErrorCode disconnect_kind = ErrorCode::Closed; + std::uint16_t close_code = 0; + std::string close_reason; + bool disconnect_notified = false; + DisconnectCallback disconnect_callback; + + // --- Corrélation des requêtes (protégé par mutex) ---------------------------------- + std::uint64_t next_id = 1; + std::map> pending; + std::shared_ptr handshake; + std::uint64_t ignored = 0; + + void init_net() + { + if (!net_initialized) { + net_initialized = ix::initNetSystem(); + } + } + + void uninit_net() + { + if (net_initialized) { + ix::uninitNetSystem(); + net_initialized = false; + } + } + + void count_ignored() + { + std::lock_guard lock(mutex); + ++ignored; + } + + void clear_handshake() + { + std::lock_guard lock(mutex); + handshake.reset(); + } + + void on_message(const ix::WebSocketMessagePtr& message) + { + switch (message->type) { + case ix::WebSocketMessageType::Open: { + std::lock_guard lock(mutex); + open = true; + cv.notify_all(); + return; + } + case ix::WebSocketMessageType::Message: + handle_text(message->str); + return; + case ix::WebSocketMessageType::Close: + handle_disconnect(ErrorCode::Closed, message->closeInfo.code, message->closeInfo.reason); + return; + case ix::WebSocketMessageType::Error: { + bool was_open = false; + { + std::lock_guard lock(mutex); + was_open = open && !closed; + } + // Erreur avant ouverture : connexion impossible ; après : déconnexion. + handle_disconnect(was_open ? ErrorCode::Closed : ErrorCode::ConnectFailed, 0, + message->errorInfo.reason); + return; + } + default: + return; + } + } + + void handle_text(const std::string& text) + { + nlohmann::json envelope; + try { + envelope = nlohmann::json::parse(text); + } catch (const nlohmann::json::exception&) { + count_ignored(); + return; + } + if (!envelope.is_object()) { + count_ignored(); + return; + } + + std::string id; + if (auto it = envelope.find("id"); it != envelope.end() && it->is_string()) { + id = it->get(); + } + + // Routage : par id (requête) ou vers la poignée de main (message sans id). + std::shared_ptr target; + { + std::lock_guard lock(mutex); + if (!id.empty()) { + if (auto it = pending.find(id); it != pending.end()) { + target = it->second; + } + } else if (handshake) { + target = handshake; + } + if (!target) { + ++ignored; // message sans id ou d'id inconnu : ignoré + return; + } + } + + const bool has_type = envelope.contains("type") && envelope["type"].is_string(); + const std::string type = has_type ? envelope["type"].get() : std::string(); + const bool has_payload = envelope.contains("payload") && envelope["payload"].is_object(); + const nlohmann::json payload = + has_payload ? envelope["payload"] : nlohmann::json::object(); + + { + std::lock_guard lock(target->mutex); + if (!has_type || !has_payload) { + target->malformed = true; + } else if (type == "error") { + target->is_error = true; + target->response_type = type; + target->error_code = payload.value("code", std::string()); + target->error_message = payload.value("message", std::string()); + } else { + target->response_type = type; + target->response_payload = payload; + } + target->done = true; + } + target->cv.notify_all(); + } + + void handle_disconnect(ErrorCode kind, std::uint16_t code, std::string reason) + { + std::vector> to_notify; + DisconnectCallback callback; + { + std::lock_guard lock(mutex); + if (closed) { + return; // déjà traité : le rappel n'est appelé qu'une fois + } + closed = true; + open = false; + disconnect_kind = kind; + close_code = code; + close_reason = reason; + for (auto& entry : pending) { + to_notify.push_back(entry.second); + } + pending.clear(); + if (handshake) { + to_notify.push_back(handshake); + handshake.reset(); + } + if (!disconnect_notified) { + disconnect_notified = true; + callback = disconnect_callback; + } + } + cv.notify_all(); + for (const std::shared_ptr& inbox : to_notify) { + { + std::lock_guard lock(inbox->mutex); + if (inbox->done) { + continue; + } + inbox->done = true; + inbox->disconnected = true; + inbox->disconnect_kind = kind; + inbox->close_code = code; + inbox->close_reason = reason; + } + inbox->cv.notify_all(); + } + if (callback) { + callback(code, reason); + } + } + + void shutdown_socket() + { + std::unique_ptr local; + { + std::lock_guard lock(mutex); + local = std::move(socket); + } + if (!local) { + return; // aucune connexion n'a été ouverte + } + local->stop(); + handle_disconnect(ErrorCode::Closed, ix::WebSocketCloseConstants::kNormalClosureCode, + std::string{}); + } +}; + +CoreClient::CoreClient() : impl_(std::make_unique()) {} + +CoreClient::~CoreClient() = default; + +ConnectResult CoreClient::connect(const std::string& url, std::chrono::milliseconds timeout) +{ + Impl& self = *impl_; + const auto deadline = std::chrono::steady_clock::now() + timeout; + + self.shutdown_socket(); + { + std::lock_guard lock(self.mutex); + self.open = false; + self.closed = false; + self.disconnect_kind = ErrorCode::Closed; + self.close_code = 0; + self.close_reason.clear(); + self.disconnect_notified = false; + } + + self.init_net(); + if (!self.net_initialized) { + return failure(ErrorCode::ConnectFailed, + "Initialisation du sous-système réseau impossible."); + } + + auto handshake = std::make_shared(); + { + std::lock_guard lock(self.mutex); + self.handshake = handshake; + } + + auto socket = std::make_unique(); + socket->setUrl(url); + socket->disableAutomaticReconnection(); + socket->disablePerMessageDeflate(); + socket->setOnMessageCallback( + [&self](const ix::WebSocketMessagePtr& message) { self.on_message(message); }); + self.socket = std::move(socket); + self.socket->start(); + + // 1. Attendre l'ouverture (ou l'échec). + bool opened = false; + { + std::unique_lock lock(self.mutex); + const bool signalled = + self.cv.wait_until(lock, deadline, [&self] { return self.open || self.closed; }); + opened = signalled && self.open && !self.closed; + if (!opened && signalled) { + const ErrorCode kind = self.disconnect_kind; + const std::uint16_t code = self.close_code; + const std::string reason = self.close_reason; + lock.unlock(); + self.clear_handshake(); + self.shutdown_socket(); + if (kind == ErrorCode::ConnectFailed) { + return failure(ErrorCode::ConnectFailed, + reason.empty() ? std::string("Connexion impossible.") : reason); + } + return failure( + ErrorCode::Closed, + reason.empty() ? std::string("Connexion fermée avant la poignée de main.") : reason, + std::string{}, code); + } + } + if (!opened) { + self.clear_handshake(); + self.shutdown_socket(); + return failure(ErrorCode::Timeout, "Délai de connexion dépassé."); + } + + // 2. Poignée de main : hello -> welcome (spec §3). + const nlohmann::json hello = { + {"v", envelope_version}, + {"type", "hello"}, + {"payload", + {{"protocolVersion", std::string(protocol_version)}, + {"client", std::string(host_client_name)}}}}; + self.socket->sendText(hello.dump()); + + { + std::unique_lock lock(handshake->mutex); + if (!handshake->cv.wait_until(lock, deadline, [&handshake] { return handshake->done; })) { + lock.unlock(); + self.clear_handshake(); + self.shutdown_socket(); + return failure(ErrorCode::Timeout, "Délai de poignée de main dépassé."); + } + } + self.clear_handshake(); + + if (handshake->disconnected) { + const ErrorCode kind = handshake->disconnect_kind; + const std::uint16_t code = handshake->close_code; + const std::string reason = handshake->close_reason; + self.shutdown_socket(); + if (kind == ErrorCode::ConnectFailed) { + return failure(ErrorCode::ConnectFailed, + reason.empty() ? std::string("Connexion impossible.") : reason); + } + return failure( + ErrorCode::Closed, + reason.empty() ? std::string("Connexion fermée pendant la poignée de main.") : reason, + std::string{}, code); + } + if (handshake->malformed) { + self.shutdown_socket(); + return failure(ErrorCode::MalformedResponse, "Réponse de poignée de main illisible."); + } + if (handshake->is_error) { + const std::string code = handshake->error_code; + const std::string message = handshake->error_message; + self.shutdown_socket(); + if (code == "incompatible-protocol") { + return failure(ErrorCode::IncompatibleVersion, + message.empty() ? std::string("Version incompatible.") : message, + code); + } + return failure(ErrorCode::ServerError, message, code); + } + if (handshake->response_type != "welcome") { + self.shutdown_socket(); + return failure(ErrorCode::MalformedResponse, + "Réponse inattendue : « " + handshake->response_type + " »."); + } + + Welcome welcome; + try { + const nlohmann::json& payload = handshake->response_payload; + welcome.protocol_version = payload.at("protocolVersion").get(); + welcome.core_version = payload.at("coreVersion").get(); + welcome.instance_id = payload.at("instanceId").get(); + welcome.pid = payload.at("pid").get(); + welcome.linked = payload.at("linked").get(); + } catch (const nlohmann::json::exception&) { + self.shutdown_socket(); + return failure(ErrorCode::MalformedResponse, "Message welcome illisible."); + } + + if (!is_compatible_protocol_version(welcome.protocol_version)) { + const std::string message = "Version du Core incompatible : « " + welcome.protocol_version + + " » (attendu « " + std::string(protocol_version) + " »)."; + self.shutdown_socket(); + return failure(ErrorCode::IncompatibleVersion, message); + } + return success(std::move(welcome)); +} + +RequestResult CoreClient::request(const std::string& type, const nlohmann::json& payload, + std::chrono::milliseconds timeout) +{ + Impl& self = *impl_; + const auto deadline = std::chrono::steady_clock::now() + timeout; + + auto inbox = std::make_shared(); + std::string id; + { + std::lock_guard lock(self.mutex); + if (!self.open || self.closed || !self.socket) { + return failure(ErrorCode::NotConnected, "Aucune connexion ouverte."); + } + id = std::to_string(self.next_id++); + self.pending.emplace(id, inbox); + } + + nlohmann::json envelope; + envelope["v"] = envelope_version; + envelope["type"] = type; + envelope["id"] = id; + envelope["payload"] = payload; + self.socket->sendText(envelope.dump()); + + { + std::unique_lock lock(inbox->mutex); + if (!inbox->cv.wait_until(lock, deadline, [&inbox] { return inbox->done; })) { + lock.unlock(); + std::lock_guard client_lock(self.mutex); + self.pending.erase(id); + return failure(ErrorCode::Timeout, + "Délai de réponse dépassé pour « " + type + " »."); + } + } + { + std::lock_guard client_lock(self.mutex); + self.pending.erase(id); + } + + if (inbox->disconnected) { + return failure( + ErrorCode::Closed, + inbox->close_reason.empty() ? std::string("Connexion fermée.") : inbox->close_reason, + std::string{}, inbox->close_code); + } + if (inbox->malformed) { + return failure(ErrorCode::MalformedResponse, "Réponse illisible du Core."); + } + if (inbox->is_error) { + return failure(ErrorCode::ServerError, inbox->error_message, + inbox->error_code); + } + if (inbox->response_type != type) { + return failure(ErrorCode::MalformedResponse, + "Type de réponse inattendu : « " + inbox->response_type + + " »."); + } + return success(inbox->response_payload); +} + +bool CoreClient::connected() const noexcept +{ + std::lock_guard lock(impl_->mutex); + return impl_->open && !impl_->closed && impl_->socket != nullptr; +} + +void CoreClient::close() +{ + impl_->shutdown_socket(); +} + +void CoreClient::on_disconnect(DisconnectCallback callback) +{ + std::lock_guard lock(impl_->mutex); + impl_->disconnect_callback = std::move(callback); +} + +std::uint64_t CoreClient::ignored_message_count() const noexcept +{ + std::lock_guard lock(impl_->mutex); + return impl_->ignored; +} + +} // namespace sequa::studio::host diff --git a/studio/host/client/src/wire.cpp b/studio/host/client/src/wire.cpp new file mode 100644 index 0000000..8c6807a --- /dev/null +++ b/studio/host/client/src/wire.cpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/studio/host/wire.hpp" + +#include + +namespace sequa::studio::host { +namespace { + +constexpr char hex_digits[] = "0123456789abcdef"; +constexpr char base64_alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +} // namespace + +std::string to_hex64(std::uint64_t value) +{ + std::string out(hex64_length, '0'); + for (std::size_t i = hex64_length; i-- > 0;) { + out[i] = hex_digits[value & 0xFU]; + value >>= 4U; + } + return out; +} + +std::optional from_hex64(std::string_view text) noexcept +{ + if (text.size() != hex64_length) { + return std::nullopt; + } + std::uint64_t value = 0; + for (const char c : text) { + std::uint64_t nibble = 0; + if (c >= '0' && c <= '9') { + nibble = static_cast(c - '0'); + } else if (c >= 'a' && c <= 'f') { + nibble = static_cast(c - 'a' + 10); + } else { + return std::nullopt; // majuscules et tout autre caractère refusés + } + value = (value << 4U) | nibble; + } + return value; +} + +std::string base64_encode(std::span bytes) +{ + std::string out; + out.reserve(((bytes.size() + 2U) / 3U) * 4U); + + std::size_t i = 0; + while (i + 3U <= bytes.size()) { + const std::uint32_t triple = (static_cast(bytes[i]) << 16U) | + (static_cast(bytes[i + 1U]) << 8U) | + static_cast(bytes[i + 2U]); + out.push_back(base64_alphabet[(triple >> 18U) & 0x3FU]); + out.push_back(base64_alphabet[(triple >> 12U) & 0x3FU]); + out.push_back(base64_alphabet[(triple >> 6U) & 0x3FU]); + out.push_back(base64_alphabet[triple & 0x3FU]); + i += 3U; + } + + const std::size_t rest = bytes.size() - i; + if (rest == 1U) { + const std::uint32_t triple = static_cast(bytes[i]) << 16U; + out.push_back(base64_alphabet[(triple >> 18U) & 0x3FU]); + out.push_back(base64_alphabet[(triple >> 12U) & 0x3FU]); + out.push_back('='); + out.push_back('='); + } else if (rest == 2U) { + const std::uint32_t triple = (static_cast(bytes[i]) << 16U) | + (static_cast(bytes[i + 1U]) << 8U); + out.push_back(base64_alphabet[(triple >> 18U) & 0x3FU]); + out.push_back(base64_alphabet[(triple >> 12U) & 0x3FU]); + out.push_back(base64_alphabet[(triple >> 6U) & 0x3FU]); + out.push_back('='); + } + return out; +} + +std::string base64_encode(std::string_view text) +{ + return base64_encode(std::span( + reinterpret_cast(text.data()), text.size())); +} + +std::optional> base64_decode(std::string_view text) +{ + if (text.size() % 4U != 0U) { + return std::nullopt; + } + + std::vector out; + out.reserve(text.size() / 4U * 3U); + if (text.empty()) { + return out; + } + + // Remplissage uniquement en fin de chaîne (0, 1 ou 2 '='). + std::size_t padding = 0; + if (text[text.size() - 1U] == '=') { + padding = 1U; + } + if (text.size() >= 2U && text[text.size() - 2U] == '=') { + padding = 2U; + } + const std::size_t data_length = text.size() - padding; + + std::uint32_t accumulator = 0; + int bits = 0; + for (std::size_t i = 0; i < data_length; ++i) { + const char c = text[i]; + int value = -1; + if (c >= 'A' && c <= 'Z') { + value = c - 'A'; + } else if (c >= 'a' && c <= 'z') { + value = c - 'a' + 26; + } else if (c >= '0' && c <= '9') { + value = c - '0' + 52; + } else if (c == '+') { + value = 62; + } else if (c == '/') { + value = 63; + } else { + return std::nullopt; // '=' au milieu ou caractère hors alphabet + } + accumulator = (accumulator << 6U) | static_cast(value); + bits += 6; + if (bits >= 8) { + bits -= 8; + out.push_back(static_cast((accumulator >> static_cast(bits)) & 0xFFU)); + // Ne conserver que les bits non encore consommés. + accumulator = bits == 0 ? 0U : accumulator & ((1U << static_cast(bits)) - 1U); + } + } + + // Bits de queue : doivent être nuls (RFC 4648 strict). + if (bits > 0 && (accumulator & ((1U << static_cast(bits)) - 1U)) != 0U) { + return std::nullopt; + } + + // Cohérence du remplissage avec la longueur des données. + const std::size_t remainder = data_length % 4U; + if ((padding == 0U && remainder != 0U) || (padding == 1U && remainder != 3U) || + (padding == 2U && remainder != 2U)) { + return std::nullopt; + } + return out; +} + +std::optional> decode_hashes(std::string_view base64) +{ + std::optional> bytes = base64_decode(base64); + if (!bytes.has_value() || bytes->size() % 8U != 0U) { + return std::nullopt; + } + std::vector hashes; + hashes.reserve(bytes->size() / 8U); + for (std::size_t i = 0; i < bytes->size(); i += 8U) { + std::uint64_t value = 0; + for (std::size_t k = 0; k < 8U; ++k) { + value |= static_cast((*bytes)[i + k]) << (8U * k); + } + hashes.push_back(value); + } + return hashes; +} + +std::optional first_divergence(std::span expected, + std::span actual) noexcept +{ + const std::size_t common = std::min(expected.size(), actual.size()); + for (std::size_t i = 0; i < common; ++i) { + if (expected[i] != actual[i]) { + return i; + } + } + if (expected.size() != actual.size()) { + return common; + } + return std::nullopt; +} + +} // namespace sequa::studio::host diff --git a/studio/host/client/tests/main.cpp b/studio/host/client/tests/main.cpp new file mode 100644 index 0000000..ecb715c --- /dev/null +++ b/studio/host/client/tests/main.cpp @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: MPL-2.0 +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include diff --git a/studio/host/client/tests/test_client.cpp b/studio/host/client/tests/test_client.cpp new file mode 100644 index 0000000..94e1765 --- /dev/null +++ b/studio/host/client/tests/test_client.cpp @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Tests d'intégration du CoreClient contre un FAUX serveur WebSocket minimal qui suit la +// spécification 0.2 (welcome, réponses avec id recopié, error, requête lente, fermeture +// volontaire). Ce test ne dépend PAS de sequa_core_server : le Core 0.2 est réécrit en +// parallèle par un autre agent. +#include "sequa/studio/host/client.hpp" +#include "sequa/studio/host/wire.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace sequa::studio::host; +using nlohmann::json; + +namespace { + +// IXWebSocket ne rapporte pas le port choisi par le système pour le port 0 : on parcourt une +// plage de ports locaux jusqu'au premier libre (plage réservée à cette brique). +constexpr int first_port = 19300; +constexpr int last_port = 19399; + +std::string encode_message(const std::string& type, const std::string& id, const json& payload) +{ + json envelope{{"v", 1}, {"type", type}, {"payload", payload}}; + if (!id.empty()) { + envelope["id"] = id; + } + return envelope.dump(); +} + +std::string encode_error(const std::string& id, const std::string& code, + const std::string& message) +{ + return encode_message("error", id, json{{"code", code}, {"message", message}}); +} + +// Faux Core : suit le profil minimal du protocole 0.2. +class FakeCore { +public: + enum class Handshake { welcome, welcome_incompatible, error_incompatible }; + + explicit FakeCore(Handshake handshake = Handshake::welcome) : handshake_(handshake) + { + ix::initNetSystem(); + } + + ~FakeCore() + { + stop(); + ix::uninitNetSystem(); + } + + FakeCore(const FakeCore&) = delete; + FakeCore& operator=(const FakeCore&) = delete; + + bool start() + { + for (int port = first_port; port <= last_port; ++port) { + auto server = std::make_unique(port, "127.0.0.1"); + server->disablePerMessageDeflate(); + server->setOnClientMessageCallback( + [this](std::shared_ptr, ix::WebSocket& socket, + const ix::WebSocketMessagePtr& message) { on_message(socket, message); }); + if (server->listenAndStart()) { + server_ = std::move(server); + port_ = port; + return true; + } + } + return false; + } + + void stop() + { + if (server_) { + server_->stop(); + server_.reset(); + } + } + + [[nodiscard]] int port() const { return port_; } + + [[nodiscard]] std::string url() const + { + return "ws://127.0.0.1:" + std::to_string(port_) + "/"; + } + + [[nodiscard]] std::string url_with_token(const std::string& token) const + { + return "ws://127.0.0.1:" + std::to_string(port_) + "/?token=" + token; + } + + // URI vue par le serveur (chemin + requête), pour vérifier que le jeton est bien transmis. + [[nodiscard]] std::string last_uri() const + { + std::lock_guard lock(mutex_); + return last_uri_; + } + + [[nodiscard]] json received_hello() const + { + std::lock_guard lock(mutex_); + return hello_; + } + +private: + void on_message(ix::WebSocket& socket, const ix::WebSocketMessagePtr& message) + { + switch (message->type) { + case ix::WebSocketMessageType::Open: + { + std::lock_guard lock(mutex_); + last_uri_ = message->openInfo.uri; + } + return; + case ix::WebSocketMessageType::Message: + handle_text(socket, message->str); + return; + default: + return; + } + } + + void handle_text(ix::WebSocket& socket, const std::string& text) + { + json envelope; + try { + envelope = json::parse(text); + } catch (const json::exception&) { + return; + } + if (!envelope.is_object()) { + return; + } + const std::string type = envelope.value("type", std::string()); + const std::string id = + envelope.contains("id") && envelope["id"].is_string() ? envelope["id"].get() + : std::string(); + + if (type == "hello") { + const json payload = envelope.value("payload", json::object()); + { + std::lock_guard lock(mutex_); + hello_ = payload; + } + if (handshake_ == Handshake::error_incompatible) { + socket.sendText(encode_error(id, "incompatible-protocol", "Version incompatible (test).")); + socket.close(1008, "incompatible-protocol"); + return; + } + socket.sendText(encode_message("welcome", id, welcome_payload())); + return; + } + + // --- Après la poignée de main --------------------------------------------------- + if (type == "core/ping") { + socket.sendText(encode_message(type, id, json::object())); + return; + } + if (type == "session/status") { + socket.sendText(encode_message(type, id, status_payload())); + return; + } + if (type == "test/echo") { + socket.sendText(encode_message(type, id, envelope.value("payload", json::object()))); + return; + } + if (type == "test/error") { + socket.sendText(encode_error(id, "boom", "erreur simulée")); + return; + } + if (type == "test/slow") { + return; // aucune réponse : provoque le délai dépassé côté client + } + if (type == "test/close") { + socket.close(1001, "fermé par le serveur"); + return; + } + socket.sendText(encode_error(id, "unknown-type", "Type inconnu.")); + } + + [[nodiscard]] json welcome_payload() const + { + const std::string version = + handshake_ == Handshake::welcome ? "0.2.0" : "0.1.0"; + return json{{"protocolVersion", version}, + {"coreVersion", "9.9.9-test"}, + {"instanceId", std::string("0123456789abcdef0123456789abcdef")}, + {"pid", 4242}, + {"linked", true}}; + } + + [[nodiscard]] json status_payload() const + { + return json{{"seed", "0000000000000000"}, + {"tickRateHz", 60}, + {"tick", 7}, + {"stateHash", "1111111111111111"}, + {"running", true}, + {"inputCount", 0}}; + } + + Handshake handshake_; + std::unique_ptr server_; + int port_ = 0; + + mutable std::mutex mutex_; + std::string last_uri_; + json hello_; +}; + +std::unique_ptr make_core(FakeCore::Handshake handshake = FakeCore::Handshake::welcome) +{ + auto core = std::make_unique(handshake); + if (!core->start()) { + return nullptr; + } + return core; +} + +} // namespace + +TEST_CASE("hostclient: connexion nominale, champs de welcome et URL transmise telle quelle") +{ + const auto core = make_core(); + REQUIRE(core); + CoreClient client; + + const ConnectResult result = + client.connect(core->url_with_token("abc123"), std::chrono::seconds(5)); + REQUIRE(result.ok()); + CHECK(client.connected()); + + CHECK(result.value->protocol_version == "0.2.0"); + CHECK(result.value->core_version == "9.9.9-test"); + CHECK(result.value->instance_id == "0123456789abcdef0123456789abcdef"); + CHECK(result.value->pid == 4242); + CHECK(result.value->linked); + + // Le host s'annonce comme `sequa-studio-host` en 0.2.0. + CHECK(core->received_hello().value("protocolVersion", std::string()) == "0.2.0"); + CHECK(core->received_hello().value("client", std::string()) == "sequa-studio-host"); + + // Le jeton de l'URL est transmis tel quel au serveur. + CHECK(core->last_uri() == "/?token=abc123"); + + client.close(); + CHECK_FALSE(client.connected()); +} + +TEST_CASE("hostclient: requete/reponse avec id (core/ping et session/status)") +{ + const auto core = make_core(); + REQUIRE(core); + CoreClient client; + REQUIRE(client.connect(core->url(), std::chrono::seconds(5)).ok()); + + const RequestResult ping = client.request("core/ping", json::object(), std::chrono::seconds(5)); + REQUIRE(ping.ok()); + CHECK(ping.value->is_object()); + CHECK(ping.value->empty()); + + const RequestResult status = + client.request("session/status", json::object(), std::chrono::seconds(5)); + REQUIRE(status.ok()); + CHECK((*status.value)["tick"] == 7); + CHECK((*status.value)["tickRateHz"] == 60); + CHECK((*status.value)["running"] == true); +} + +TEST_CASE("hostclient: 8 requetes concurrentes depuis 8 fils, chacune recoit SA reponse") +{ + const auto core = make_core(); + REQUIRE(core); + CoreClient client; + REQUIRE(client.connect(core->url(), std::chrono::seconds(5)).ok()); + + constexpr int request_count = 8; + std::vector> futures; + futures.reserve(request_count); + for (int i = 0; i < request_count; ++i) { + futures.push_back(std::async(std::launch::async, [&client, i] { + return client.request("test/echo", json{{"n", i}}, std::chrono::seconds(5)); + })); + } + + for (int i = 0; i < request_count; ++i) { + const RequestResult result = futures[static_cast(i)].get(); + REQUIRE(result.ok()); + CHECK((*result.value)["n"] == i); + } +} + +TEST_CASE("hostclient: timeout d'une requete lente") +{ + const auto core = make_core(); + REQUIRE(core); + CoreClient client; + REQUIRE(client.connect(core->url(), std::chrono::seconds(5)).ok()); + + const auto start = std::chrono::steady_clock::now(); + const RequestResult result = + client.request("test/slow", json::object(), std::chrono::milliseconds(200)); + const auto elapsed = std::chrono::steady_clock::now() - start; + + REQUIRE_FALSE(result.ok()); + CHECK(result.error->code == ErrorCode::Timeout); + CHECK(elapsed < std::chrono::seconds(3)); + // La connexion reste utilisable après un délai dépassé. + CHECK(client.request("core/ping", json::object(), std::chrono::seconds(5)).ok()); +} + +TEST_CASE("hostclient: deconnexion pendant l'attente -> erreur rapide") +{ + const auto core = make_core(); + REQUIRE(core); + CoreClient client; + REQUIRE(client.connect(core->url(), std::chrono::seconds(5)).ok()); + + const auto start = std::chrono::steady_clock::now(); + const RequestResult result = + client.request("test/close", json::object(), std::chrono::seconds(10)); + const auto elapsed = std::chrono::steady_clock::now() - start; + + REQUIRE_FALSE(result.ok()); + CHECK(result.error->code == ErrorCode::Closed); + CHECK(result.error->close_code == 1001U); + CHECK(elapsed < std::chrono::seconds(1)); + CHECK_FALSE(client.connected()); +} + +TEST_CASE("hostclient: error du serveur et type inconnu") +{ + const auto core = make_core(); + REQUIRE(core); + CoreClient client; + REQUIRE(client.connect(core->url(), std::chrono::seconds(5)).ok()); + + const RequestResult error = client.request("test/error", json::object(), std::chrono::seconds(5)); + REQUIRE_FALSE(error.ok()); + CHECK(error.error->code == ErrorCode::ServerError); + CHECK(error.error->server_code == "boom"); + CHECK(error.error->message == "erreur simulée"); + + const RequestResult unknown = client.request("nope/thing", json::object(), std::chrono::seconds(5)); + REQUIRE_FALSE(unknown.ok()); + CHECK(unknown.error->code == ErrorCode::ServerError); + CHECK(unknown.error->server_code == "unknown-type"); +} + +TEST_CASE("hostclient: version incompatible annoncee dans welcome") +{ + const auto core = make_core(FakeCore::Handshake::welcome_incompatible); + REQUIRE(core); + CoreClient client; + + const ConnectResult result = client.connect(core->url(), std::chrono::seconds(5)); + REQUIRE_FALSE(result.ok()); + CHECK(result.error->code == ErrorCode::IncompatibleVersion); + CHECK_FALSE(client.connected()); +} + +TEST_CASE("hostclient: error incompatible-protocol du serveur") +{ + const auto core = make_core(FakeCore::Handshake::error_incompatible); + REQUIRE(core); + CoreClient client; + + const ConnectResult result = client.connect(core->url(), std::chrono::seconds(5)); + REQUIRE_FALSE(result.ok()); + CHECK(result.error->code == ErrorCode::IncompatibleVersion); + CHECK(result.error->server_code == "incompatible-protocol"); +} + +TEST_CASE("hostclient: connexion impossible") +{ + CoreClient client; + // IXWebSocket rapporte le refus de connexion en environ 2 s : délai généreux, mais le + // résultat doit rester ConnectFailed (et non Timeout). + const ConnectResult result = + client.connect("ws://127.0.0.1:19399/?token=x", std::chrono::milliseconds(5000)); + REQUIRE_FALSE(result.ok()); + CHECK(result.error->code == ErrorCode::ConnectFailed); + CHECK_FALSE(client.connected()); +} + +TEST_CASE("hostclient: on_disconnect appele une seule fois") +{ + const auto core = make_core(); + REQUIRE(core); + CoreClient client; + REQUIRE(client.connect(core->url(), std::chrono::seconds(5)).ok()); + + std::mutex mutex; + std::condition_variable changed; + int calls = 0; + client.on_disconnect([&](std::uint16_t, const std::string&) { + std::lock_guard lock(mutex); + ++calls; + changed.notify_all(); + }); + + client.close(); + { + std::unique_lock lock(mutex); + changed.wait_for(lock, std::chrono::seconds(2), [&] { return calls > 0; }); + } + CHECK(calls == 1); +} + +TEST_CASE("hostclient: requete sans connexion -> not-connected") +{ + CoreClient client; + const RequestResult result = client.request("core/ping", json::object(), std::chrono::seconds(1)); + REQUIRE_FALSE(result.ok()); + CHECK(result.error->code == ErrorCode::NotConnected); +} diff --git a/studio/host/client/tests/test_wire.cpp b/studio/host/client/tests/test_wire.cpp new file mode 100644 index 0000000..6ce5b1a --- /dev/null +++ b/studio/host/client/tests/test_wire.cpp @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Tests des aides de représentation du protocole (spec §2, §7.6) : hex64, base64 RFC 4648, +// décodage d'empreintes et first_divergence. +#include "sequa/studio/host/wire.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace sequa::studio::host; + +namespace { + +std::vector bytes_of(std::string_view text) +{ + std::vector out; + out.reserve(text.size()); + for (const char c : text) { + out.push_back(static_cast(c)); + } + return out; +} + +} // namespace + +TEST_CASE("hostclient: hex64 aller-retour et refus stricts") +{ + CHECK(to_hex64(0) == "0000000000000000"); + CHECK(to_hex64(0x0123456789abcdefULL) == "0123456789abcdef"); + CHECK(to_hex64(std::numeric_limits::max()) == "ffffffffffffffff"); + + CHECK(from_hex64("0000000000000000") == 0U); + CHECK(from_hex64("0123456789abcdef") == 0x0123456789abcdefULL); + CHECK(from_hex64("ffffffffffffffff") == std::numeric_limits::max()); + + // Refus strict : majuscules, longueur incorrecte, caractère hors alphabet. + CHECK_FALSE(from_hex64("0123456789ABCDEF").has_value()); + CHECK_FALSE(from_hex64("0123456789abcde").has_value()); + CHECK_FALSE(from_hex64("0123456789abcdef0").has_value()); + CHECK_FALSE(from_hex64("0123456789abcdeg").has_value()); + CHECK_FALSE(from_hex64("").has_value()); + + const std::uint64_t values[] = {1U, 255U, 65536U, 0xdeadbeefcafef00dULL}; + for (const std::uint64_t value : values) { + CHECK(from_hex64(to_hex64(value)) == value); + CHECK(to_hex64(value).size() == hex64_length); + } +} + +TEST_CASE("hostclient: base64 RFC 4648 (vecteurs et refus)") +{ + // Vecteurs de test de la RFC 4648 §10. + CHECK(base64_encode("") == ""); + CHECK(base64_encode("f") == "Zg=="); + CHECK(base64_encode("fo") == "Zm8="); + CHECK(base64_encode("foo") == "Zm9v"); + CHECK(base64_encode("foob") == "Zm9vYg=="); + CHECK(base64_encode("fooba") == "Zm9vYmE="); + CHECK(base64_encode("foobar") == "Zm9vYmFy"); + + const auto decoded = base64_decode("Zm9vYmFy"); + REQUIRE(decoded.has_value()); + CHECK(*decoded == bytes_of("foobar")); + + CHECK(base64_decode("") == std::optional>(std::vector{})); + const auto one = base64_decode("Zg=="); + REQUIRE(one.has_value()); + CHECK(*one == bytes_of("f")); + + // Refus stricts. + CHECK_FALSE(base64_decode("Zm9vYmF").has_value()); // longueur non multiple de 4 + CHECK_FALSE(base64_decode("Zm9v!mFy").has_value()); // caractère hors alphabet + CHECK_FALSE(base64_decode("Zg=").has_value()); // remplissage incomplet + CHECK_FALSE(base64_decode("=Zg=").has_value()); // '=' en tête + CHECK_FALSE(base64_decode("Zg==Zg==").has_value()); // '=' au milieu + CHECK_FALSE(base64_decode("Zh==").has_value()); // bits de queue non nuls + + // Aller-retour sur des octets binaires quelconques. + const std::vector raw{0U, 1U, 2U, 250U, 255U}; + const auto round_trip = base64_decode(base64_encode(std::span(raw))); + REQUIRE(round_trip.has_value()); + CHECK(*round_trip == raw); +} + +TEST_CASE("hostclient: decode_hashes et first_divergence") +{ + const std::vector a{1U, 2U, 3U, 4U}; + CHECK(first_divergence(a, a) == std::nullopt); + + const std::vector b{1U, 2U, 99U, 4U}; + CHECK(first_divergence(a, b) == std::size_t{2}); + CHECK(first_divergence(b, a) == std::size_t{2}); + + const std::vector prefix{1U, 2U}; + CHECK(first_divergence(a, prefix) == std::size_t{2}); + CHECK(first_divergence(prefix, a) == std::size_t{2}); + + // decode_hashes : tableau {1, 2} en uint64 petit-boutiste. + const std::vector little_endian{1U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, + 2U, 0U, 0U, 0U, 0U, 0U, 0U, 0U}; + const auto hashes = decode_hashes(base64_encode(std::span(little_endian))); + REQUIRE(hashes.has_value()); + CHECK(*hashes == (std::vector{1U, 2U})); +} + +TEST_CASE("hostclient: decode_hashes refuse les longueurs invalides") +{ + // 5 octets : pas un multiple de 8. + CHECK_FALSE(decode_hashes(base64_encode("trois")).has_value()); + // Base64 invalide. + CHECK_FALSE(decode_hashes("!!").has_value()); +}