feat(studio/host): brique client, protocole 0.2 côté host

CoreClient (IXWebSocket) : connexion + hello sequa-studio-host, requêtes corrélées par id avec délais, requêtes concurrentes, erreur immédiate à la déconnexion, rappel on_disconnect. wire : hex64, base64 RFC 4648 strict, décodage des empreintes, first_divergence. 15 cas (Debug, ASan) contre un faux serveur conforme à la spec. Écrit par un subagent (DeepSeek V4.1 Flash), relu.
This commit is contained in:
Ange-Marie MAURIN - A2M Systèmes committed 2026-11-06 22:43:30 +01:00
1 parent 99b0a0f008
commit ea9f5709fa
9 files changed
+1579

No files matched your search

+34
View File
@@ -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()
+69
View File
@@ -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<T>`) 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<std::uint64_t> from_hex64(std::string_view)` :
entiers 64 bits en 16 caractères hexadécimaux minuscules ; analyse stricte.
- `std::string base64_encode(std::span<const std::uint8_t>)` / `(std::string_view)` et
`std::optional<std::vector<std::uint8_t>> base64_decode(std::string_view)` : base64 standard
RFC 4648, décodage strict.
- `std::optional<std::vector<std::uint64_t>> decode_hashes(std::string_view)` : base64 -> tableau
d'empreintes `uint64` petit-boutiste.
- `std::optional<std::size_t> 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<void(std::uint16_t, const std::string&)> 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`.
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <chrono>
#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <nlohmann/json.hpp>
// 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<T>) 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 <typename T>
struct Result {
std::optional<T> value;
std::optional<Error> error;
[[nodiscard]] bool ok() const noexcept { return value.has_value(); }
explicit operator bool() const noexcept { return ok(); }
};
using ConnectResult = Result<Welcome>;
using RequestResult = Result<nlohmann::json>;
// 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<void(std::uint16_t code, const std::string& reason)>;
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> impl_;
};
} // namespace sequa::studio::host
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <cstddef>
#include <cstdint>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <vector>
// 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<std::uint64_t> 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<const std::uint8_t> bytes);
[[nodiscard]] std::string base64_encode(std::string_view text);
[[nodiscard]] std::optional<std::vector<std::uint8_t>> 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<std::vector<std::uint64_t>> 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<std::size_t> first_divergence(
std::span<const std::uint64_t> expected, std::span<const std::uint64_t> actual) noexcept;
} // namespace sequa::studio::host
+573
View File
@@ -0,0 +1,573 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/client.hpp"
#include <ixwebsocket/IXNetSystem.h>
#include <ixwebsocket/IXWebSocket.h>
#include <ixwebsocket/IXWebSocketCloseConstants.h>
#include <condition_variable>
#include <map>
#include <mutex>
#include <utility>
#include <vector>
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 <typename T>
Result<T> failure(ErrorCode code, std::string message, std::string server_code = {},
std::uint16_t close_code = 0)
{
Result<T> 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 <typename T>
Result<T> success(T value)
{
Result<T> 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<ix::WebSocket> 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<std::string, std::shared_ptr<Inbox>> pending;
std::shared_ptr<Inbox> 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<std::string>();
}
// Routage : par id (requête) ou vers la poignée de main (message sans id).
std::shared_ptr<Inbox> 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>() : 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<std::shared_ptr<Inbox>> 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>& 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<ix::WebSocket> 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<Impl>()) {}
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<Welcome>(ErrorCode::ConnectFailed,
"Initialisation du sous-système réseau impossible.");
}
auto handshake = std::make_shared<Impl::Inbox>();
{
std::lock_guard lock(self.mutex);
self.handshake = handshake;
}
auto socket = std::make_unique<ix::WebSocket>();
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<Welcome>(ErrorCode::ConnectFailed,
reason.empty() ? std::string("Connexion impossible.") : reason);
}
return failure<Welcome>(
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<Welcome>(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<Welcome>(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<Welcome>(ErrorCode::ConnectFailed,
reason.empty() ? std::string("Connexion impossible.") : reason);
}
return failure<Welcome>(
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<Welcome>(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<Welcome>(ErrorCode::IncompatibleVersion,
message.empty() ? std::string("Version incompatible.") : message,
code);
}
return failure<Welcome>(ErrorCode::ServerError, message, code);
}
if (handshake->response_type != "welcome") {
self.shutdown_socket();
return failure<Welcome>(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<std::string>();
welcome.core_version = payload.at("coreVersion").get<std::string>();
welcome.instance_id = payload.at("instanceId").get<std::string>();
welcome.pid = payload.at("pid").get<std::int64_t>();
welcome.linked = payload.at("linked").get<bool>();
} catch (const nlohmann::json::exception&) {
self.shutdown_socket();
return failure<Welcome>(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<Welcome>(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<Impl::Inbox>();
std::string id;
{
std::lock_guard lock(self.mutex);
if (!self.open || self.closed || !self.socket) {
return failure<nlohmann::json>(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<nlohmann::json>(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<nlohmann::json>(
ErrorCode::Closed,
inbox->close_reason.empty() ? std::string("Connexion fermée.") : inbox->close_reason,
std::string{}, inbox->close_code);
}
if (inbox->malformed) {
return failure<nlohmann::json>(ErrorCode::MalformedResponse, "Réponse illisible du Core.");
}
if (inbox->is_error) {
return failure<nlohmann::json>(ErrorCode::ServerError, inbox->error_message,
inbox->error_code);
}
if (inbox->response_type != type) {
return failure<nlohmann::json>(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
+183
View File
@@ -0,0 +1,183 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/studio/host/wire.hpp"
#include <algorithm>
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<std::uint64_t> 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<std::uint64_t>(c - '0');
} else if (c >= 'a' && c <= 'f') {
nibble = static_cast<std::uint64_t>(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<const std::uint8_t> 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<std::uint32_t>(bytes[i]) << 16U) |
(static_cast<std::uint32_t>(bytes[i + 1U]) << 8U) |
static_cast<std::uint32_t>(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<std::uint32_t>(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<std::uint32_t>(bytes[i]) << 16U) |
(static_cast<std::uint32_t>(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<const std::uint8_t>(
reinterpret_cast<const std::uint8_t*>(text.data()), text.size()));
}
std::optional<std::vector<std::uint8_t>> base64_decode(std::string_view text)
{
if (text.size() % 4U != 0U) {
return std::nullopt;
}
std::vector<std::uint8_t> 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<std::uint32_t>(value);
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push_back(static_cast<std::uint8_t>((accumulator >> static_cast<unsigned>(bits)) & 0xFFU));
// Ne conserver que les bits non encore consommés.
accumulator = bits == 0 ? 0U : accumulator & ((1U << static_cast<unsigned>(bits)) - 1U);
}
}
// Bits de queue : doivent être nuls (RFC 4648 strict).
if (bits > 0 && (accumulator & ((1U << static_cast<unsigned>(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<std::vector<std::uint64_t>> decode_hashes(std::string_view base64)
{
std::optional<std::vector<std::uint8_t>> bytes = base64_decode(base64);
if (!bytes.has_value() || bytes->size() % 8U != 0U) {
return std::nullopt;
}
std::vector<std::uint64_t> 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<std::uint64_t>((*bytes)[i + k]) << (8U * k);
}
hashes.push_back(value);
}
return hashes;
}
std::optional<std::size_t> first_divergence(std::span<const std::uint64_t> expected,
std::span<const std::uint64_t> 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
+3
View File
@@ -0,0 +1,3 @@
// SPDX-License-Identifier: MPL-2.0
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
+430
View File
@@ -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 <doctest/doctest.h>
#include <ixwebsocket/IXNetSystem.h>
#include <ixwebsocket/IXWebSocketServer.h>
#include <nlohmann/json.hpp>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <future>
#include <memory>
#include <mutex>
#include <string>
#include <utility>
#include <vector>
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<ix::WebSocketServer>(port, "127.0.0.1");
server->disablePerMessageDeflate();
server->setOnClientMessageCallback(
[this](std::shared_ptr<ix::ConnectionState>, 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>()
: 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<ix::WebSocketServer> server_;
int port_ = 0;
mutable std::mutex mutex_;
std::string last_uri_;
json hello_;
};
std::unique_ptr<FakeCore> make_core(FakeCore::Handshake handshake = FakeCore::Handshake::welcome)
{
auto core = std::make_unique<FakeCore>(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<std::future<RequestResult>> 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<std::size_t>(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);
}
+120
View File
@@ -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 <doctest/doctest.h>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <vector>
using namespace sequa::studio::host;
namespace {
std::vector<std::uint8_t> bytes_of(std::string_view text)
{
std::vector<std::uint8_t> out;
out.reserve(text.size());
for (const char c : text) {
out.push_back(static_cast<std::uint8_t>(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<std::uint64_t>::max()) == "ffffffffffffffff");
CHECK(from_hex64("0000000000000000") == 0U);
CHECK(from_hex64("0123456789abcdef") == 0x0123456789abcdefULL);
CHECK(from_hex64("ffffffffffffffff") == std::numeric_limits<std::uint64_t>::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<std::uint8_t>>(std::vector<std::uint8_t>{}));
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<std::uint8_t> raw{0U, 1U, 2U, 250U, 255U};
const auto round_trip = base64_decode(base64_encode(std::span<const std::uint8_t>(raw)));
REQUIRE(round_trip.has_value());
CHECK(*round_trip == raw);
}
TEST_CASE("hostclient: decode_hashes et first_divergence")
{
const std::vector<std::uint64_t> a{1U, 2U, 3U, 4U};
CHECK(first_divergence(a, a) == std::nullopt);
const std::vector<std::uint64_t> 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<std::uint64_t> 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<std::uint8_t> 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<const std::uint8_t>(little_endian)));
REQUIRE(hashes.has_value());
CHECK(*hashes == (std::vector<std::uint64_t>{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());
}