diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 7a3d5a3..a8bf2ec 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -4,10 +4,23 @@ # 2026-09-06-obs-camera-plugin-design.md in the streamer-tools repo) -- # this must build and test headlessly on every platform. -add_library(stplugin_core STATIC +set(STPLUGIN_CORE_SOURCES src/core.cpp + src/json.cpp + src/http_common.cpp + src/api_client.cpp ) +# HTTP backend, one per platform. See core/include/stplugin/http.h for why +# this is split rather than using libcurl everywhere. +if(WIN32) + list(APPEND STPLUGIN_CORE_SOURCES src/http_winhttp.cpp) +else() + list(APPEND STPLUGIN_CORE_SOURCES src/http_curl.cpp) +endif() + +add_library(stplugin_core STATIC ${STPLUGIN_CORE_SOURCES}) + target_include_directories(stplugin_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include @@ -18,6 +31,16 @@ target_link_libraries(stplugin_core LiveKit::livekit ) +if(WIN32) + target_link_libraries(stplugin_core PRIVATE winhttp) +else() + find_package(CURL REQUIRED) + target_link_libraries(stplugin_core PRIVATE CURL::libcurl) +endif() + +find_package(Threads REQUIRED) +target_link_libraries(stplugin_core PUBLIC Threads::Threads) + set_target_properties(stplugin_core PROPERTIES POSITION_INDEPENDENT_CODE ON ) diff --git a/core/include/stplugin/api_client.h b/core/include/stplugin/api_client.h new file mode 100644 index 0000000..bf00faf --- /dev/null +++ b/core/include/stplugin/api_client.h @@ -0,0 +1,118 @@ +/* +streamer-tools OBS Camera Plugin - streamer-tools API client +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#pragma once + +// Client for the two read-key-scoped endpoints in +// apps/server/src/obs/plugin.routes.ts (streamer-tools repo): +// +// GET /api/obs/:slug/slots?key= +// 200 { slots: [ { identity, displayName, live } ] } +// 404 { error: 'not found' } wrong key OR unknown room +// 503 { error: 'livekit not configured' } +// +// POST /api/obs/:slug/token?key= +// 200 { lkToken, wsUrl, identity } +// 404 / 503 as above +// +// The server deliberately answers a wrong key and an unknown slug identically +// (404), so this client must not claim to know which it was. + +#include +#include +#include + +#include "stplugin/core.h" +#include "stplugin/http.h" + +namespace stplugin { + +enum class ApiStatus { + Ok, + /// server URL / slug / key were not all filled in + InvalidConfig, + /// request never completed (DNS, TLS, timeout, ...) + NetworkError, + /// HTTP 404: unknown room slug or wrong read key -- indistinguishable + NotFound, + /// HTTP 503: the server has no LiveKit credentials configured + Unavailable, + /// any other non-2xx status + HttpError, + /// 2xx but the body was not the JSON shape this client expects + MalformedResponse, +}; + +/// A short, operator-facing description. Never includes the read key. +const char *describeApiStatus(ApiStatus status); + +struct SlotInfo { + /// LiveKit participant identity -- this is what the session wrapper + /// subscribes to, and what gets persisted in the OBS source settings. + std::string identity; + /// Human label for the dropdown; the server falls back to identity. + std::string display_name; + /// Currently publishing camera video. + bool live = false; +}; + +struct SlotsResult { + ApiStatus status = ApiStatus::InvalidConfig; + /// Detail for logs/UI. Never contains the read key. + std::string message; + std::vector slots; + + bool ok() const { return status == ApiStatus::Ok; } +}; + +struct TokenResult { + ApiStatus status = ApiStatus::InvalidConfig; + std::string message; + /// LiveKit JWT for a hidden, subscribe-only participant. + std::string lk_token; + /// LiveKit websocket URL to connect to. + std::string ws_url; + /// The obs:: identity the server minted for us. + std::string identity; + + bool ok() const { return status == ApiStatus::Ok; } +}; + +class ApiClient { +public: + /// Takes ownership of the HTTP client, so tests can inject a fake. + explicit ApiClient(std::shared_ptr http); + + SlotsResult fetchSlots(const ConnectionConfig &config) const; + TokenResult requestToken(const ConnectionConfig &config) const; + + /// Accepts what an operator would actually paste: a bare hostname, a URL + /// with a trailing slash, extra whitespace. Returns an empty string if + /// nothing usable is left. Defaults to https:// when no scheme is given, + /// because the read key must never be sent in the clear by accident. + static std::string normalizeServerUrl(const std::string &raw); + + /// Exposed for tests and for logging: the exact URL a call will hit, + /// with the read key replaced by "***". + static std::string redactedUrl(const std::string &url); + +private: + std::shared_ptr http_; +}; + +} // namespace stplugin diff --git a/core/include/stplugin/http.h b/core/include/stplugin/http.h new file mode 100644 index 0000000..d578a69 --- /dev/null +++ b/core/include/stplugin/http.h @@ -0,0 +1,78 @@ +/* +streamer-tools OBS Camera Plugin - HTTP client interface +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#pragma once + +// A two-method HTTP interface, injectable so ApiClient can be unit-tested +// without a network (mirroring the streamer-tools repo's injectable-deps +// convention, per the design doc's Testing section). +// +// Backends, chosen per platform so no third-party HTTP dependency has to be +// built on any of the three CI runners: +// - Linux/macOS: libcurl. Already present on both (client-sdk-cpp's own +// liblivekit links libcurl on Linux, and macOS ships libcurl in the SDK). +// - Windows: WinHTTP, which ships with the OS and handles TLS through +// SChannel -- avoiding an OpenSSL or curl build on the Windows runner. + +#include +#include + +namespace stplugin { + +struct HttpResponse { + /// HTTP status code, or 0 when the request never completed (DNS failure, + /// TLS failure, timeout, ...). Callers must check `network_error` first. + long status = 0; + + /// Response body. May be empty, may be arbitrary bytes: never assume it + /// parses as JSON. + std::string body; + + /// Empty on success. Non-empty means the request did not complete and + /// `status`/`body` are meaningless. + std::string network_error; + + bool ok() const { return network_error.empty(); } +}; + +struct HttpRequest { + std::string method = "GET"; + std::string url; + std::string body; + std::string content_type; + + /// Whole-request timeout. Kept short: this runs on OBS's UI thread when + /// the properties dropdown is refreshed, and on the source's own worker + /// thread when a token is minted. + int timeout_ms = 10000; +}; + +class HttpClient { +public: + virtual ~HttpClient() = default; + virtual HttpResponse send(const HttpRequest &request) = 0; +}; + +/// Percent-encode a string for use in a URL query value. +std::string urlEncode(const std::string &value); + +/// Construct the platform's real HTTP client. Returns nullptr if no backend +/// was compiled in. +HttpClient *createPlatformHttpClient(); + +} // namespace stplugin diff --git a/core/include/stplugin/json.h b/core/include/stplugin/json.h new file mode 100644 index 0000000..20d08e8 --- /dev/null +++ b/core/include/stplugin/json.h @@ -0,0 +1,106 @@ +/* +streamer-tools OBS Camera Plugin - minimal JSON reader +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#pragma once + +// A deliberately small, strict, allocation-bounded JSON reader. +// +// Why hand-rolled rather than vendoring nlohmann/json: the only JSON this +// plugin ever parses is two small, fixed-shape responses from its own +// server (apps/server/src/obs/plugin.routes.ts in the streamer-tools repo), +// and the parser has to build unmodified on three platforms with no package +// manager step in CI. The scope is small enough to test exhaustively -- +// including the malformed inputs a compromised or misconfigured endpoint +// could return, which is the case that must not crash or hang OBS. +// +// Properties this parser guarantees, all covered by core/tests/test_json.cpp: +// - never throws; every failure is reported as Value::invalid() +// - bounded recursion (kMaxDepth) so nesting cannot blow the stack +// - trailing garbage after the top-level value is an error +// - accessors on a wrong-typed value return the caller's default rather +// than aborting, so callers can be written without type interrogation + +#include +#include +#include +#include + +namespace stplugin { +namespace json { + +/// Maximum nesting depth accepted by parse(). Any deeper input is rejected +/// as invalid rather than recursed into. +constexpr int kMaxDepth = 32; + +class Value { +public: + enum class Type { Invalid, Null, Bool, Number, String, Array, Object }; + + Value() = default; + + static Value invalid() { return Value(); } + static Value makeNull(); + static Value makeBool(bool v); + static Value makeNumber(double v); + static Value makeString(std::string v); + static Value makeArray(std::vector v); + static Value makeObject(std::map v); + + Type type() const { return type_; } + bool valid() const { return type_ != Type::Invalid; } + bool isNull() const { return type_ == Type::Null; } + bool isBool() const { return type_ == Type::Bool; } + bool isNumber() const { return type_ == Type::Number; } + bool isString() const { return type_ == Type::String; } + bool isArray() const { return type_ == Type::Array; } + bool isObject() const { return type_ == Type::Object; } + + /// Object member lookup. Returns invalid() for a missing key or when this + /// value is not an object. + const Value &operator[](const std::string &key) const; + + /// Array element access. Returns invalid() when out of range or when this + /// value is not an array. + const Value &at(std::size_t index) const; + + std::size_t size() const; + + /// Typed accessors. Each returns `fallback` when this value is missing or + /// of the wrong type, so callers never have to check first. + std::string asString(const std::string &fallback = std::string()) const; + bool asBool(bool fallback = false) const; + double asNumber(double fallback = 0.0) const; + + const std::vector &elements() const { return array_; } + +private: + Type type_ = Type::Invalid; + bool bool_ = false; + double number_ = 0.0; + std::string string_; + std::vector array_; + std::map object_; +}; + +/// Parse a complete JSON document. Returns Value::invalid() on any syntax +/// error, on trailing non-whitespace content, or on excessive nesting. +/// Never throws. +Value parse(const std::string &text); + +} // namespace json +} // namespace stplugin diff --git a/core/src/api_client.cpp b/core/src/api_client.cpp new file mode 100644 index 0000000..8b49bd7 --- /dev/null +++ b/core/src/api_client.cpp @@ -0,0 +1,211 @@ +/* +streamer-tools OBS Camera Plugin - streamer-tools API client +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include "stplugin/api_client.h" + +#include "stplugin/json.h" + +#include + +namespace stplugin { + +namespace { + +std::string trim(const std::string &s) +{ + std::size_t begin = 0; + std::size_t end = s.size(); + auto is_space = [](char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; }; + while (begin < end && is_space(s[begin])) + ++begin; + while (end > begin && is_space(s[end - 1])) + --end; + return s.substr(begin, end - begin); +} + +/// Map a completed HTTP response onto the shared status codes. Returns +/// ApiStatus::Ok when the caller should go on to parse the body. +ApiStatus classify(const HttpResponse &response, std::string &message) +{ + if (!response.ok()) { + message = response.network_error; + return ApiStatus::NetworkError; + } + if (response.status >= 200 && response.status < 300) + return ApiStatus::Ok; + if (response.status == 404) { + message = "unknown room slug, or the read key is wrong or has been rotated"; + return ApiStatus::NotFound; + } + if (response.status == 503) { + message = "the streamer-tools server has no LiveKit credentials configured"; + return ApiStatus::Unavailable; + } + message = "HTTP " + std::to_string(response.status); + return ApiStatus::HttpError; +} + +std::string buildUrl(const ConnectionConfig &config, const char *suffix) +{ + return ApiClient::normalizeServerUrl(config.server_url) + "/api/obs/" + + urlEncode(trim(config.room_slug)) + suffix + "?key=" + urlEncode(trim(config.read_key)); +} + +} // namespace + +const char *describeApiStatus(ApiStatus status) +{ + switch (status) { + case ApiStatus::Ok: return "ok"; + case ApiStatus::InvalidConfig: return "server URL, room slug and read key are all required"; + case ApiStatus::NetworkError: return "could not reach the streamer-tools server"; + case ApiStatus::NotFound: return "room not found, or the read key is wrong"; + case ApiStatus::Unavailable: return "server has no LiveKit configured"; + case ApiStatus::HttpError: return "unexpected response from the streamer-tools server"; + case ApiStatus::MalformedResponse: return "unreadable response from the streamer-tools server"; + } + return "unknown error"; +} + +ApiClient::ApiClient(std::shared_ptr http) : http_(std::move(http)) {} + +std::string ApiClient::normalizeServerUrl(const std::string &raw) +{ + std::string url = trim(raw); + if (url.empty()) + return url; + + // A bare "streamers.example.com" is what an operator will paste half the + // time. Defaulting to https (never http) keeps the read key off the wire + // in the clear. + const bool has_scheme = url.compare(0, 7, "http://") == 0 || url.compare(0, 8, "https://") == 0; + if (!has_scheme) + url = "https://" + url; + + while (!url.empty() && url.back() == '/') + url.pop_back(); + + // "https://" with nothing after it is not a server. + if (url == "https:/" || url == "https:" || url == "http:/" || url == "http:" || + url == "https://" || url == "http://") + return std::string(); + + return url; +} + +std::string ApiClient::redactedUrl(const std::string &url) +{ + const std::size_t at = url.find("key="); + if (at == std::string::npos) + return url; + const std::size_t value = at + 4; + std::size_t end = url.find('&', value); + if (end == std::string::npos) + end = url.size(); + return url.substr(0, value) + "***" + url.substr(end); +} + +SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const +{ + SlotsResult result; + if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) { + result.status = ApiStatus::InvalidConfig; + result.message = describeApiStatus(ApiStatus::InvalidConfig); + return result; + } + + HttpRequest request; + request.method = "GET"; + request.url = buildUrl(config, "/slots"); + + const HttpResponse response = http_->send(request); + const ApiStatus status = classify(response, result.message); + if (status != ApiStatus::Ok) { + result.status = status; + return result; + } + + const json::Value root = json::parse(response.body); + const json::Value &slots = root["slots"]; + if (!root.isObject() || !slots.isArray()) { + result.status = ApiStatus::MalformedResponse; + result.message = "expected a JSON object with a \"slots\" array"; + return result; + } + + for (const json::Value &entry : slots.elements()) { + // A slot without an identity is unusable -- it is what the session + // wrapper subscribes by -- so skip it rather than surfacing a + // dropdown row that can never connect. Anything else is best-effort: + // a missing displayName falls back to the identity exactly as the + // server itself does. + const std::string identity = entry["identity"].asString(); + if (identity.empty()) + continue; + SlotInfo slot; + slot.identity = identity; + slot.display_name = entry["displayName"].asString(identity); + if (slot.display_name.empty()) + slot.display_name = identity; + slot.live = entry["live"].asBool(false); + result.slots.push_back(std::move(slot)); + } + + result.status = ApiStatus::Ok; + return result; +} + +TokenResult ApiClient::requestToken(const ConnectionConfig &config) const +{ + TokenResult result; + if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) { + result.status = ApiStatus::InvalidConfig; + result.message = describeApiStatus(ApiStatus::InvalidConfig); + return result; + } + + HttpRequest request; + request.method = "POST"; + request.url = buildUrl(config, "/token"); + request.content_type = "application/json"; + request.body = "{}"; + + const HttpResponse response = http_->send(request); + const ApiStatus status = classify(response, result.message); + if (status != ApiStatus::Ok) { + result.status = status; + return result; + } + + const json::Value root = json::parse(response.body); + const std::string token = root["lkToken"].asString(); + const std::string ws_url = root["wsUrl"].asString(); + if (!root.isObject() || token.empty() || ws_url.empty()) { + result.status = ApiStatus::MalformedResponse; + result.message = "expected a JSON object with non-empty \"lkToken\" and \"wsUrl\""; + return result; + } + + result.lk_token = token; + result.ws_url = ws_url; + result.identity = root["identity"].asString(); + result.status = ApiStatus::Ok; + return result; +} + +} // namespace stplugin diff --git a/core/src/http_common.cpp b/core/src/http_common.cpp new file mode 100644 index 0000000..a4e1ccc --- /dev/null +++ b/core/src/http_common.cpp @@ -0,0 +1,46 @@ +/* +streamer-tools OBS Camera Plugin - HTTP helpers shared by all backends +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include "stplugin/http.h" + +namespace stplugin { + +// Hand-rolled rather than curl_easy_escape so the WinHTTP backend gets the +// same behaviour, and so this is testable without a live HTTP client. +// Unreserved set per RFC 3986 section 2.3. +std::string urlEncode(const std::string &value) +{ + static const char *kHex = "0123456789ABCDEF"; + std::string out; + out.reserve(value.size()); + for (const char raw : value) { + const unsigned char c = static_cast(raw); + const bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~'; + if (unreserved) { + out.push_back(static_cast(c)); + } else { + out.push_back('%'); + out.push_back(kHex[c >> 4]); + out.push_back(kHex[c & 0x0F]); + } + } + return out; +} + +} // namespace stplugin diff --git a/core/src/http_curl.cpp b/core/src/http_curl.cpp new file mode 100644 index 0000000..96d8933 --- /dev/null +++ b/core/src/http_curl.cpp @@ -0,0 +1,137 @@ +/* +streamer-tools OBS Camera Plugin - libcurl HTTP backend (Linux/macOS) +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include "stplugin/http.h" + +#include + +#include +#include + +namespace stplugin { + +namespace { + +/// Hard cap on a response body. The two endpoints this client talks to return +/// a few hundred bytes; anything larger is a misconfigured proxy or a wrong +/// URL, and must not be allowed to grow OBS's heap without bound. +constexpr std::size_t kMaxResponseBytes = 4u * 1024u * 1024u; + +struct WriteContext { + std::string body; + bool overflowed = false; +}; + +std::size_t writeCallback(char *ptr, std::size_t size, std::size_t nmemb, void *userdata) +{ + auto *ctx = static_cast(userdata); + const std::size_t bytes = size * nmemb; + if (ctx->body.size() + bytes > kMaxResponseBytes) { + ctx->overflowed = true; + return 0; // aborts the transfer with CURLE_WRITE_ERROR + } + ctx->body.append(ptr, bytes); + return bytes; +} + +/// curl_global_init is not thread-safe and must run once per process before +/// any easy handle is created. OBS may create several sources concurrently. +void ensureCurlGlobalInit() +{ + static std::once_flag once; + std::call_once(once, [] { curl_global_init(CURL_GLOBAL_DEFAULT); }); +} + +class CurlHttpClient : public HttpClient { +public: + HttpResponse send(const HttpRequest &request) override + { + ensureCurlGlobalInit(); + + HttpResponse response; + CURL *curl = curl_easy_init(); + if (!curl) { + response.network_error = "curl_easy_init failed"; + return response; + } + + WriteContext ctx; + struct curl_slist *headers = nullptr; + + curl_easy_setopt(curl, CURLOPT_URL, request.url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast(request.timeout_ms)); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast(request.timeout_ms)); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "streamer-tools-obs-plugin/1.0"); + // NOSIGNAL is required whenever curl is used off the main thread: + // without it curl installs a SIGALRM handler for DNS timeouts, which + // is process-global and would be a rude thing to do inside OBS. + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + // TLS verification stays on. The read key is a credential; sending it + // to an unverified host is exactly the failure this must not have. + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + + if (request.method == "POST") { + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, request.body.c_str()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast(request.body.size())); + } else if (request.method != "GET") { + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, request.method.c_str()); + } + + if (!request.content_type.empty()) { + const std::string header = "Content-Type: " + request.content_type; + headers = curl_slist_append(headers, header.c_str()); + } + // Fastify answers a bare POST with no body fine, but some proxies + // insert an Expect: 100-continue round trip; suppress it. + headers = curl_slist_append(headers, "Expect:"); + if (headers) + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + const CURLcode rc = curl_easy_perform(curl); + if (rc == CURLE_OK) { + long status = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + response.status = status; + response.body = std::move(ctx.body); + } else if (ctx.overflowed) { + response.network_error = "response body exceeded 4 MiB"; + } else { + response.network_error = curl_easy_strerror(rc); + } + + if (headers) + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return response; + } +}; + +} // namespace + +HttpClient *createPlatformHttpClient() +{ + return new CurlHttpClient(); +} + +} // namespace stplugin diff --git a/core/src/http_winhttp.cpp b/core/src/http_winhttp.cpp new file mode 100644 index 0000000..82161ed --- /dev/null +++ b/core/src/http_winhttp.cpp @@ -0,0 +1,208 @@ +/* +streamer-tools OBS Camera Plugin - WinHTTP backend (Windows) +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +// WinHTTP rather than libcurl on Windows: it ships with the OS, does TLS +// through SChannel (so no OpenSSL to build or ship), and needs no package +// manager on the self-hosted `winvm-builder` runner -- which, per the +// scaffold README, is a bare VM without even cmake preinstalled. + +#include "stplugin/http.h" + +#include +#include + +#include +#include +#include + +namespace stplugin { + +namespace { + +constexpr std::size_t kMaxResponseBytes = 4u * 1024u * 1024u; + +std::wstring widen(const std::string &s) +{ + if (s.empty()) + return std::wstring(); + const int needed = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast(s.size()), nullptr, 0); + if (needed <= 0) + return std::wstring(); + std::wstring out(static_cast(needed), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast(s.size()), &out[0], needed); + return out; +} + +std::string lastErrorMessage(const char *what) +{ + return std::string(what) + " failed (GetLastError=" + std::to_string(GetLastError()) + ")"; +} + +/// RAII for the three WinHTTP handle kinds, which all close the same way. +class Handle { +public: + Handle() = default; + explicit Handle(HINTERNET h) : h_(h) {} + ~Handle() + { + if (h_) + WinHttpCloseHandle(h_); + } + Handle(const Handle &) = delete; + Handle &operator=(const Handle &) = delete; + + void reset(HINTERNET h) + { + if (h_) + WinHttpCloseHandle(h_); + h_ = h; + } + HINTERNET get() const { return h_; } + explicit operator bool() const { return h_ != nullptr; } + +private: + HINTERNET h_ = nullptr; +}; + +class WinHttpClient : public HttpClient { +public: + HttpResponse send(const HttpRequest &request) override + { + HttpResponse response; + + const std::wstring url = widen(request.url); + if (url.empty()) { + response.network_error = "empty or non-UTF-8 URL"; + return response; + } + + URL_COMPONENTS parts{}; + parts.dwStructSize = sizeof(parts); + wchar_t host[256] = {0}; + wchar_t path[4096] = {0}; + wchar_t extra[4096] = {0}; + parts.lpszHostName = host; + parts.dwHostNameLength = static_cast(sizeof(host) / sizeof(host[0])); + parts.lpszUrlPath = path; + parts.dwUrlPathLength = static_cast(sizeof(path) / sizeof(path[0])); + parts.lpszExtraInfo = extra; + parts.dwExtraInfoLength = static_cast(sizeof(extra) / sizeof(extra[0])); + + if (!WinHttpCrackUrl(url.c_str(), static_cast(url.size()), 0, &parts)) { + response.network_error = lastErrorMessage("WinHttpCrackUrl"); + return response; + } + if (parts.nScheme != INTERNET_SCHEME_HTTP && parts.nScheme != INTERNET_SCHEME_HTTPS) { + response.network_error = "unsupported URL scheme"; + return response; + } + + Handle session(WinHttpOpen(L"streamer-tools-obs-plugin/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)); + if (!session) { + response.network_error = lastErrorMessage("WinHttpOpen"); + return response; + } + + const DWORD timeout = static_cast(request.timeout_ms); + WinHttpSetTimeouts(session.get(), static_cast(timeout), static_cast(timeout), + static_cast(timeout), static_cast(timeout)); + + Handle connect(WinHttpConnect(session.get(), host, parts.nPort, 0)); + if (!connect) { + response.network_error = lastErrorMessage("WinHttpConnect"); + return response; + } + + std::wstring target(path); + target += extra; + + const DWORD flags = (parts.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0u; + Handle req(WinHttpOpenRequest(connect.get(), widen(request.method).c_str(), target.c_str(), nullptr, + WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags)); + if (!req) { + response.network_error = lastErrorMessage("WinHttpOpenRequest"); + return response; + } + + std::wstring headers; + if (!request.content_type.empty()) + headers = L"Content-Type: " + widen(request.content_type) + L"\r\n"; + + const LPCWSTR header_ptr = headers.empty() ? WINHTTP_NO_ADDITIONAL_HEADERS : headers.c_str(); + const DWORD header_len = headers.empty() ? 0u : static_cast(headers.size()); + + void *body_ptr = request.body.empty() ? WINHTTP_NO_REQUEST_DATA + : const_cast(request.body.data()); + const DWORD body_len = static_cast(request.body.size()); + + if (!WinHttpSendRequest(req.get(), header_ptr, header_len, body_ptr, body_len, body_len, 0)) { + response.network_error = lastErrorMessage("WinHttpSendRequest"); + return response; + } + if (!WinHttpReceiveResponse(req.get(), nullptr)) { + response.network_error = lastErrorMessage("WinHttpReceiveResponse"); + return response; + } + + DWORD status = 0; + DWORD status_size = sizeof(status); + if (!WinHttpQueryHeaders(req.get(), WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX)) { + response.network_error = lastErrorMessage("WinHttpQueryHeaders"); + return response; + } + response.status = static_cast(status); + + std::string body; + for (;;) { + DWORD available = 0; + if (!WinHttpQueryDataAvailable(req.get(), &available)) { + response.network_error = lastErrorMessage("WinHttpQueryDataAvailable"); + return response; + } + if (available == 0) + break; + if (body.size() + available > kMaxResponseBytes) { + response.network_error = "response body exceeded 4 MiB"; + return response; + } + std::vector chunk(available); + DWORD read = 0; + if (!WinHttpReadData(req.get(), chunk.data(), available, &read)) { + response.network_error = lastErrorMessage("WinHttpReadData"); + return response; + } + if (read == 0) + break; + body.append(chunk.data(), read); + } + + response.body = std::move(body); + return response; + } +}; + +} // namespace + +HttpClient *createPlatformHttpClient() +{ + return new WinHttpClient(); +} + +} // namespace stplugin diff --git a/core/src/json.cpp b/core/src/json.cpp new file mode 100644 index 0000000..8d1fe0b --- /dev/null +++ b/core/src/json.cpp @@ -0,0 +1,442 @@ +/* +streamer-tools OBS Camera Plugin - minimal JSON reader +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include "stplugin/json.h" + +#include +#include + +namespace stplugin { +namespace json { + +namespace { +const Value &invalidSingleton() +{ + static const Value v; + return v; +} +} // namespace + +Value Value::makeNull() +{ + Value v; + v.type_ = Type::Null; + return v; +} + +Value Value::makeBool(bool b) +{ + Value v; + v.type_ = Type::Bool; + v.bool_ = b; + return v; +} + +Value Value::makeNumber(double n) +{ + Value v; + v.type_ = Type::Number; + v.number_ = n; + return v; +} + +Value Value::makeString(std::string s) +{ + Value v; + v.type_ = Type::String; + v.string_ = std::move(s); + return v; +} + +Value Value::makeArray(std::vector a) +{ + Value v; + v.type_ = Type::Array; + v.array_ = std::move(a); + return v; +} + +Value Value::makeObject(std::map o) +{ + Value v; + v.type_ = Type::Object; + v.object_ = std::move(o); + return v; +} + +const Value &Value::operator[](const std::string &key) const +{ + if (type_ != Type::Object) + return invalidSingleton(); + auto it = object_.find(key); + if (it == object_.end()) + return invalidSingleton(); + return it->second; +} + +const Value &Value::at(std::size_t index) const +{ + if (type_ != Type::Array || index >= array_.size()) + return invalidSingleton(); + return array_[index]; +} + +std::size_t Value::size() const +{ + if (type_ == Type::Array) + return array_.size(); + if (type_ == Type::Object) + return object_.size(); + if (type_ == Type::String) + return string_.size(); + return 0; +} + +std::string Value::asString(const std::string &fallback) const +{ + return type_ == Type::String ? string_ : fallback; +} + +bool Value::asBool(bool fallback) const +{ + return type_ == Type::Bool ? bool_ : fallback; +} + +double Value::asNumber(double fallback) const +{ + return type_ == Type::Number ? number_ : fallback; +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +namespace { + +class Parser { +public: + explicit Parser(const std::string &text) : s_(text) {} + + bool parseDocument(Value &out) + { + skipWs(); + if (!parseValue(out, 0)) + return false; + skipWs(); + // Trailing content is an error: "{}garbage" must not silently parse + // as an empty object. + return pos_ == s_.size(); + } + +private: + const std::string &s_; + std::size_t pos_ = 0; + + bool eof() const { return pos_ >= s_.size(); } + char peek() const { return s_[pos_]; } + + void skipWs() + { + while (!eof()) { + const char c = s_[pos_]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') + ++pos_; + else + break; + } + } + + bool literal(const char *lit) + { + const std::size_t n = std::strlen(lit); + if (s_.compare(pos_, n, lit) != 0) + return false; + pos_ += n; + return true; + } + + bool parseValue(Value &out, int depth) + { + if (depth > kMaxDepth) + return false; + if (eof()) + return false; + + switch (peek()) { + case '{': + return parseObject(out, depth); + case '[': + return parseArray(out, depth); + case '"': { + std::string str; + if (!parseString(str)) + return false; + out = Value::makeString(std::move(str)); + return true; + } + case 't': + if (!literal("true")) + return false; + out = Value::makeBool(true); + return true; + case 'f': + if (!literal("false")) + return false; + out = Value::makeBool(false); + return true; + case 'n': + if (!literal("null")) + return false; + out = Value::makeNull(); + return true; + default: + return parseNumber(out); + } + } + + bool parseObject(Value &out, int depth) + { + ++pos_; // '{' + std::map members; + skipWs(); + if (!eof() && peek() == '}') { + ++pos_; + out = Value::makeObject(std::move(members)); + return true; + } + for (;;) { + skipWs(); + std::string key; + if (!parseString(key)) + return false; + skipWs(); + if (eof() || peek() != ':') + return false; + ++pos_; + skipWs(); + Value v; + if (!parseValue(v, depth + 1)) + return false; + members[key] = std::move(v); + skipWs(); + if (eof()) + return false; + if (peek() == ',') { + ++pos_; + continue; + } + if (peek() == '}') { + ++pos_; + out = Value::makeObject(std::move(members)); + return true; + } + return false; + } + } + + bool parseArray(Value &out, int depth) + { + ++pos_; // '[' + std::vector items; + skipWs(); + if (!eof() && peek() == ']') { + ++pos_; + out = Value::makeArray(std::move(items)); + return true; + } + for (;;) { + skipWs(); + Value v; + if (!parseValue(v, depth + 1)) + return false; + items.push_back(std::move(v)); + skipWs(); + if (eof()) + return false; + if (peek() == ',') { + ++pos_; + continue; + } + if (peek() == ']') { + ++pos_; + out = Value::makeArray(std::move(items)); + return true; + } + return false; + } + } + + bool parseHex4(unsigned &out) + { + if (pos_ + 4 > s_.size()) + return false; + unsigned value = 0; + for (int i = 0; i < 4; ++i) { + const char c = s_[pos_ + static_cast(i)]; + unsigned digit; + if (c >= '0' && c <= '9') + digit = static_cast(c - '0'); + else if (c >= 'a' && c <= 'f') + digit = static_cast(c - 'a') + 10u; + else if (c >= 'A' && c <= 'F') + digit = static_cast(c - 'A') + 10u; + else + return false; + value = (value << 4) | digit; + } + pos_ += 4; + out = value; + return true; + } + + static void appendUtf8(std::string &out, unsigned cp) + { + if (cp < 0x80) { + out.push_back(static_cast(cp)); + } else if (cp < 0x800) { + out.push_back(static_cast(0xC0u | (cp >> 6))); + out.push_back(static_cast(0x80u | (cp & 0x3Fu))); + } else if (cp < 0x10000) { + out.push_back(static_cast(0xE0u | (cp >> 12))); + out.push_back(static_cast(0x80u | ((cp >> 6) & 0x3Fu))); + out.push_back(static_cast(0x80u | (cp & 0x3Fu))); + } else { + out.push_back(static_cast(0xF0u | (cp >> 18))); + out.push_back(static_cast(0x80u | ((cp >> 12) & 0x3Fu))); + out.push_back(static_cast(0x80u | ((cp >> 6) & 0x3Fu))); + out.push_back(static_cast(0x80u | (cp & 0x3Fu))); + } + } + + bool parseString(std::string &out) + { + if (eof() || peek() != '"') + return false; + ++pos_; + out.clear(); + for (;;) { + if (eof()) + return false; // unterminated string + const unsigned char c = static_cast(s_[pos_]); + if (c == '"') { + ++pos_; + return true; + } + if (c == '\\') { + ++pos_; + if (eof()) + return false; + const char esc = s_[pos_++]; + switch (esc) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + unsigned cp = 0; + if (!parseHex4(cp)) + return false; + if (cp >= 0xD800 && cp <= 0xDBFF) { + // High surrogate: a low surrogate must follow. + if (pos_ + 1 < s_.size() && s_[pos_] == '\\' && s_[pos_ + 1] == 'u') { + pos_ += 2; + unsigned lo = 0; + if (!parseHex4(lo)) + return false; + if (lo < 0xDC00 || lo > 0xDFFF) + return false; + cp = 0x10000u + ((cp - 0xD800u) << 10) + (lo - 0xDC00u); + } else { + return false; + } + } else if (cp >= 0xDC00 && cp <= 0xDFFF) { + return false; // lone low surrogate + } + appendUtf8(out, cp); + break; + } + default: + return false; + } + continue; + } + if (c < 0x20) + return false; // raw control character + out.push_back(static_cast(c)); + ++pos_; + } + } + + bool parseNumber(Value &out) + { + const std::size_t start = pos_; + if (!eof() && peek() == '-') + ++pos_; + if (eof()) + return false; + if (peek() == '0') { + ++pos_; + } else if (peek() >= '1' && peek() <= '9') { + while (!eof() && peek() >= '0' && peek() <= '9') + ++pos_; + } else { + return false; + } + if (!eof() && peek() == '.') { + ++pos_; + if (eof() || peek() < '0' || peek() > '9') + return false; + while (!eof() && peek() >= '0' && peek() <= '9') + ++pos_; + } + if (!eof() && (peek() == 'e' || peek() == 'E')) { + ++pos_; + if (!eof() && (peek() == '+' || peek() == '-')) + ++pos_; + if (eof() || peek() < '0' || peek() > '9') + return false; + while (!eof() && peek() >= '0' && peek() <= '9') + ++pos_; + } + const std::string token = s_.substr(start, pos_ - start); + // strtod is locale-sensitive for the decimal separator, but the + // grammar above only ever hands it ASCII digits with a '.', and OBS + // does not switch the C locale away from "C". Using strtod rather + // than std::stod keeps this noexcept. + out = Value::makeNumber(std::strtod(token.c_str(), nullptr)); + return true; + } +}; + +} // namespace + +Value parse(const std::string &text) +{ + Parser p(text); + Value v; + if (!p.parseDocument(v)) + return Value::invalid(); + return v; +} + +} // namespace json +} // namespace stplugin diff --git a/core/tests/CMakeLists.txt b/core/tests/CMakeLists.txt index 2b1f2f4..343c97c 100644 --- a/core/tests/CMakeLists.txt +++ b/core/tests/CMakeLists.txt @@ -1,18 +1,28 @@ -add_executable(stplugin_core_tests - test_core.cpp -) -target_link_libraries(stplugin_core_tests PRIVATE stplugin_core) -add_test(NAME stplugin_core_tests COMMAND stplugin_core_tests) +# Dependency-free CTest targets (see test_util.h for why there is no gtest). + +function(stplugin_add_test name) + add_executable(${name} ${name}.cpp) + target_link_libraries(${name} PRIVATE stplugin_core) + target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + if(WIN32) + # loopback_server.h needs Winsock for the real-backend tests. + target_link_libraries(${name} PRIVATE ws2_32) + endif() + add_test(NAME ${name} COMMAND ${name}) + # Nothing here should ever take a minute; a hang is a failure, not a + # reason for CI to sit for its default 1500s. + set_tests_properties(${name} PROPERTIES TIMEOUT 120) +endfunction() + +stplugin_add_test(test_core) +stplugin_add_test(test_json) +stplugin_add_test(test_api_client) # Smoke test for the LiveKit SDK link: initialize()/shutdown() must succeed # in-process. This is the cheapest possible proof that LiveKit::livekit is # not just linked but loadable and callable (it dlopen-chains into # liblivekit_ffi, which is where a broken RPATH would show up). -add_executable(stplugin_livekit_smoke - test_livekit_smoke.cpp -) -target_link_libraries(stplugin_livekit_smoke PRIVATE stplugin_core) -target_compile_definitions(stplugin_livekit_smoke PRIVATE +stplugin_add_test(test_livekit_smoke) +target_compile_definitions(test_livekit_smoke PRIVATE STPLUGIN_EXPECTED_LIVEKIT_VERSION="${LIVEKIT_SDK_VERSION_RESOLVED}" ) -add_test(NAME stplugin_livekit_smoke COMMAND stplugin_livekit_smoke) diff --git a/core/tests/loopback_server.h b/core/tests/loopback_server.h new file mode 100644 index 0000000..cfb1726 --- /dev/null +++ b/core/tests/loopback_server.h @@ -0,0 +1,246 @@ +/* +streamer-tools OBS Camera Plugin - minimal loopback HTTP server for tests +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#pragma once + +// A single-threaded, one-request-at-a-time HTTP/1.1 server on 127.0.0.1, used +// to exercise the *real* platform HTTP backend (libcurl on Linux/macOS, +// WinHTTP on Windows) rather than only a fake. The handler returns raw bytes, +// so tests can serve deliberately malformed responses and half-closed +// connections -- the cases that must not hang or crash OBS. +// +// Plain HTTP only: a TLS listener would need a certificate and would test +// libcurl/SChannel rather than this plugin. + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +using st_socket_t = SOCKET; +#define ST_INVALID_SOCKET INVALID_SOCKET +#define ST_CLOSE_SOCKET closesocket +#else +#include +#include +#include +#include +#include +using st_socket_t = int; +#define ST_INVALID_SOCKET (-1) +#define ST_CLOSE_SOCKET ::close +#endif + +namespace sttest { + +/// Returns raw response bytes for a received raw request. Returning an empty +/// string means "close the connection without replying". +using LoopbackHandler = std::function; + +class LoopbackServer { +public: + explicit LoopbackServer(LoopbackHandler handler) : handler_(std::move(handler)) + { +#ifdef _WIN32 + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); +#endif + listen_ = ::socket(AF_INET, SOCK_STREAM, 0); + if (listen_ == ST_INVALID_SOCKET) + return; + + int reuse = 1; + ::setsockopt(listen_, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&reuse), sizeof(reuse)); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // let the OS pick a free port + if (::bind(listen_, reinterpret_cast(&addr), sizeof(addr)) != 0) { + ST_CLOSE_SOCKET(listen_); + listen_ = ST_INVALID_SOCKET; + return; + } + if (::listen(listen_, 4) != 0) { + ST_CLOSE_SOCKET(listen_); + listen_ = ST_INVALID_SOCKET; + return; + } + + sockaddr_in bound{}; +#ifdef _WIN32 + int len = sizeof(bound); +#else + socklen_t len = sizeof(bound); +#endif + if (::getsockname(listen_, reinterpret_cast(&bound), &len) != 0) { + ST_CLOSE_SOCKET(listen_); + listen_ = ST_INVALID_SOCKET; + return; + } + port_ = ntohs(bound.sin_port); + + thread_ = std::thread([this] { run(); }); + } + + ~LoopbackServer() + { + stop_.store(true); + if (thread_.joinable()) + thread_.join(); + if (listen_ != ST_INVALID_SOCKET) + ST_CLOSE_SOCKET(listen_); +#ifdef _WIN32 + WSACleanup(); +#endif + } + + LoopbackServer(const LoopbackServer &) = delete; + LoopbackServer &operator=(const LoopbackServer &) = delete; + + bool valid() const { return listen_ != ST_INVALID_SOCKET; } + int port() const { return port_; } + std::string baseUrl() const { return "http://127.0.0.1:" + std::to_string(port_); } + int requestCount() const { return requests_.load(); } + + /// The most recent raw request, for asserting on method/path/body. + std::string lastRequest() const + { + std::lock_guard guard(mutex_); + return last_request_; + } + +private: + void run() + { + while (!stop_.load()) { + // select() with a short timeout rather than a blocking accept(), + // so the destructor's stop flag is honoured promptly on every + // platform (closing a socket another thread is blocked in + // accept() on is not portable). + fd_set readable; + FD_ZERO(&readable); + FD_SET(listen_, &readable); + timeval tv{}; + tv.tv_sec = 0; + tv.tv_usec = 50000; // 50ms + const int ready = ::select(static_cast(listen_) + 1, &readable, nullptr, nullptr, &tv); + if (ready <= 0) + continue; + + st_socket_t client = ::accept(listen_, nullptr, nullptr); + if (client == ST_INVALID_SOCKET) + continue; + + const std::string request = readRequest(client); + { + std::lock_guard guard(mutex_); + last_request_ = request; + } + requests_.fetch_add(1); + + const std::string response = handler_ ? handler_(request) : std::string(); + if (!response.empty()) + sendAll(client, response); + ST_CLOSE_SOCKET(client); + } + } + + static std::string readRequest(st_socket_t client) + { + std::string data; + char buffer[4096]; + std::size_t header_end = std::string::npos; + long content_length = 0; + + for (;;) { +#ifdef _WIN32 + const int n = ::recv(client, buffer, static_cast(sizeof(buffer)), 0); +#else + const ssize_t n = ::recv(client, buffer, sizeof(buffer), 0); +#endif + if (n <= 0) + break; + data.append(buffer, static_cast(n)); + + if (header_end == std::string::npos) { + header_end = data.find("\r\n\r\n"); + if (header_end != std::string::npos) + content_length = parseContentLength(data.substr(0, header_end)); + } + if (header_end != std::string::npos && + data.size() >= header_end + 4 + static_cast(content_length)) + break; + } + return data; + } + + static long parseContentLength(const std::string &headers) + { + std::string lower; + lower.reserve(headers.size()); + for (char c : headers) + lower.push_back(static_cast(c >= 'A' && c <= 'Z' ? c + 32 : c)); + const std::size_t at = lower.find("content-length:"); + if (at == std::string::npos) + return 0; + return std::strtol(headers.c_str() + at + 15, nullptr, 10); + } + + static void sendAll(st_socket_t client, const std::string &data) + { + std::size_t sent = 0; + while (sent < data.size()) { +#ifdef _WIN32 + const int n = ::send(client, data.data() + sent, static_cast(data.size() - sent), 0); +#else + const ssize_t n = ::send(client, data.data() + sent, data.size() - sent, 0); +#endif + if (n <= 0) + return; + sent += static_cast(n); + } + } + + LoopbackHandler handler_; + st_socket_t listen_ = ST_INVALID_SOCKET; + int port_ = 0; + std::thread thread_; + std::atomic stop_{false}; + std::atomic requests_{0}; + mutable std::mutex mutex_; + std::string last_request_; +}; + +/// Build a well-formed HTTP/1.1 response with an explicit Content-Length and +/// Connection: close, so the client never waits for keep-alive reuse. +inline std::string httpResponse(int status, const std::string &reason, const std::string &body, + const std::string &content_type = "application/json") +{ + return "HTTP/1.1 " + std::to_string(status) + " " + reason + "\r\n" + "Content-Type: " + content_type + + "\r\n" + "Content-Length: " + std::to_string(body.size()) + "\r\n" + "Connection: close\r\n\r\n" + + body; +} + +} // namespace sttest diff --git a/core/tests/test_api_client.cpp b/core/tests/test_api_client.cpp new file mode 100644 index 0000000..0c74260 --- /dev/null +++ b/core/tests/test_api_client.cpp @@ -0,0 +1,434 @@ +/* +streamer-tools OBS Camera Plugin - API client tests +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +// Two layers of coverage: +// 1. a fake HttpClient, for response parsing and every error branch; +// 2. a real loopback HTTP server driven through the *platform* backend +// (libcurl or WinHTTP), so the backend itself is exercised in CI on all +// three runners rather than assumed to work. + +#include +#include +#include +#include +#include + +#include "stplugin/api_client.h" +#include "stplugin/http.h" + +#include "loopback_server.h" +#include "test_util.h" + +using namespace stplugin; + +namespace { + +class FakeHttpClient : public HttpClient { +public: + HttpResponse next; + HttpRequest last; + int calls = 0; + + HttpResponse send(const HttpRequest &request) override + { + last = request; + ++calls; + return next; + } +}; + +ConnectionConfig testConfig() +{ + return ConnectionConfig{"https://streamers.example.com", "main-room", "readkey123"}; +} + +std::shared_ptr makeFake(long status, const std::string &body) +{ + auto fake = std::make_shared(); + fake->next.status = status; + fake->next.body = body; + return fake; +} + +// --------------------------------------------------------------------------- +// URL handling +// --------------------------------------------------------------------------- + +void testNormalizeServerUrl() +{ + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com"), std::string("https://a.example.com")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com/"), std::string("https://a.example.com")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com///"), std::string("https://a.example.com")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl(" https://a.example.com "), std::string("https://a.example.com")); + // No scheme defaults to https, never http: the read key is a credential. + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("a.example.com"), std::string("https://a.example.com")); + // An explicit http:// is honoured -- the test LXC is reachable that way. + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("http://192.168.1.175:3000"), std::string("http://192.168.1.175:3000")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl(""), std::string("")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl(" "), std::string("")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://"), std::string("")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("/"), std::string("")); +} + +void testUrlEncodeAndRedaction() +{ + ST_ASSERT_EQ(urlEncode("plain-slug_1.0~"), std::string("plain-slug_1.0~")); + ST_ASSERT_EQ(urlEncode("a b"), std::string("a%20b")); + ST_ASSERT_EQ(urlEncode("a/b?c=d&e"), std::string("a%2Fb%3Fc%3Dd%26e")); + ST_ASSERT_EQ(urlEncode("k\xc3\xa9y"), std::string("k%C3%A9y")); + + ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/api/obs/r/slots?key=secret"), + std::string("https://h/api/obs/r/slots?key=***")); + ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/api/obs/r/slots?key=secret&x=1"), + std::string("https://h/api/obs/r/slots?key=***&x=1")); + ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/nothing"), std::string("https://h/nothing")); +} + +void testRequestShape() +{ + auto fake = makeFake(200, R"({"slots":[]})"); + ApiClient client(fake); + ConnectionConfig config = testConfig(); + // Values that need encoding, and stray whitespace an operator would paste. + config.room_slug = " main room "; + config.read_key = " a+b/c "; + (void)client.fetchSlots(config); + ST_ASSERT_EQ(fake->last.method, std::string("GET")); + ST_ASSERT_EQ(fake->last.url, + std::string("https://streamers.example.com/api/obs/main%20room/slots?key=a%2Bb%2Fc")); + + auto fake2 = makeFake(200, R"({"lkToken":"t","wsUrl":"wss://x","identity":"obs:r:1"})"); + ApiClient client2(fake2); + (void)client2.requestToken(testConfig()); + ST_ASSERT_EQ(fake2->last.method, std::string("POST")); + ST_ASSERT_EQ(fake2->last.url, + std::string("https://streamers.example.com/api/obs/main-room/token?key=readkey123")); + ST_ASSERT_EQ(fake2->last.content_type, std::string("application/json")); +} + +// --------------------------------------------------------------------------- +// Response parsing +// --------------------------------------------------------------------------- + +void testSlotsHappyPath() +{ + auto fake = makeFake(200, + R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true},)" + R"({"identity":"cam2","displayName":"Bob","live":false}]})"); + ApiClient client(fake); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.slots.size(), std::size_t(2)); + ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1")); + ST_ASSERT_EQ(result.slots[0].display_name, std::string("Alice")); + ST_ASSERT_EQ(result.slots[0].live, true); + ST_ASSERT_EQ(result.slots[1].live, false); +} + +void testSlotsEdgeCases() +{ + // Empty room: a valid answer, not an error. + { + ApiClient client(makeFake(200, R"({"slots":[]})")); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.slots.size(), std::size_t(0)); + } + // A missing/blank displayName falls back to the identity, matching what + // the server itself does for a slot with no display_name. + { + ApiClient client(makeFake(200, R"({"slots":[{"identity":"cam1"},{"identity":"cam2","displayName":""}]})")); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.slots.size(), std::size_t(2)); + ST_ASSERT_EQ(result.slots[0].display_name, std::string("cam1")); + ST_ASSERT_EQ(result.slots[1].display_name, std::string("cam2")); + ST_ASSERT_EQ(result.slots[0].live, false); // missing `live` is not live + } + // An entry with no identity is unusable and is dropped, not surfaced as a + // dropdown row that could never connect. + { + ApiClient client(makeFake(200, R"({"slots":[{"displayName":"ghost"},{"identity":"cam1"}]})")); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.slots.size(), std::size_t(1)); + ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1")); + } + // Wrong types where the shape is otherwise right: don't crash, don't + // invent values. + { + ApiClient client(makeFake(200, R"({"slots":[{"identity":"cam1","displayName":42,"live":"yes"}]})")); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.slots.size(), std::size_t(1)); + ST_ASSERT_EQ(result.slots[0].display_name, std::string("cam1")); + ST_ASSERT_EQ(result.slots[0].live, false); + } +} + +void testTokenHappyPath() +{ + ApiClient client(makeFake(200, + R"({"lkToken":"eyJhbGciOiJIUzI1NiJ9.abc.def",)" + R"("wsUrl":"wss://streamers.example.com","identity":"obs:main-room:Ab_1"})")); + const TokenResult result = client.requestToken(testConfig()); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.lk_token, std::string("eyJhbGciOiJIUzI1NiJ9.abc.def")); + ST_ASSERT_EQ(result.ws_url, std::string("wss://streamers.example.com")); + ST_ASSERT_EQ(result.identity, std::string("obs:main-room:Ab_1")); +} + +void testHttpErrorStatuses() +{ + // 404 -- a wrong read key and an unknown slug are deliberately + // indistinguishable server-side, so the message must not claim to know. + { + ApiClient client(makeFake(404, R"({"error":"not found"})")); + const SlotsResult slots = client.fetchSlots(testConfig()); + ST_ASSERT(!slots.ok()); + ST_ASSERT(slots.status == ApiStatus::NotFound); + ST_ASSERT_EQ(slots.slots.size(), std::size_t(0)); + + const TokenResult token = client.requestToken(testConfig()); + ST_ASSERT(token.status == ApiStatus::NotFound); + ST_ASSERT_EQ(token.lk_token, std::string("")); + } + // 503 -- server reachable, LiveKit not configured. + { + ApiClient client(makeFake(503, R"({"error":"livekit not configured"})")); + ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::Unavailable); + ST_ASSERT(client.requestToken(testConfig()).status == ApiStatus::Unavailable); + } + // Anything else, e.g. a reverse proxy answering before the app does. + { + ApiClient client(makeFake(502, "502 Bad Gateway")); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.status == ApiStatus::HttpError); + ST_ASSERT_EQ(result.message, std::string("HTTP 502")); + } + { + ApiClient client(makeFake(401, "")); + ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::HttpError); + } +} + +void testMalformedSuccessBodies() +{ + // 200 with a body that is not the expected shape must be reported, not + // silently treated as "no slots". + const char *bad_slots[] = { + "", + "not json at all", + "{}", + R"({"slots":null})", + R"({"slots":{}})", + R"({"slots":"cam1"})", + "[]", + R"({"slots":[)", + "login page", + }; + for (const char *body : bad_slots) { + ApiClient client(makeFake(200, body)); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.status == ApiStatus::MalformedResponse); + ST_ASSERT_EQ(result.slots.size(), std::size_t(0)); + } + + const char *bad_token[] = { + "", + "{}", + R"({"lkToken":""})", + R"({"lkToken":"t"})", // no wsUrl + R"({"wsUrl":"wss://x"})", // no token + R"({"lkToken":123,"wsUrl":"wss://x"})", // wrong type + R"({"lkToken":"t","wsUrl":""})", + "[]", + "\xff\xfe binary", + }; + for (const char *body : bad_token) { + ApiClient client(makeFake(200, body)); + const TokenResult result = client.requestToken(testConfig()); + ST_ASSERT(result.status == ApiStatus::MalformedResponse); + ST_ASSERT_EQ(result.lk_token, std::string("")); + } +} + +void testNetworkErrorAndInvalidConfig() +{ + { + auto fake = std::make_shared(); + fake->next.network_error = "Could not resolve host"; + ApiClient client(fake); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.status == ApiStatus::NetworkError); + ST_ASSERT_EQ(result.message, std::string("Could not resolve host")); + } + // An incomplete config must never reach the HTTP layer at all. + { + auto fake = makeFake(200, R"({"slots":[]})"); + ApiClient client(fake); + ST_ASSERT(client.fetchSlots(ConnectionConfig{"", "r", "k"}).status == ApiStatus::InvalidConfig); + ST_ASSERT(client.fetchSlots(ConnectionConfig{"https://h", "", "k"}).status == ApiStatus::InvalidConfig); + ST_ASSERT(client.fetchSlots(ConnectionConfig{"https://h", "r", ""}).status == ApiStatus::InvalidConfig); + ST_ASSERT(client.requestToken(ConnectionConfig{"https://", "r", "k"}).status == ApiStatus::InvalidConfig); + ST_ASSERT_EQ(fake->calls, 0); + } + // A null HTTP client is a programming error, not a crash. + { + ApiClient client(nullptr); + ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::InvalidConfig); + } +} + +// --------------------------------------------------------------------------- +// Real platform HTTP backend, against a real loopback socket +// --------------------------------------------------------------------------- + +ConnectionConfig loopbackConfig(const sttest::LoopbackServer &server) +{ + return ConnectionConfig{server.baseUrl(), "main-room", "readkey123"}; +} + +void testPlatformBackendAgainstLoopback() +{ + std::shared_ptr http(createPlatformHttpClient()); + ST_ASSERT(http != nullptr); + if (!http) + return; + ApiClient client(http); + + // 200 with real slots, and the request line/headers the server sees. + { + sttest::LoopbackServer server([](const std::string &) { + return sttest::httpResponse(200, "OK", + R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true}]})"); + }); + ST_ASSERT(server.valid()); + const SlotsResult result = client.fetchSlots(loopbackConfig(server)); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.slots.size(), std::size_t(1)); + ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1")); + ST_ASSERT(server.lastRequest().find("GET /api/obs/main-room/slots?key=readkey123") == 0); + } + + // POST /token: verify the method and that a body is actually sent. + { + sttest::LoopbackServer server([](const std::string &) { + return sttest::httpResponse(200, "OK", R"({"lkToken":"tok","wsUrl":"wss://lk.example","identity":"obs:r:1"})"); + }); + ST_ASSERT(server.valid()); + const TokenResult result = client.requestToken(loopbackConfig(server)); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.lk_token, std::string("tok")); + ST_ASSERT_EQ(result.ws_url, std::string("wss://lk.example")); + ST_ASSERT(server.lastRequest().find("POST /api/obs/main-room/token?key=readkey123") == 0); + } + + // 404 and 503 over a real socket. + { + sttest::LoopbackServer server([](const std::string &) { + return sttest::httpResponse(404, "Not Found", R"({"error":"not found"})"); + }); + ST_ASSERT(client.fetchSlots(loopbackConfig(server)).status == ApiStatus::NotFound); + } + { + sttest::LoopbackServer server([](const std::string &) { + return sttest::httpResponse(503, "Service Unavailable", R"({"error":"livekit not configured"})"); + }); + ST_ASSERT(client.requestToken(loopbackConfig(server)).status == ApiStatus::Unavailable); + } + + // 200 with a truncated JSON body: must be MalformedResponse, not a hang + // and not a crash. + { + sttest::LoopbackServer server([](const std::string &) { + return sttest::httpResponse(200, "OK", R"({"slots":[{"identity":)"); + }); + ST_ASSERT(client.fetchSlots(loopbackConfig(server)).status == ApiStatus::MalformedResponse); + } + + // A server that accepts the connection and closes without replying at + // all. This is a network error, and it must come back promptly. + { + sttest::LoopbackServer server([](const std::string &) { return std::string(); }); + const auto start = std::chrono::steady_clock::now(); + const SlotsResult result = client.fetchSlots(loopbackConfig(server)); + const auto elapsed = std::chrono::steady_clock::now() - start; + ST_ASSERT(result.status == ApiStatus::NetworkError); + ST_ASSERT(std::chrono::duration_cast(elapsed).count() < 15); + } + + // Garbage that is not HTTP at all. + { + sttest::LoopbackServer server([](const std::string &) { return std::string("\x01\x02not http\r\n\r\n"); }); + const SlotsResult result = client.fetchSlots(loopbackConfig(server)); + ST_ASSERT(result.status == ApiStatus::NetworkError || result.status == ApiStatus::MalformedResponse || + result.status == ApiStatus::HttpError); + } + + // Nothing listening on the port at all: a clean NetworkError. + { + int dead_port = 0; + { + sttest::LoopbackServer server([](const std::string &) { return std::string(); }); + dead_port = server.port(); + } // server destroyed, port closed + ConnectionConfig config{"http://127.0.0.1:" + std::to_string(dead_port), "main-room", "readkey123"}; + ST_ASSERT(client.fetchSlots(config).status == ApiStatus::NetworkError); + } +} + +void testPlatformBackendTimeout() +{ + // A server that accepts and then stalls. The plugin must give up on its + // own timeout rather than blocking an OBS thread indefinitely. + sttest::LoopbackServer server([](const std::string &) { + std::this_thread::sleep_for(std::chrono::seconds(5)); + return sttest::httpResponse(200, "OK", R"({"slots":[]})"); + }); + ST_ASSERT(server.valid()); + + std::shared_ptr http(createPlatformHttpClient()); + HttpRequest request; + request.url = server.baseUrl() + "/api/obs/main-room/slots?key=k"; + request.timeout_ms = 700; + + const auto start = std::chrono::steady_clock::now(); + const HttpResponse response = http->send(request); + const auto elapsed = std::chrono::steady_clock::now() - start; + ST_ASSERT(!response.ok()); + ST_ASSERT(std::chrono::duration_cast(elapsed).count() < 4000); +} + +} // namespace + +int main() +{ + testNormalizeServerUrl(); + testUrlEncodeAndRedaction(); + testRequestShape(); + testSlotsHappyPath(); + testSlotsEdgeCases(); + testTokenHappyPath(); + testHttpErrorStatuses(); + testMalformedSuccessBodies(); + testNetworkErrorAndInvalidConfig(); + testPlatformBackendAgainstLoopback(); + testPlatformBackendTimeout(); + return st_test_report("api_client"); +} diff --git a/core/tests/test_json.cpp b/core/tests/test_json.cpp new file mode 100644 index 0000000..de17412 --- /dev/null +++ b/core/tests/test_json.cpp @@ -0,0 +1,163 @@ +/* +streamer-tools OBS Camera Plugin - JSON reader tests +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include + +#include "stplugin/json.h" +#include "test_util.h" + +using stplugin::json::Value; +using stplugin::json::parse; + +static void testRealResponses() +{ + // The exact shape apps/server/src/obs/plugin.routes.ts returns. + const Value slots = parse( + R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true},)" + R"({"identity":"cam2","displayName":"Bob","live":false}]})"); + ST_ASSERT(slots.valid()); + ST_ASSERT(slots.isObject()); + ST_ASSERT(slots["slots"].isArray()); + ST_ASSERT_EQ(slots["slots"].size(), std::size_t(2)); + ST_ASSERT_EQ(slots["slots"].at(0)["identity"].asString(), std::string("cam1")); + ST_ASSERT_EQ(slots["slots"].at(0)["displayName"].asString(), std::string("Alice")); + ST_ASSERT_EQ(slots["slots"].at(0)["live"].asBool(), true); + ST_ASSERT_EQ(slots["slots"].at(1)["live"].asBool(true), false); + + const Value token = parse( + R"({"lkToken":"eyJhbGciOiJIUzI1NiJ9.abc.def","wsUrl":"wss://streamers.example.com",)" + R"("identity":"obs:main-room:Ab_1-cd2"})"); + ST_ASSERT_EQ(token["lkToken"].asString(), std::string("eyJhbGciOiJIUzI1NiJ9.abc.def")); + ST_ASSERT_EQ(token["wsUrl"].asString(), std::string("wss://streamers.example.com")); + ST_ASSERT_EQ(token["identity"].asString(), std::string("obs:main-room:Ab_1-cd2")); + + const Value error = parse(R"({"error":"not found"})"); + ST_ASSERT_EQ(error["error"].asString(), std::string("not found")); +} + +static void testScalarsAndEscapes() +{ + ST_ASSERT(parse("null").isNull()); + ST_ASSERT_EQ(parse("true").asBool(), true); + ST_ASSERT_EQ(parse("false").asBool(true), false); + ST_ASSERT_EQ(parse("0").asNumber(), 0.0); + ST_ASSERT_EQ(parse("-12").asNumber(), -12.0); + ST_ASSERT_EQ(parse("1.5e2").asNumber(), 150.0); + ST_ASSERT_EQ(parse("\"\"").asString("x"), std::string("")); + ST_ASSERT_EQ(parse(R"("a\"b\\c\/d")").asString(), std::string("a\"b\\c/d")); + ST_ASSERT_EQ(parse(R"("\n\t\r\b\f")").asString(), std::string("\n\t\r\b\f")); + + // \u escapes, including a surrogate pair (an emoji in a display name is + // entirely plausible and must not corrupt the dropdown). + ST_ASSERT_EQ(parse(R"("\u0041")").asString(), std::string("A")); + ST_ASSERT_EQ(parse(R"("caf\u00e9")").asString(), std::string("caf\xc3\xa9")); + ST_ASSERT_EQ(parse(R"("\ud83d\ude00")").asString(), std::string("\xf0\x9f\x98\x80")); + + // Whitespace everywhere legal. + ST_ASSERT_EQ(parse(" {\n \"a\" :\t[ 1 , 2 ]\r\n} ")["a"].size(), std::size_t(2)); +} + +static void testMalformedIsRejectedNotCrashed() +{ + const char *bad[] = { + "", + " ", + "{", + "}", + "[", + "[1,", + "[1,]", + "{\"a\"}", + "{\"a\":}", + "{\"a\":1,}", + "{a:1}", + "{'a':1}", + "\"unterminated", + "\"bad\\escape\"", + "\"\\u00\"", + "\"\\uZZZZ\"", + "\"\\ud83d\"", // lone high surrogate + "\"\\ude00\"", // lone low surrogate + "01", // leading zero + "+1", + ".5", + "1.", + "1e", + "1e+", + "tru", + "nulll", + "{}garbage", // trailing content + "[1,2] [3]", + "\"raw\ncontrol\"", // literal control char inside a string + "\xff\xfe", // binary garbage, e.g. an HTML error page prefix + "502 Bad Gateway", + }; + for (const char *text : bad) { + const Value v = parse(text); + ST_ASSERT(!v.valid()); + // Accessors on an invalid value must still be safe and return the + // caller's fallback. + ST_ASSERT_EQ(v["anything"].asString("fallback"), std::string("fallback")); + ST_ASSERT_EQ(v.at(0).asNumber(-1.0), -1.0); + ST_ASSERT_EQ(v.size(), std::size_t(0)); + } +} + +static void testDepthLimit() +{ + // Deep-but-legal nesting is rejected rather than recursed into, so a + // hostile response cannot overflow the stack inside OBS. + std::string deep; + const int depth = stplugin::json::kMaxDepth + 50; + for (int i = 0; i < depth; ++i) + deep += "["; + for (int i = 0; i < depth; ++i) + deep += "]"; + ST_ASSERT(!parse(deep).valid()); + + // Just inside the limit still parses. + std::string shallow; + for (int i = 0; i < stplugin::json::kMaxDepth - 1; ++i) + shallow += "["; + shallow += "1"; + for (int i = 0; i < stplugin::json::kMaxDepth - 1; ++i) + shallow += "]"; + ST_ASSERT(parse(shallow).valid()); +} + +static void testWrongTypesFallBack() +{ + const Value v = parse(R"({"n":5,"s":"x","b":true,"arr":[1],"obj":{}})"); + ST_ASSERT_EQ(v["n"].asString("fallback"), std::string("fallback")); + ST_ASSERT_EQ(v["s"].asNumber(-1.0), -1.0); + ST_ASSERT_EQ(v["s"].asBool(true), true); + ST_ASSERT_EQ(v["missing"].asString("fallback"), std::string("fallback")); + ST_ASSERT_EQ(v["arr"].at(5).asNumber(-1.0), -1.0); + ST_ASSERT_EQ(v["obj"].at(0).asNumber(-1.0), -1.0); + ST_ASSERT_EQ(v["n"]["deeper"].asString("fallback"), std::string("fallback")); +} + +int main() +{ + testRealResponses(); + testScalarsAndEscapes(); + testMalformedIsRejectedNotCrashed(); + testDepthLimit(); + testWrongTypesFallBack(); + return st_test_report("json"); +}