feat(studio/host): brique web, service de l'interface et ouverture du navigateur
UiServer (ix::HttpServer) : GET/HEAD, / -> index.html, MIME, en-têtes no-store/nosniff, vérification exclusive du port sous Windows. Chemins : décodage unique, refus de .., ., fichiers cachés, antislash, octet nul, : (lecteur ou flux NTFS), puis confinement vérifié par weakly_canonical. build_ui_url, build_core_ws_url, open_browser (http/https uniquement). 14 cas de test dont 12 tentatives d'évasion. Écrit par un subagent (DeepSeek V4.1 Flash), relu ; refus de : étendu aux flux NTFS.
This commit is contained in:
1 parent
e703c217f7
commit
99b0a0f008
12 files changed
+1125
No files matched your search
@@ -0,0 +1,32 @@
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
#
|
||||
# sequa::host_web : sert studio/ui/dist en HTTP local et ouvre le navigateur.
|
||||
# Dépendance privée : ixwebsocket::ixwebsocket (serveur HTTP). ws2_32 sous Windows
|
||||
# pour la vérification exclusive du port (voir core/src/server/server.cpp).
|
||||
|
||||
add_library(sequa_host_web
|
||||
src/ui_server.cpp
|
||||
src/urls.cpp
|
||||
src/browser.cpp)
|
||||
add_library(sequa::host_web ALIAS sequa_host_web)
|
||||
|
||||
target_include_directories(sequa_host_web
|
||||
PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||
target_compile_features(sequa_host_web PUBLIC cxx_std_20)
|
||||
target_link_libraries(sequa_host_web PRIVATE ixwebsocket::ixwebsocket)
|
||||
if(WIN32)
|
||||
target_link_libraries(sequa_host_web PRIVATE ws2_32) # vérification exclusive du port
|
||||
endif()
|
||||
sequa_target_defaults(sequa_host_web)
|
||||
|
||||
if(SEQUA_BUILD_TESTS)
|
||||
add_executable(sequa_host_web_tests
|
||||
tests/main.cpp
|
||||
tests/test_ui_server.cpp
|
||||
tests/test_urls.cpp
|
||||
tests/test_browser.cpp)
|
||||
target_link_libraries(sequa_host_web_tests PRIVATE
|
||||
sequa::host_web doctest::doctest ixwebsocket::ixwebsocket)
|
||||
sequa_target_defaults(sequa_host_web_tests)
|
||||
doctest_discover_tests(sequa_host_web_tests)
|
||||
endif()
|
||||
@@ -0,0 +1,76 @@
|
||||
# `sequa::host_web` — service HTTP de l'interface et ouverture du navigateur
|
||||
|
||||
Brique `web` du Studio host (`studio/host/web/`). Elle sert l'interface construite
|
||||
(`studio/ui/dist`) en HTTP local et ouvre le navigateur de l'auteur. Références :
|
||||
[ADR 0006](../../../docs/adr/0006-studio-host-et-cycle-de-vie-du-core.md) et
|
||||
[spécification Studio - Core 0.2](../../../docs/specs/protocole-studio-core-0.2.md) §7.
|
||||
|
||||
## Rôle
|
||||
|
||||
- `UiServer` : serveur HTTP statique en lecture seule (GET et HEAD) sur `127.0.0.1:9180`
|
||||
par défaut. `"/"` sert `index.html`, la query est ignorée pour trouver le fichier.
|
||||
- `urls.hpp` : construction de l'URL de l'interface (`?core=<url WebSocket encodée>`) et
|
||||
de l'URL WebSocket du Core (`ws://host:port/?token=…`), encodage pourcentage RFC 3986.
|
||||
- `browser.hpp` : validation stricte des URL (http/https uniquement) et ouverture du
|
||||
navigateur (ShellExecuteW sous Windows, `open` sous macOS, `xdg-open` sous Linux).
|
||||
|
||||
## API publique
|
||||
|
||||
```cpp
|
||||
struct UiServerOptions {
|
||||
std::string host = "127.0.0.1";
|
||||
std::uint16_t port = 9180;
|
||||
std::filesystem::path root; // studio/ui/dist
|
||||
std::function<void(std::string_view)> log; // une ligne par requête
|
||||
};
|
||||
|
||||
class UiServer {
|
||||
public:
|
||||
explicit UiServer(UiServerOptions options);
|
||||
~UiServer(); // stop() propre
|
||||
std::string start(); // "" si OK, sinon la raison
|
||||
void stop();
|
||||
bool running() const noexcept;
|
||||
const UiServerOptions& options() const noexcept;
|
||||
};
|
||||
|
||||
std::string percent_encode(std::string_view value);
|
||||
std::string build_ui_url(std::string_view ui_base_url, std::string_view core_ws_url);
|
||||
std::string build_core_ws_url(std::string_view host, std::uint16_t port, std::string_view token);
|
||||
|
||||
bool is_openable_url(std::string_view url);
|
||||
bool open_browser(std::string_view url);
|
||||
```
|
||||
|
||||
## Sécurité des chemins
|
||||
|
||||
`request->uri` d'IXWebSocket **n'est pas** décodée par la bibliothèque : c'est `UiServer`
|
||||
qui décode le pourcentage une seule fois, puis refuse (404, jamais 500) :
|
||||
|
||||
- octet nul (`%00`), antislash, segments `.` ou `..`, segments vides (`//`), chemin
|
||||
absolu, lettre de lecteur (`C:`), segments commençant par `.` (fichiers cachés),
|
||||
double décodage (`%252e` laisse un triplet après le premier décodage).
|
||||
|
||||
Le fichier est ensuite résolu par `std::filesystem::weakly_canonical` et doit rester
|
||||
**dans la racine canonique** (protection contre les liens symboliques) et être un fichier
|
||||
régulier. Ce contrôle est plus strict que le rappel par défaut d'IXWebSocket, qui se
|
||||
contente de rogner les `..` et ne décode jamais l'URI.
|
||||
|
||||
Sous Windows, `start()` vérifie en plus le port de façon exclusive
|
||||
(`SO_EXCLUSIVEADDRUSE`) avant d'écouter : IXWebSocket active `SO_REUSEADDR`, qui
|
||||
autoriserait sinon deux processus à écouter le même port. La technique est reprise de
|
||||
`core/src/server/server.cpp`.
|
||||
|
||||
En-têtes renvoyés systématiquement : `Content-Type`, `Cache-Control: no-store`,
|
||||
`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` (et `Allow` sur 405).
|
||||
|
||||
## Tests
|
||||
|
||||
`sequa_host_web_tests` (doctest) : types MIME, HEAD, 405, 404, query ignorée, journal,
|
||||
port occupé, racine absente, tests purs des URL, et dix tentatives d'évasion qui doivent
|
||||
toutes renvoyer 404 sans jamais livrer le fichier secret placé hors de la racine.
|
||||
|
||||
## Licences
|
||||
|
||||
- Ce code : MPL 2.0 (voir l'en-tête SPDX de chaque fichier).
|
||||
- IXWebSocket : BSD-3-Clause.
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
//
|
||||
// Ouverture du navigateur du système pour l'interface servie localement (ADR 0006).
|
||||
#pragma once
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace sequa::studio::host {
|
||||
|
||||
// Vrai pour une URL http:// ou https:// bien formée (schéma insensible à la casse,
|
||||
// aucun caractère de contrôle). Sert de garde anti-injection avant open_browser().
|
||||
bool is_openable_url(std::string_view url);
|
||||
|
||||
// Ouvre l'URL dans le navigateur par défaut : ShellExecuteW (Windows), `open` (macOS),
|
||||
// `xdg-open` (Linux, via posix_spawnp, sans shell). Refuse toute URL non http(s).
|
||||
bool open_browser(std::string_view url);
|
||||
|
||||
} // namespace sequa::studio::host
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
//
|
||||
// UiServer : sert l'interface construite (studio/ui/dist) en HTTP local, en lecture
|
||||
// seule (GET et HEAD). Voir docs/specs/protocole-studio-core-0.2.md §7 et ADR 0006.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace sequa::studio::host {
|
||||
|
||||
struct UiServerOptions
|
||||
{
|
||||
// Écoute locale par défaut (ADR 0006, spécification §1).
|
||||
std::string host = "127.0.0.1";
|
||||
std::uint16_t port = 9180;
|
||||
// Dossier racine servi (défaut applicatif : studio/ui/dist).
|
||||
std::filesystem::path root;
|
||||
// Journal facultatif : une ligne par requête (« méthode chemin statut »).
|
||||
std::function<void(std::string_view)> log;
|
||||
};
|
||||
|
||||
// Serveur HTTP statique de l'interface. start() renvoie la chaîne vide en cas de
|
||||
// succès, sinon une raison lisible. L'objet se nettoie dans son destructeur.
|
||||
class UiServer
|
||||
{
|
||||
public:
|
||||
explicit UiServer(UiServerOptions options);
|
||||
~UiServer();
|
||||
|
||||
UiServer(const UiServer&) = delete;
|
||||
UiServer& operator=(const UiServer&) = delete;
|
||||
|
||||
std::string start();
|
||||
void stop();
|
||||
bool running() const noexcept;
|
||||
const UiServerOptions& options() const noexcept;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace sequa::studio::host
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
//
|
||||
// Construction des URL du Studio : page de l'interface portant l'URL WebSocket du Core
|
||||
// (spécification §7.2 étape 3) et URL ws:// du Core avec son jeton (spécification §3).
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace sequa::studio::host {
|
||||
|
||||
// Encodage pourcentage RFC 3986 : tout est encodé sauf les non-réservés A-Z a-z 0-9 - . _ ~.
|
||||
std::string percent_encode(std::string_view value);
|
||||
|
||||
// ui_base_url + "/?core=" + core_ws_url encodée.
|
||||
std::string build_ui_url(std::string_view ui_base_url, std::string_view core_ws_url);
|
||||
|
||||
// "ws://host:port/?token=<token>".
|
||||
std::string build_core_ws_url(std::string_view host, std::uint16_t port, std::string_view token);
|
||||
|
||||
} // namespace sequa::studio::host
|
||||
@@ -0,0 +1,96 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
//
|
||||
// Ouverture du navigateur par défaut. Aucune URL autre que http(s) n'est acceptée,
|
||||
// et aucun interpréteur de commandes n'est utilisé (pas d'injection possible).
|
||||
#include "sequa/studio/host/browser.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#ifdef _WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#else
|
||||
#include <cerrno>
|
||||
#include <spawn.h>
|
||||
|
||||
extern char** environ;
|
||||
#endif
|
||||
|
||||
namespace sequa::studio::host {
|
||||
|
||||
namespace {
|
||||
|
||||
bool starts_with_ci_case(std::string_view value, std::string_view prefix)
|
||||
{
|
||||
if (value.size() < prefix.size()) {
|
||||
return false;
|
||||
}
|
||||
for (std::size_t i = 0; i < prefix.size(); ++i) {
|
||||
if (std::tolower(static_cast<unsigned char>(value[i])) !=
|
||||
std::tolower(static_cast<unsigned char>(prefix[i]))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool is_openable_url(std::string_view url)
|
||||
{
|
||||
if (!starts_with_ci_case(url, "http://") && !starts_with_ci_case(url, "https://")) {
|
||||
return false;
|
||||
}
|
||||
for (const char raw : url) {
|
||||
const unsigned char c = static_cast<unsigned char>(raw);
|
||||
if (c <= 0x20 || c == 0x7F) {
|
||||
return false; // espace ou caractère de contrôle : refusé
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool open_browser(std::string_view url)
|
||||
{
|
||||
if (!is_openable_url(url)) {
|
||||
return false;
|
||||
}
|
||||
const std::string value(url);
|
||||
|
||||
#ifdef _WIN32
|
||||
// UTF-8 -> UTF-16 correctement, y compris pour les accents du chemin.
|
||||
const int wide_length =
|
||||
::MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
|
||||
if (wide_length <= 0) {
|
||||
return false;
|
||||
}
|
||||
std::wstring wide(static_cast<std::size_t>(wide_length), L'\0');
|
||||
if (::MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), wide.data(),
|
||||
wide_length) <= 0) {
|
||||
return false;
|
||||
}
|
||||
const HINSTANCE result = ::ShellExecuteW(nullptr, L"open", wide.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
return reinterpret_cast<std::intptr_t>(result) > 32;
|
||||
#else
|
||||
#if defined(__APPLE__)
|
||||
const char* program = "open";
|
||||
#else
|
||||
const char* program = "xdg-open";
|
||||
#endif
|
||||
pid_t pid = 0;
|
||||
char* argv[] = {const_cast<char*>(program), const_cast<char*>(value.c_str()), nullptr};
|
||||
const int rc = ::posix_spawnp(&pid, program, nullptr, nullptr, argv, environ);
|
||||
return rc == 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace sequa::studio::host
|
||||
@@ -0,0 +1,420 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
//
|
||||
// Serveur HTTP statique de l'interface (studio/ui/dist). Lecture seule (GET, HEAD),
|
||||
// sécurisation stricte des chemins : décodage pourcentage unique puis vérification
|
||||
// que le fichier réel reste dans la racine (protection contre les liens symboliques).
|
||||
#include "sequa/studio/host/ui_server.hpp"
|
||||
|
||||
#include <ixwebsocket/IXHttp.h>
|
||||
#include <ixwebsocket/IXHttpServer.h>
|
||||
#include <ixwebsocket/IXNetSystem.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <ios>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#endif
|
||||
|
||||
namespace sequa::studio::host {
|
||||
|
||||
namespace {
|
||||
|
||||
#ifdef _WIN32
|
||||
// IXWebSocket active SO_REUSEADDR sur sa socket d'écoute. Sous Windows, cette option
|
||||
// permet à un second processus d'écouter sur le MÊME port (répartition imprévisible des
|
||||
// connexions) au lieu d'échouer. On vérifie donc d'abord que le port est libre avec une
|
||||
// réservation exclusive temporaire. Un intervalle de concurrence subsiste entre cette
|
||||
// vérification et l'écoute réelle : acceptable pour un serveur de développement local.
|
||||
// Technique copiée de core/src/server/server.cpp (probe_port_exclusive).
|
||||
std::string probe_port_exclusive(const std::string& host, int port)
|
||||
{
|
||||
const SOCKET probe = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (probe == INVALID_SOCKET) {
|
||||
return "Création de socket impossible (WSA " + std::to_string(WSAGetLastError()) + ").";
|
||||
}
|
||||
BOOL exclusive = TRUE;
|
||||
::setsockopt(probe, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, reinterpret_cast<const char*>(&exclusive),
|
||||
sizeof(exclusive));
|
||||
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_port = ::htons(static_cast<u_short>(port));
|
||||
if (::inet_pton(AF_INET, host.c_str(), &address.sin_addr) != 1) {
|
||||
::closesocket(probe);
|
||||
return "Adresse IPv4 invalide : " + host;
|
||||
}
|
||||
|
||||
const int result = ::bind(probe, reinterpret_cast<const sockaddr*>(&address), sizeof(address));
|
||||
const int error = WSAGetLastError();
|
||||
::closesocket(probe);
|
||||
if (result != 0) {
|
||||
return "Port " + std::to_string(port) + " déjà utilisé sur " + host + " (WSA " + std::to_string(error) + ").";
|
||||
}
|
||||
return {};
|
||||
}
|
||||
#endif
|
||||
|
||||
int hex_value(char c)
|
||||
{
|
||||
if (c >= '0' && c <= '9') {
|
||||
return c - '0';
|
||||
}
|
||||
if (c >= 'a' && c <= 'f') {
|
||||
return c - 'a' + 10;
|
||||
}
|
||||
if (c >= 'A' && c <= 'F') {
|
||||
return c - 'A' + 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Une séquence %XX encore présente dans le résultat signifie un double encodage
|
||||
// (ex. %252e se décode une fois en %2e, qui reste décodable en « . ») : refusé.
|
||||
bool has_percent_triplet(std::string_view value)
|
||||
{
|
||||
for (std::size_t i = 0; i + 2 < value.size(); ++i) {
|
||||
if (value[i] == '%' && hex_value(value[i + 1]) >= 0 && hex_value(value[i + 2]) >= 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Décodage pourcentage UNE seule fois. Faux si une séquence %XX est mal formée.
|
||||
bool percent_decode_once(std::string_view input, std::string& output)
|
||||
{
|
||||
output.clear();
|
||||
output.reserve(input.size());
|
||||
for (std::size_t i = 0; i < input.size(); ++i) {
|
||||
const char c = input[i];
|
||||
if (c != '%') {
|
||||
output.push_back(c);
|
||||
continue;
|
||||
}
|
||||
if (i + 2 >= input.size()) {
|
||||
return false;
|
||||
}
|
||||
const int high = hex_value(input[i + 1]);
|
||||
const int low = hex_value(input[i + 2]);
|
||||
if (high < 0 || low < 0) {
|
||||
return false;
|
||||
}
|
||||
output.push_back(static_cast<char>((high << 4) | low));
|
||||
i += 2;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Valide la cible de requête et renvoie le chemin relatif à la racine, ou nullopt si
|
||||
// refusé (octet nul, antislash, « .. », « . », chemin absolu, lettre de lecteur,
|
||||
// fichier caché, segment vide, double décodage).
|
||||
std::optional<std::filesystem::path> safe_relative_path(std::string_view raw_path)
|
||||
{
|
||||
std::string decoded;
|
||||
if (!percent_decode_once(raw_path, decoded)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (decoded.find('\0') != std::string::npos || decoded.find('\\') != std::string::npos) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (decoded.empty() || decoded.front() != '/') {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (has_percent_triplet(decoded)) {
|
||||
return std::nullopt; // double décodage
|
||||
}
|
||||
|
||||
std::string_view rest(decoded);
|
||||
rest.remove_prefix(1);
|
||||
if (!rest.empty() && rest.front() == '/') {
|
||||
return std::nullopt; // « //... » : chemin réseau/absolu
|
||||
}
|
||||
if (rest.empty()) {
|
||||
return std::filesystem::path("index.html");
|
||||
}
|
||||
|
||||
std::filesystem::path relative;
|
||||
std::size_t start = 0;
|
||||
while (start <= rest.size()) {
|
||||
const std::size_t slash = rest.find('/', start);
|
||||
const std::string_view segment =
|
||||
slash == std::string_view::npos ? rest.substr(start) : rest.substr(start, slash - start);
|
||||
if (segment.empty()) {
|
||||
return std::nullopt; // segment vide : « /a//b » ou « /a/ »
|
||||
}
|
||||
if (segment == "." || segment == "..") {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (segment.front() == '.') {
|
||||
return std::nullopt; // fichier caché
|
||||
}
|
||||
if (segment.find(':') != std::string_view::npos) {
|
||||
return std::nullopt; // lettre de lecteur (C:) ou flux NTFS (« fichier:flux »)
|
||||
}
|
||||
relative /= std::filesystem::path(std::string(segment));
|
||||
if (slash == std::string_view::npos) {
|
||||
break;
|
||||
}
|
||||
start = slash + 1;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
// Vrai si « candidate » est identique à « root » ou situé sous « root » (comparaison
|
||||
// composant par composant, sans piège de préfixe textuel).
|
||||
bool is_within(const std::filesystem::path& root, const std::filesystem::path& candidate)
|
||||
{
|
||||
auto root_it = root.begin();
|
||||
auto candidate_it = candidate.begin();
|
||||
for (; root_it != root.end(); ++root_it, ++candidate_it) {
|
||||
if (candidate_it == candidate.end() || *candidate_it != *root_it) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Résout la cible vers un fichier régulier DANS la racine, ou nullopt.
|
||||
std::optional<std::filesystem::path> resolve_file(const std::filesystem::path& root_canonical,
|
||||
std::string_view raw_path)
|
||||
{
|
||||
const std::optional<std::filesystem::path> relative = safe_relative_path(raw_path);
|
||||
if (!relative) {
|
||||
return std::nullopt;
|
||||
}
|
||||
std::error_code ec;
|
||||
const std::filesystem::path candidate =
|
||||
std::filesystem::weakly_canonical(root_canonical / *relative, ec);
|
||||
if (ec || !is_within(root_canonical, candidate)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (!std::filesystem::is_regular_file(candidate, ec) || ec) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
bool read_file(const std::filesystem::path& path, std::string& content)
|
||||
{
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
file.seekg(0, std::ios::end);
|
||||
const std::streamoff size = file.tellg();
|
||||
if (size < 0) {
|
||||
return false;
|
||||
}
|
||||
file.seekg(0, std::ios::beg);
|
||||
content.resize(static_cast<std::size_t>(size));
|
||||
if (size > 0) {
|
||||
file.read(content.data(), static_cast<std::streamsize>(size));
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string mime_type(const std::filesystem::path& file)
|
||||
{
|
||||
std::string extension = file.extension().string();
|
||||
for (char& c : extension) {
|
||||
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
}
|
||||
if (extension == ".html") {
|
||||
return "text/html; charset=utf-8";
|
||||
}
|
||||
if (extension == ".js" || extension == ".mjs") {
|
||||
return "text/javascript; charset=utf-8";
|
||||
}
|
||||
if (extension == ".css") {
|
||||
return "text/css; charset=utf-8";
|
||||
}
|
||||
if (extension == ".json" || extension == ".map") {
|
||||
return "application/json; charset=utf-8";
|
||||
}
|
||||
if (extension == ".svg") {
|
||||
return "image/svg+xml; charset=utf-8";
|
||||
}
|
||||
if (extension == ".png") {
|
||||
return "image/png";
|
||||
}
|
||||
if (extension == ".jpg" || extension == ".jpeg") {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (extension == ".gif") {
|
||||
return "image/gif";
|
||||
}
|
||||
if (extension == ".webp") {
|
||||
return "image/webp";
|
||||
}
|
||||
if (extension == ".ico") {
|
||||
return "image/x-icon";
|
||||
}
|
||||
if (extension == ".wasm") {
|
||||
return "application/wasm";
|
||||
}
|
||||
if (extension == ".txt") {
|
||||
return "text/plain; charset=utf-8";
|
||||
}
|
||||
if (extension == ".woff") {
|
||||
return "font/woff";
|
||||
}
|
||||
if (extension == ".woff2") {
|
||||
return "font/woff2";
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
ix::HttpResponsePtr make_response(int status,
|
||||
std::string description,
|
||||
std::string body,
|
||||
std::string content_type,
|
||||
std::string allow = {})
|
||||
{
|
||||
ix::WebSocketHttpHeaders headers;
|
||||
headers["Content-Type"] = std::move(content_type);
|
||||
headers["Cache-Control"] = "no-store";
|
||||
headers["X-Content-Type-Options"] = "nosniff";
|
||||
headers["Referrer-Policy"] = "no-referrer";
|
||||
if (!allow.empty()) {
|
||||
headers["Allow"] = std::move(allow);
|
||||
}
|
||||
return std::make_shared<ix::HttpResponse>(
|
||||
status, std::move(description), ix::HttpErrorCode::Ok, headers, std::move(body));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct UiServer::Impl
|
||||
{
|
||||
explicit Impl(UiServerOptions opts) : options(std::move(opts)) {}
|
||||
|
||||
void log(const std::string& line) const
|
||||
{
|
||||
if (options.log) {
|
||||
options.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
ix::HttpResponsePtr handle_request(const ix::HttpRequest& request) const
|
||||
{
|
||||
std::string target = request.uri;
|
||||
if (const std::size_t query = target.find('?'); query != std::string::npos) {
|
||||
target.resize(query); // « /?core=... » : ignorer la requête pour trouver le fichier
|
||||
}
|
||||
|
||||
const bool is_get = request.method == "GET";
|
||||
const bool is_head = request.method == "HEAD";
|
||||
if (!is_get && !is_head) {
|
||||
log(request.method + " " + target + " 405");
|
||||
return make_response(
|
||||
405, "Method Not Allowed", "405 - méthode non autorisée\n", "text/plain; charset=utf-8", "GET, HEAD");
|
||||
}
|
||||
|
||||
const std::optional<std::filesystem::path> file = resolve_file(root_canonical, target);
|
||||
std::string content;
|
||||
if (!file || !read_file(*file, content)) {
|
||||
log(request.method + " " + target + " 404");
|
||||
return make_response(404, "Not Found", "404 - fichier introuvable\n", "text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
log(request.method + " " + target + " 200");
|
||||
std::string body = is_head ? std::string() : std::move(content);
|
||||
return make_response(200, "OK", std::move(body), mime_type(*file));
|
||||
}
|
||||
|
||||
UiServerOptions options;
|
||||
std::filesystem::path root_canonical;
|
||||
std::optional<ix::HttpServer> server;
|
||||
bool net_initialized = false;
|
||||
};
|
||||
|
||||
UiServer::UiServer(UiServerOptions options) : impl_(std::make_unique<Impl>(std::move(options))) {}
|
||||
|
||||
UiServer::~UiServer()
|
||||
{
|
||||
stop();
|
||||
}
|
||||
|
||||
std::string UiServer::start()
|
||||
{
|
||||
if (impl_->server) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
impl_->root_canonical = std::filesystem::canonical(impl_->options.root, ec);
|
||||
if (ec || impl_->root_canonical.empty() || !std::filesystem::is_directory(impl_->root_canonical, ec)) {
|
||||
impl_->root_canonical.clear();
|
||||
return "studio/ui/dist introuvable : lancer npm run build dans studio/ui (racine : " +
|
||||
impl_->options.root.string() + ")";
|
||||
}
|
||||
|
||||
if (!impl_->net_initialized) {
|
||||
if (!ix::initNetSystem()) {
|
||||
return "Initialisation du réseau impossible.";
|
||||
}
|
||||
impl_->net_initialized = true;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
if (std::string error = probe_port_exclusive(impl_->options.host, static_cast<int>(impl_->options.port));
|
||||
!error.empty()) {
|
||||
return error;
|
||||
}
|
||||
#endif
|
||||
|
||||
auto& server = impl_->server.emplace(static_cast<int>(impl_->options.port), impl_->options.host);
|
||||
const Impl* self = impl_.get();
|
||||
server.setOnConnectionCallback(
|
||||
[self](ix::HttpRequestPtr request, std::shared_ptr<ix::ConnectionState>) {
|
||||
return self->handle_request(*request);
|
||||
});
|
||||
|
||||
const auto [ok, error] = server.listen();
|
||||
if (!ok) {
|
||||
impl_->server.reset();
|
||||
return error;
|
||||
}
|
||||
server.start();
|
||||
impl_->log("Interface en écoute sur http://" + impl_->options.host + ":" +
|
||||
std::to_string(impl_->options.port) + " (racine " + impl_->root_canonical.string() + ")");
|
||||
return {};
|
||||
}
|
||||
|
||||
void UiServer::stop()
|
||||
{
|
||||
if (impl_->server) {
|
||||
impl_->server->stop();
|
||||
impl_->server.reset();
|
||||
impl_->log("Interface arrêtée");
|
||||
}
|
||||
if (impl_->net_initialized) {
|
||||
ix::uninitNetSystem();
|
||||
impl_->net_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool UiServer::running() const noexcept
|
||||
{
|
||||
return impl_->server.has_value();
|
||||
}
|
||||
|
||||
const UiServerOptions& UiServer::options() const noexcept
|
||||
{
|
||||
return impl_->options;
|
||||
}
|
||||
|
||||
} // namespace sequa::studio::host
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
//
|
||||
// Encodage pourcentage RFC 3986 et construction des URL du Studio.
|
||||
#include "sequa/studio/host/urls.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace sequa::studio::host {
|
||||
|
||||
std::string percent_encode(std::string_view value)
|
||||
{
|
||||
static constexpr char hex[] = "0123456789ABCDEF";
|
||||
std::string output;
|
||||
output.reserve(value.size());
|
||||
for (const char raw : value) {
|
||||
const unsigned char c = static_cast<unsigned char>(raw);
|
||||
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' ||
|
||||
c == '.' || c == '_' || c == '~') {
|
||||
output.push_back(static_cast<char>(c));
|
||||
} else {
|
||||
output.push_back('%');
|
||||
output.push_back(hex[c >> 4]);
|
||||
output.push_back(hex[c & 0x0F]);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
std::string build_ui_url(std::string_view ui_base_url, std::string_view core_ws_url)
|
||||
{
|
||||
std::string url(ui_base_url);
|
||||
url += "/?core=";
|
||||
url += percent_encode(core_ws_url);
|
||||
return url;
|
||||
}
|
||||
|
||||
std::string build_core_ws_url(std::string_view host, std::uint16_t port, std::string_view token)
|
||||
{
|
||||
std::string url = "ws://";
|
||||
url += host;
|
||||
url += ':';
|
||||
url += std::to_string(port);
|
||||
url += "/?token=";
|
||||
url += token;
|
||||
return url;
|
||||
}
|
||||
|
||||
} // namespace sequa::studio::host
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
|
||||
#include <doctest/doctest.h>
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
//
|
||||
// Tests de la validation des URL avant ouverture du navigateur. Aucun navigateur n'est
|
||||
// réellement ouvert.
|
||||
#include "sequa/studio/host/browser.hpp"
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
using namespace sequa::studio::host;
|
||||
|
||||
TEST_CASE("Web : is_openable_url n'accepte que http et https")
|
||||
{
|
||||
CHECK(is_openable_url("http://127.0.0.1:9180/?core=x"));
|
||||
CHECK(is_openable_url("https://example.com/chemin?x=1#f"));
|
||||
CHECK(is_openable_url("HTTP://EXAMPLE.COM/"));
|
||||
CHECK(is_openable_url("HtTpS://example.com/"));
|
||||
|
||||
CHECK_FALSE(is_openable_url(""));
|
||||
CHECK_FALSE(is_openable_url("file:///C:/Windows/win.ini"));
|
||||
CHECK_FALSE(is_openable_url("javascript:alert(1)"));
|
||||
CHECK_FALSE(is_openable_url("ftp://example.com/"));
|
||||
CHECK_FALSE(is_openable_url("http:/pas-de-double-slash"));
|
||||
CHECK_FALSE(is_openable_url("http://x\ncommande"));
|
||||
CHECK_FALSE(is_openable_url("http://x\ttab"));
|
||||
CHECK_FALSE(is_openable_url("http://\x01"));
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
//
|
||||
// Tests d'intégration : un vrai client HTTP (ix::HttpClient) contre UiServer, en boucle
|
||||
// locale, avec des tentatives d'évasion de la racine qui doivent toutes échouer.
|
||||
#include "sequa/studio/host/ui_server.hpp"
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
#include <ixwebsocket/IXHttp.h>
|
||||
#include <ixwebsocket/IXHttpClient.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
using namespace sequa::studio::host;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::string_view secret_content = "SECRET-NE-DOIT-JAMAIS-SORTIR";
|
||||
|
||||
// Dossier temporaire imposé par la tâche.
|
||||
fs::path opencode_temp_dir()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
const fs::path fixed("C:/Users/a-mma/AppData/Local/Temp/opencode");
|
||||
if (fs::is_directory(fixed)) {
|
||||
return fixed;
|
||||
}
|
||||
#endif
|
||||
return fs::temp_directory_path();
|
||||
}
|
||||
|
||||
fs::path make_unique_dir()
|
||||
{
|
||||
static std::atomic<unsigned> counter{0};
|
||||
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
|
||||
for (unsigned attempt = 0; attempt < 100; ++attempt) {
|
||||
const fs::path dir = opencode_temp_dir() /
|
||||
("sequa-host-web-" + std::to_string(stamp) + "-" +
|
||||
std::to_string(counter.fetch_add(1)));
|
||||
std::error_code ec;
|
||||
if (fs::create_directories(dir, ec)) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void write_file(const fs::path& path, std::string_view content)
|
||||
{
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
out.write(content.data(), static_cast<std::streamsize>(content.size()));
|
||||
}
|
||||
|
||||
// Racine temporaire + serveur lancé sur un port libre de 19400-19499.
|
||||
class TestEnv
|
||||
{
|
||||
public:
|
||||
TestEnv() { setup(); }
|
||||
~TestEnv()
|
||||
{
|
||||
server_.reset(); // arrête le serveur avant de supprimer les fichiers
|
||||
std::error_code ec;
|
||||
fs::remove_all(base_, ec);
|
||||
}
|
||||
|
||||
TestEnv(const TestEnv&) = delete;
|
||||
TestEnv& operator=(const TestEnv&) = delete;
|
||||
|
||||
bool started() const { return static_cast<bool>(server_); }
|
||||
std::uint16_t port() const { return port_; }
|
||||
const fs::path& root() const { return root_; }
|
||||
const fs::path& base() const { return base_; }
|
||||
|
||||
std::vector<std::string> logs() const
|
||||
{
|
||||
const std::lock_guard<std::mutex> lock(mutex_);
|
||||
return logs_;
|
||||
}
|
||||
|
||||
private:
|
||||
void setup()
|
||||
{
|
||||
base_ = make_unique_dir();
|
||||
if (base_.empty()) {
|
||||
return;
|
||||
}
|
||||
root_ = base_ / "root";
|
||||
fs::create_directories(root_ / "assets");
|
||||
write_file(root_ / "index.html", "<!doctype html><title>SEQUA</title>\n");
|
||||
write_file(root_ / "assets" / "app.js", "console.log('ok');\n");
|
||||
write_file(root_ / "assets" / "style.css", "body { color: red; }\n");
|
||||
write_file(root_ / ".hidden", "cache\n");
|
||||
write_file(base_ / "secret.txt", secret_content); // HORS de la racine
|
||||
|
||||
UiServerOptions options;
|
||||
options.host = "127.0.0.1";
|
||||
options.root = root_;
|
||||
options.log = [this](std::string_view line) {
|
||||
const std::lock_guard<std::mutex> lock(mutex_);
|
||||
logs_.emplace_back(line);
|
||||
};
|
||||
for (int candidate = 19400; candidate <= 19499; ++candidate) {
|
||||
options.port = static_cast<std::uint16_t>(candidate);
|
||||
server_ = std::make_unique<UiServer>(options);
|
||||
if (server_->start().empty()) {
|
||||
port_ = static_cast<std::uint16_t>(candidate);
|
||||
return;
|
||||
}
|
||||
server_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
fs::path base_;
|
||||
fs::path root_;
|
||||
std::uint16_t port_ = 0;
|
||||
mutable std::mutex mutex_;
|
||||
std::vector<std::string> logs_;
|
||||
std::unique_ptr<UiServer> server_; // détruit en premier : le journal reste valide
|
||||
};
|
||||
|
||||
ix::HttpResponsePtr http_request(std::uint16_t port, const std::string& verb, const std::string& path)
|
||||
{
|
||||
ix::HttpClient client;
|
||||
ix::HttpRequestArgsPtr args = client.createRequest();
|
||||
args->followRedirects = false;
|
||||
const std::string url = "http://127.0.0.1:" + std::to_string(port) + path;
|
||||
if (verb == "GET") {
|
||||
return client.get(url, args);
|
||||
}
|
||||
if (verb == "HEAD") {
|
||||
return client.head(url, args);
|
||||
}
|
||||
return client.post(url, std::string("x"), args);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Web : GET / sert index.html avec les bons en-tetes")
|
||||
{
|
||||
TestEnv env;
|
||||
REQUIRE(env.started());
|
||||
|
||||
const ix::HttpResponsePtr response = http_request(env.port(), "GET", "/");
|
||||
REQUIRE(response);
|
||||
CHECK(response->statusCode == 200);
|
||||
CHECK(response->body.find("SEQUA") != std::string::npos);
|
||||
CHECK(response->headers["Content-Type"] == "text/html; charset=utf-8");
|
||||
CHECK(response->headers["Cache-Control"] == "no-store");
|
||||
CHECK(response->headers["X-Content-Type-Options"] == "nosniff");
|
||||
CHECK(response->headers["Referrer-Policy"] == "no-referrer");
|
||||
}
|
||||
|
||||
TEST_CASE("Web : la query est ignoree pour trouver le fichier")
|
||||
{
|
||||
TestEnv env;
|
||||
REQUIRE(env.started());
|
||||
|
||||
const ix::HttpResponsePtr response = http_request(env.port(), "GET", "/?core=ws%3A%2F%2Fx");
|
||||
REQUIRE(response);
|
||||
CHECK(response->statusCode == 200);
|
||||
CHECK(response->body.find("SEQUA") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("Web : types MIME des assets")
|
||||
{
|
||||
TestEnv env;
|
||||
REQUIRE(env.started());
|
||||
|
||||
const ix::HttpResponsePtr js = http_request(env.port(), "GET", "/assets/app.js");
|
||||
REQUIRE(js);
|
||||
CHECK(js->statusCode == 200);
|
||||
CHECK(js->headers["Content-Type"] == "text/javascript; charset=utf-8");
|
||||
|
||||
const ix::HttpResponsePtr css = http_request(env.port(), "GET", "/assets/style.css");
|
||||
REQUIRE(css);
|
||||
CHECK(css->statusCode == 200);
|
||||
CHECK(css->headers["Content-Type"] == "text/css; charset=utf-8");
|
||||
}
|
||||
|
||||
TEST_CASE("Web : HEAD renvoie les en-tetes sans corps")
|
||||
{
|
||||
TestEnv env;
|
||||
REQUIRE(env.started());
|
||||
|
||||
const ix::HttpResponsePtr response = http_request(env.port(), "HEAD", "/");
|
||||
REQUIRE(response);
|
||||
CHECK(response->statusCode == 200);
|
||||
CHECK(response->headers["Content-Type"] == "text/html; charset=utf-8");
|
||||
CHECK(response->headers["Content-Length"] == "0");
|
||||
}
|
||||
|
||||
TEST_CASE("Web : POST -> 405 avec Allow")
|
||||
{
|
||||
TestEnv env;
|
||||
REQUIRE(env.started());
|
||||
|
||||
const ix::HttpResponsePtr response = http_request(env.port(), "POST", "/");
|
||||
REQUIRE(response);
|
||||
CHECK(response->statusCode == 405);
|
||||
CHECK(response->headers["Allow"] == "GET, HEAD");
|
||||
}
|
||||
|
||||
TEST_CASE("Web : fichier absent -> 404")
|
||||
{
|
||||
TestEnv env;
|
||||
REQUIRE(env.started());
|
||||
|
||||
const ix::HttpResponsePtr response = http_request(env.port(), "GET", "/absent.js");
|
||||
REQUIRE(response);
|
||||
CHECK(response->statusCode == 404);
|
||||
}
|
||||
|
||||
TEST_CASE("Web : tentatives d'evasion refusees sans jamais livrer le secret")
|
||||
{
|
||||
TestEnv env;
|
||||
REQUIRE(env.started());
|
||||
|
||||
const std::vector<std::string> evasions = {
|
||||
"/../secret.txt",
|
||||
"/%2e%2e/secret.txt",
|
||||
"/%2E%2E%2Fsecret.txt",
|
||||
"/..%5csecret.txt",
|
||||
"/assets/../../secret.txt",
|
||||
"/%252e%252e/secret.txt",
|
||||
"/C:/Windows/win.ini",
|
||||
"//etc/passwd",
|
||||
"/.hidden",
|
||||
"/%00",
|
||||
"/index.html:secret",
|
||||
"/index.html::$DATA",
|
||||
};
|
||||
for (const std::string& path : evasions) {
|
||||
CAPTURE(path);
|
||||
const ix::HttpResponsePtr response = http_request(env.port(), "GET", path);
|
||||
REQUIRE(response);
|
||||
CHECK(response->statusCode == 404);
|
||||
CHECK(response->body.find("SECRET") == std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Web : journal une ligne par requete")
|
||||
{
|
||||
TestEnv env;
|
||||
REQUIRE(env.started());
|
||||
|
||||
(void)http_request(env.port(), "GET", "/");
|
||||
(void)http_request(env.port(), "GET", "/absent.js");
|
||||
(void)http_request(env.port(), "POST", "/");
|
||||
|
||||
const std::vector<std::string> logs = env.logs();
|
||||
auto contains = [&logs](const std::string& needle) {
|
||||
for (const std::string& line : logs) {
|
||||
if (line.find(needle) != std::string::npos) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
CHECK(contains("GET / 200"));
|
||||
CHECK(contains("GET /absent.js 404"));
|
||||
CHECK(contains("POST / 405"));
|
||||
}
|
||||
|
||||
TEST_CASE("Web : port deja occupe -> start() echoue")
|
||||
{
|
||||
TestEnv env;
|
||||
REQUIRE(env.started());
|
||||
|
||||
UiServerOptions options;
|
||||
options.host = "127.0.0.1";
|
||||
options.port = env.port();
|
||||
options.root = env.root();
|
||||
UiServer second(options);
|
||||
CHECK_FALSE(second.start().empty());
|
||||
CHECK_FALSE(second.running());
|
||||
}
|
||||
|
||||
TEST_CASE("Web : racine absente -> erreur claire")
|
||||
{
|
||||
const fs::path missing =
|
||||
opencode_temp_dir() / "sequa-host-web-inexistant-doit-echouer";
|
||||
|
||||
UiServerOptions options;
|
||||
options.host = "127.0.0.1";
|
||||
options.port = 19499;
|
||||
options.root = missing;
|
||||
UiServer server(options);
|
||||
|
||||
const std::string error = server.start();
|
||||
CHECK(error.find("studio/ui/dist introuvable") != std::string::npos);
|
||||
CHECK_FALSE(server.running());
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
//
|
||||
// Tests purs des URL du Studio et de l'encodage pourcentage.
|
||||
#include "sequa/studio/host/urls.hpp"
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace sequa::studio::host;
|
||||
|
||||
TEST_CASE("Web : build_core_ws_url")
|
||||
{
|
||||
CHECK(build_core_ws_url("127.0.0.1", 9100, "ab12cd") == "ws://127.0.0.1:9100/?token=ab12cd");
|
||||
CHECK(build_core_ws_url("localhost", 19400, "") == "ws://localhost:19400/?token=");
|
||||
}
|
||||
|
||||
TEST_CASE("Web : build_ui_url encode l'URL du Core")
|
||||
{
|
||||
const std::string url = build_ui_url("http://127.0.0.1:9180", "ws://127.0.0.1:9100/?token=ab12");
|
||||
CHECK(url == "http://127.0.0.1:9180/?core=ws%3A%2F%2F127.0.0.1%3A9100%2F%3Ftoken%3Dab12");
|
||||
}
|
||||
|
||||
TEST_CASE("Web : percent_encode respecte les non-reserves RFC 3986")
|
||||
{
|
||||
CHECK(percent_encode("AZaz09-._~") == "AZaz09-._~");
|
||||
CHECK(percent_encode("a b/c?d=e&f") == "a%20b%2Fc%3Fd%3De%26f");
|
||||
CHECK(percent_encode("") == "");
|
||||
// Octets UTF-8 de « é » (C3 A9).
|
||||
CHECK(percent_encode("\xC3\xA9") == "%C3%A9");
|
||||
// Les octets bruts ne doivent pas passer.
|
||||
CHECK(percent_encode(std::string("\x00\x7F\x80", 3)) == "%00%7F%80");
|
||||
}
|
||||
Reference in new issue
Block a user