LiveKit integration: real camera feed pipeline #1

Merged
jknapp merged 17 commits from feat/livekit-integration into main 2026-09-07 06:06:50 +00:00
13 changed files with 2234 additions and 12 deletions
Showing only changes of commit bf33966a4e - Show all commits
+24 -1
View File
@@ -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
)
+118
View File
@@ -0,0 +1,118 @@
/*
streamer-tools OBS Camera Plugin - streamer-tools API client
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
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 <https://www.gnu.org/licenses/>
*/
#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=<readKey>
// 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=<readKey>
// 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 <memory>
#include <string>
#include <vector>
#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<SlotInfo> 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:<slug>:<nonce> 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<HttpClient> 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<HttpClient> http_;
};
} // namespace stplugin
+78
View File
@@ -0,0 +1,78 @@
/*
streamer-tools OBS Camera Plugin - HTTP client interface
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
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 <https://www.gnu.org/licenses/>
*/
#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 <map>
#include <string>
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
+106
View File
@@ -0,0 +1,106 @@
/*
streamer-tools OBS Camera Plugin - minimal JSON reader
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
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 <https://www.gnu.org/licenses/>
*/
#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 <cstdint>
#include <map>
#include <string>
#include <vector>
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<Value> v);
static Value makeObject(std::map<std::string, Value> 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<Value> &elements() const { return array_; }
private:
Type type_ = Type::Invalid;
bool bool_ = false;
double number_ = 0.0;
std::string string_;
std::vector<Value> array_;
std::map<std::string, Value> 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
+211
View File
@@ -0,0 +1,211 @@
/*
streamer-tools OBS Camera Plugin - streamer-tools API client
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
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 <https://www.gnu.org/licenses/>
*/
#include "stplugin/api_client.h"
#include "stplugin/json.h"
#include <utility>
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<HttpClient> 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
+46
View File
@@ -0,0 +1,46 @@
/*
streamer-tools OBS Camera Plugin - HTTP helpers shared by all backends
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
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 <https://www.gnu.org/licenses/>
*/
#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<unsigned char>(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<char>(c));
} else {
out.push_back('%');
out.push_back(kHex[c >> 4]);
out.push_back(kHex[c & 0x0F]);
}
}
return out;
}
} // namespace stplugin
+137
View File
@@ -0,0 +1,137 @@
/*
streamer-tools OBS Camera Plugin - libcurl HTTP backend (Linux/macOS)
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
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 <https://www.gnu.org/licenses/>
*/
#include "stplugin/http.h"
#include <curl/curl.h>
#include <cstddef>
#include <mutex>
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<WriteContext *>(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<long>(request.timeout_ms));
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast<long>(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<long>(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
+208
View File
@@ -0,0 +1,208 @@
/*
streamer-tools OBS Camera Plugin - WinHTTP backend (Windows)
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
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 <https://www.gnu.org/licenses/>
*/
// 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 <windows.h>
#include <winhttp.h>
#include <cstddef>
#include <string>
#include <vector>
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<int>(s.size()), nullptr, 0);
if (needed <= 0)
return std::wstring();
std::wstring out(static_cast<std::size_t>(needed), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(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<DWORD>(sizeof(host) / sizeof(host[0]));
parts.lpszUrlPath = path;
parts.dwUrlPathLength = static_cast<DWORD>(sizeof(path) / sizeof(path[0]));
parts.lpszExtraInfo = extra;
parts.dwExtraInfoLength = static_cast<DWORD>(sizeof(extra) / sizeof(extra[0]));
if (!WinHttpCrackUrl(url.c_str(), static_cast<DWORD>(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<DWORD>(request.timeout_ms);
WinHttpSetTimeouts(session.get(), static_cast<int>(timeout), static_cast<int>(timeout),
static_cast<int>(timeout), static_cast<int>(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<DWORD>(headers.size());
void *body_ptr = request.body.empty() ? WINHTTP_NO_REQUEST_DATA
: const_cast<char *>(request.body.data());
const DWORD body_len = static_cast<DWORD>(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<long>(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<char> 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
+442
View File
@@ -0,0 +1,442 @@
/*
streamer-tools OBS Camera Plugin - minimal JSON reader
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
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 <https://www.gnu.org/licenses/>
*/
#include "stplugin/json.h"
#include <cstdlib>
#include <cstring>
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<Value> a)
{
Value v;
v.type_ = Type::Array;
v.array_ = std::move(a);
return v;
}
Value Value::makeObject(std::map<std::string, Value> 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<std::string, Value> 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<Value> 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<std::size_t>(i)];
unsigned digit;
if (c >= '0' && c <= '9')
digit = static_cast<unsigned>(c - '0');
else if (c >= 'a' && c <= 'f')
digit = static_cast<unsigned>(c - 'a') + 10u;
else if (c >= 'A' && c <= 'F')
digit = static_cast<unsigned>(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<char>(cp));
} else if (cp < 0x800) {
out.push_back(static_cast<char>(0xC0u | (cp >> 6)));
out.push_back(static_cast<char>(0x80u | (cp & 0x3Fu)));
} else if (cp < 0x10000) {
out.push_back(static_cast<char>(0xE0u | (cp >> 12)));
out.push_back(static_cast<char>(0x80u | ((cp >> 6) & 0x3Fu)));
out.push_back(static_cast<char>(0x80u | (cp & 0x3Fu)));
} else {
out.push_back(static_cast<char>(0xF0u | (cp >> 18)));
out.push_back(static_cast<char>(0x80u | ((cp >> 12) & 0x3Fu)));
out.push_back(static_cast<char>(0x80u | ((cp >> 6) & 0x3Fu)));
out.push_back(static_cast<char>(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<unsigned char>(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<char>(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
+21 -11
View File
@@ -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)
+246
View File
@@ -0,0 +1,246 @@
/*
streamer-tools OBS Camera Plugin - minimal loopback HTTP server for tests
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
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 <https://www.gnu.org/licenses/>
*/
#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 <atomic>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
using st_socket_t = SOCKET;
#define ST_INVALID_SOCKET INVALID_SOCKET
#define ST_CLOSE_SOCKET closesocket
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
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<std::string(const std::string &request)>;
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<const char *>(&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<sockaddr *>(&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<sockaddr *>(&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<std::mutex> 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<int>(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<std::mutex> 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<int>(sizeof(buffer)), 0);
#else
const ssize_t n = ::recv(client, buffer, sizeof(buffer), 0);
#endif
if (n <= 0)
break;
data.append(buffer, static_cast<std::size_t>(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<std::size_t>(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<char>(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<int>(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<std::size_t>(n);
}
}
LoopbackHandler handler_;
st_socket_t listen_ = ST_INVALID_SOCKET;
int port_ = 0;
std::thread thread_;
std::atomic<bool> stop_{false};
std::atomic<int> 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
+434
View File
@@ -0,0 +1,434 @@
/*
streamer-tools OBS Camera Plugin - API client tests
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
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 <https://www.gnu.org/licenses/>
*/
// 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 <chrono>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#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<FakeHttpClient> makeFake(long status, const std::string &body)
{
auto fake = std::make_shared<FakeHttpClient>();
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, "<html>502 Bad Gateway</html>"));
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":[)",
"<!DOCTYPE html><html>login page</html>",
};
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<FakeHttpClient>();
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<HttpClient> 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<std::chrono::seconds>(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<HttpClient> 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<std::chrono::milliseconds>(elapsed).count() < 4000);
}
} // namespace
int main()
{
testNormalizeServerUrl();
testUrlEncodeAndRedaction();
testRequestShape();
testSlotsHappyPath();
testSlotsEdgeCases();
testTokenHappyPath();
testHttpErrorStatuses();
testMalformedSuccessBodies();
testNetworkErrorAndInvalidConfig();
testPlatformBackendAgainstLoopback();
testPlatformBackendTimeout();
return st_test_report("api_client");
}
+163
View File
@@ -0,0 +1,163 @@
/*
streamer-tools OBS Camera Plugin - JSON reader tests
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
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 <https://www.gnu.org/licenses/>
*/
#include <string>
#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
"<!DOCTYPE html><html><body>502 Bad Gateway</body></html>",
};
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");
}