diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e31966..7ac05f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,11 +26,11 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libcurl4-openssl-dev libssl-dev + sudo apt-get install -y libcurl4-openssl-dev libssl-dev nlohmann-json3-dev - name: Install dependencies (macOS) if: runner.os == 'macOS' - run: brew install openssl@3 + run: brew install openssl@3 nlohmann-json - name: Install OpenSSL (Windows) if: runner.os == 'Windows' @@ -42,7 +42,7 @@ jobs: if: runner.os == 'Windows' shell: bash run: | - "$VCPKG_INSTALLATION_ROOT/vcpkg" install --triplet x64-windows curl + "$VCPKG_INSTALLATION_ROOT/vcpkg" install --triplet x64-windows curl nlohmann-json - name: Configure (Ubuntu) if: runner.os == 'Linux' @@ -69,3 +69,32 @@ jobs: - name: Test run: ctest --test-dir build --output-on-failure -C Release + + - name: Install package + run: cmake --install build --prefix "${{ github.workspace }}/install" --config Release + + - name: Configure consumer smoke (Ubuntu) + if: runner.os == 'Linux' + run: | + cmake -S tests/consumer_smoke -B consumer-build \ + -DCMAKE_PREFIX_PATH="${{ github.workspace }}/install" + + - name: Configure consumer smoke (macOS) + if: runner.os == 'macOS' + run: | + cmake -S tests/consumer_smoke -B consumer-build \ + -DCMAKE_PREFIX_PATH="${{ github.workspace }}/install" \ + -DOPENSSL_ROOT_DIR="$(brew --prefix openssl@3)" + + - name: Configure consumer smoke (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + cmake -S tests/consumer_smoke -B consumer-build \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_TARGET_TRIPLET=x64-windows \ + -DCMAKE_PREFIX_PATH="${{ github.workspace }}/install" \ + -DOPENSSL_ROOT_DIR="C:/Program Files/OpenSSL" + + - name: Build consumer smoke + run: cmake --build consumer-build --config Release diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f4d142..7cc14be 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,8 +11,14 @@ project(moonbase_cpp include(GNUInstallDirs) include(CMakePackageConfigHelpers) -option(MOONBASE_BUILD_TESTS "Build Moonbase C++ SDK tests" ON) -option(MOONBASE_BUILD_EXAMPLES "Build Moonbase C++ SDK examples" ON) +if(CMAKE_SOURCE_DIR STREQUAL PROJECT_SOURCE_DIR) + set(MOONBASE_IS_TOP_LEVEL ON) +else() + set(MOONBASE_IS_TOP_LEVEL OFF) +endif() + +option(MOONBASE_BUILD_TESTS "Build Moonbase C++ SDK tests" ${MOONBASE_IS_TOP_LEVEL}) +option(MOONBASE_BUILD_EXAMPLES "Build Moonbase C++ SDK examples" ${MOONBASE_IS_TOP_LEVEL}) option(MOONBASE_BUILD_JUCE_EXAMPLE "Build the JUCE bridge example (fetches JUCE; off by default)" OFF) @@ -90,6 +96,7 @@ if(MOONBASE_BUILD_TESTS) tests/main.cpp tests/client_tests.cpp tests/fingerprint_tests.cpp + tests/header_smoke_tests.cpp tests/licensing_tests.cpp tests/process_dedup_tests.cpp tests/store_tests.cpp diff --git a/README.md b/README.md index 022c790..507cea4 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,11 @@ The build provides three options, all useful when consuming the SDK as a subproj | Option | Default | Purpose | | --- | --- | --- | -| `MOONBASE_BUILD_TESTS` | `ON` | Build the doctest-based unit and live tests. | -| `MOONBASE_BUILD_EXAMPLES` | `ON` | Build the standalone activation example under `examples/`. | +| `MOONBASE_BUILD_TESTS` | `ON` for the top-level project, `OFF` as a subproject | Build the doctest-based unit and live tests. | +| `MOONBASE_BUILD_EXAMPLES` | `ON` for the top-level project, `OFF` as a subproject | Build the standalone activation example under `examples/`. | | `MOONBASE_BUILD_JUCE_EXAMPLE` | `OFF` | Fetch JUCE and build the JUCE bridge example (see below). | -Set `MOONBASE_BUILD_TESTS` and `MOONBASE_BUILD_EXAMPLES` to `OFF` when integrating via `add_subdirectory` or `FetchContent` to avoid building artifacts you don't need. +Override `MOONBASE_BUILD_TESTS` and `MOONBASE_BUILD_EXAMPLES` explicitly when you want a subproject integration to build SDK artifacts too. ## Basic Usage @@ -72,6 +72,8 @@ options.endpoint = "https://demo.moonbase.sh"; options.product_id = "demo-app"; options.public_key = public_key_pem; options.account_id = "tenant-id"; // optional issuer check +options.http_connect_timeout = std::chrono::seconds(10); +options.http_request_timeout = std::chrono::seconds(30); moonbase::licensing licensing(options); @@ -156,7 +158,9 @@ The default fingerprint provider builds a stable, native hardware fingerprint from platform identity parameters such as SMBIOS fields on Windows, `IOPlatformUUID` on macOS, and board/BIOS/CPU fields on Linux. Use a custom `fingerprint_provider` when you need an exact legacy fingerprint or any other -application-specific device ID. +application-specific device ID. If you include narrow SDK headers instead of +``, include `` for the +native provider and `` for the default CURL transport. ## JUCE Plugins diff --git a/docs/juce.md b/docs/juce.md index 15beacf..a5b42ab 100644 --- a/docs/juce.md +++ b/docs/juce.md @@ -307,8 +307,8 @@ through the existing `PropertiesFile` you used for the JUCE flow. ## CMake note -The SDK's standard build (`MOONBASE_BUILD_EXAMPLES=ON`) does not pull in -JUCE — only the small `examples/activation.cpp` is compiled. JUCE is only +The SDK's top-level example build (`MOONBASE_BUILD_EXAMPLES=ON`) does not pull +in JUCE — only the small `examples/activation.cpp` is compiled. JUCE is only fetched when you opt in with `-DMOONBASE_BUILD_JUCE_EXAMPLE=ON` (see above), or when you integrate `MoonbaseJuceBridge.h` into your own JUCE/Projucer/CMake project alongside `target_link_libraries(your_target diff --git a/include/moonbase/client.hpp b/include/moonbase/client.hpp index 19a1b72..163dbd0 100644 --- a/include/moonbase/client.hpp +++ b/include/moonbase/client.hpp @@ -166,6 +166,8 @@ class license_client { request.method = "POST"; request.url = url; request.headers = detail::default_headers("application/json"); + request.connect_timeout = options_.http_connect_timeout; + request.request_timeout = options_.http_request_timeout; request.body = payload.dump(); const auto response = transport_->send(request); @@ -192,6 +194,8 @@ class license_client { request.method = "POST"; request.url = url; request.headers = detail::default_headers("text/plain"); + request.connect_timeout = options_.http_connect_timeout; + request.request_timeout = options_.http_request_timeout; request.body = std::string(token); const auto response = transport_->send(request); @@ -211,6 +215,8 @@ class license_client { request.method = "POST"; request.url = url; request.headers = detail::default_headers("text/plain"); + request.connect_timeout = options_.http_connect_timeout; + request.request_timeout = options_.http_request_timeout; request.body = std::string(token); const auto response = transport_->send(request); @@ -226,6 +232,8 @@ class license_client { request.method = "GET"; request.url = activation.request_url; request.headers = detail::default_headers(); + request.connect_timeout = options_.http_connect_timeout; + request.request_timeout = options_.http_request_timeout; const auto response = transport_->send(request); if (response.status_code == 204 || response.status_code == 404) { diff --git a/include/moonbase/default_fingerprint.hpp b/include/moonbase/default_fingerprint.hpp new file mode 100644 index 0000000..4f85640 --- /dev/null +++ b/include/moonbase/default_fingerprint.hpp @@ -0,0 +1,389 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#endif + +#include + +#include "moonbase/fingerprint.hpp" + +namespace moonbase { + +class default_fingerprint_provider : public fingerprint_provider { +public: + using identity_parameter = std::pair; + + [[nodiscard]] static std::string platform_tag() + { +#if defined(__APPLE__) + return "mac"; +#elif defined(_WIN32) + return "windows"; +#elif defined(__ANDROID__) + return "android"; +#elif defined(__linux__) + return "linux"; +#elif defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) || defined(__DragonFly__) + return "bsd"; +#else + return "unknown"; +#endif + } + + [[nodiscard]] static std::string hash_identity_parameters( + const std::vector& parameters, + std::string_view platform = platform_tag()) + { + std::string material; + material += "moonbase-cpp:fingerprint:v1\n"; + material += "platform="; + material.append(platform.data(), platform.size()); + material += "\n"; + + for (const auto& parameter : parameters) { + auto name = trim_ascii(parameter.first); + auto value = trim_ascii(parameter.second); + if (!name.empty() && !value.empty()) { + material += name; + material += "="; + material += value; + material += "\n"; + } + } + + return sha256_hex(material); + } + + [[nodiscard]] static std::vector identity_parameters() + { + std::vector parameters; + +#if defined(_WIN32) + append_windows_identity_parameters(parameters); +#elif defined(__APPLE__) + auto uuid = trim_ascii(command_output( + "ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | " + "awk -F\\\" '/IOPlatformUUID/{print $4; exit}'")); + uuid.erase(std::remove(uuid.begin(), uuid.end(), '-'), uuid.end()); + append_parameter(parameters, "ioPlatformUuid", uuid); +#elif defined(__linux__) && !defined(__ANDROID__) + const auto board_serial = trim_ascii(read_file("/sys/class/dmi/id/board_serial")); + if (!board_serial.empty()) { + append_parameter(parameters, "boardSerial", board_serial); + } else { + append_parameter(parameters, "biosDate", read_file("/sys/class/dmi/id/bios_date")); + append_parameter(parameters, "biosRelease", read_file("/sys/class/dmi/id/bios_release")); + append_parameter(parameters, "biosVendor", read_file("/sys/class/dmi/id/bios_vendor")); + append_parameter(parameters, "biosVersion", read_file("/sys/class/dmi/id/bios_version")); + } + + const auto cpu_data = command_output("lscpu 2>/dev/null"); + if (!cpu_data.empty()) { + append_parameter(parameters, "cpuFamily", linux_cpu_field(cpu_data, "CPU family:")); + append_parameter(parameters, "cpuModel", linux_cpu_field(cpu_data, "Model:")); + append_parameter(parameters, "cpuModelName", linux_cpu_field(cpu_data, "Model name:")); + append_parameter(parameters, "cpuVendor", linux_cpu_field(cpu_data, "Vendor ID:")); + } +#endif + + return parameters; + } + + [[nodiscard]] std::string device_name() const override + { +#if defined(_WIN32) + char buffer[128]{}; + DWORD size = static_cast(sizeof(buffer)) - 1; + if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer, &size)) { + return std::string(buffer, size); + } + return {}; +#else + std::array buffer{}; + if (gethostname(buffer.data(), buffer.size() - 1) == 0) { + auto name = std::string(buffer.data()); +#if defined(__APPLE__) + const auto suffix = std::string(".local"); + if (name.size() >= suffix.size()) { + auto tail = name.substr(name.size() - suffix.size()); + std::transform(tail.begin(), tail.end(), tail.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (tail == suffix) { + name.erase(name.size() - suffix.size()); + } + } +#endif + return name; + } + return {}; +#endif + } + + [[nodiscard]] std::string device_id() const override + { + auto parameters = identity_parameters(); + if (parameters.empty()) { + append_parameter(parameters, "deviceName", device_name()); + } + return hash_identity_parameters(parameters); + } + +private: + [[nodiscard]] static std::string sha256_hex(std::string_view material) + { + unsigned char digest[SHA256_DIGEST_LENGTH]{}; + SHA256( + reinterpret_cast(material.data()), + material.size(), + digest); + + std::ostringstream output; + output << std::hex << std::setfill('0'); + for (const auto byte : digest) { + output << std::setw(2) << static_cast(byte); + } + return output.str(); + } + + [[nodiscard]] static std::string read_file(const std::string& path) + { + std::ifstream file(path); + if (!file) { + return {}; + } + std::ostringstream out; + out << file.rdbuf(); + return out.str(); + } + + [[nodiscard]] static std::string command_output(const std::string& command) + { +#if defined(_WIN32) + (void)command; + return {}; +#else + std::array buffer{}; + std::string result; + std::unique_ptr pipe(popen(command.c_str(), "r"), pclose); + if (!pipe) { + return {}; + } + while (fgets(buffer.data(), static_cast(buffer.size()), pipe.get()) != nullptr) { + result += buffer.data(); + } + return result; +#endif + } + + [[nodiscard]] static std::string trim_ascii(std::string value) + { + while (!value.empty() && + (value.back() == '\n' || value.back() == '\r' || value.back() == ' ' || value.back() == '\t')) { + value.pop_back(); + } + while (!value.empty() && + (value.front() == '\n' || value.front() == '\r' || value.front() == ' ' || value.front() == '\t')) { + value.erase(value.begin()); + } + return value; + } + + [[nodiscard]] static std::string linux_cpu_field(const std::string& lscpu_output, const std::string& key) + { + const auto key_index = lscpu_output.find(key); + if (key_index == std::string::npos) { + return {}; + } + + const auto colon = lscpu_output.find(':', key_index); + if (colon == std::string::npos) { + return {}; + } + + const auto end = lscpu_output.find('\n', colon); + return trim_ascii(lscpu_output.substr( + colon + 1, + end == std::string::npos ? std::string::npos : end - colon - 1)); + } + + static void append_parameter( + std::vector& parameters, + std::string name, + std::string value) + { + name = trim_ascii(std::move(name)); + value = trim_ascii(std::move(value)); + if (!name.empty() && !value.empty()) { + parameters.emplace_back(std::move(name), std::move(value)); + } + } + +#if defined(_WIN32) + [[nodiscard]] static std::string windows_string_from_offset( + const std::vector& content, + const std::vector& strings, + std::size_t byte_offset) + { + if (byte_offset >= content.size()) { + return {}; + } + + const auto index = static_cast(content[byte_offset]); + if (index == 0 || index > strings.size()) { + return {}; + } + + return std::string(strings[index - 1]); + } + + [[nodiscard]] static std::size_t windows_bounded_string_length(const char* value, std::size_t max_length) + { + std::size_t length = 0; + while (length < max_length && value[length] != '\0') { + ++length; + } + return length; + } + + static void append_windows_identity_parameters(std::vector& parameters) + { + constexpr DWORD signature = + static_cast('R') | + (static_cast('S') << 8U) | + (static_cast('M') << 16U) | + (static_cast('B') << 24U); + + const auto table_size = GetSystemFirmwareTable(signature, 0, nullptr, 0); + if (table_size == 0) { + return; + } + + std::vector smbios(table_size); + if (GetSystemFirmwareTable(signature, 0, smbios.data(), table_size) != table_size) { + return; + } + + struct raw_smbios_data { + std::uint8_t unused[4]; + std::uint32_t length; + }; + + struct smbios_header { + std::uint8_t id; + std::uint8_t length; + std::uint16_t handle; + }; + + if (smbios.size() < sizeof(raw_smbios_data)) { + return; + } + + raw_smbios_data raw{}; + std::memcpy(&raw, smbios.data(), sizeof(raw)); + if (smbios.size() < sizeof(raw_smbios_data) + raw.length) { + return; + } + + std::vector content( + smbios.begin() + static_cast(sizeof(raw_smbios_data)), + smbios.begin() + static_cast(sizeof(raw_smbios_data) + raw.length)); + + std::size_t offset = 0; + while (offset < content.size()) { + if (content.size() - offset < sizeof(smbios_header)) { + break; + } + + smbios_header header{}; + std::memcpy(&header, content.data() + offset, sizeof(header)); + if (header.length == 0 || content.size() - offset < header.length) { + break; + } + + std::vector strings; + auto string_offset = offset + header.length; + while (string_offset < content.size()) { + const auto* str = reinterpret_cast(content.data() + string_offset); + const auto max_length = content.size() - string_offset; + const auto length = windows_bounded_string_length(str, max_length); + if (length == 0) { + break; + } + strings.emplace_back(str, length); + string_offset += std::min(length + 1, max_length); + } + + const auto end_of_table = std::min( + content.size(), + std::max(offset + static_cast(header.length) + 2, string_offset + 1)); + + const auto from_offset = [&](std::size_t byte_offset) { + return windows_string_from_offset(content, strings, offset + byte_offset); + }; + + switch (header.id) { + case 1: { + append_parameter(parameters, "systemManufacturer", from_offset(0x04)); + append_parameter(parameters, "systemProductName", from_offset(0x05)); + + if (offset + 0x08 + 16 <= content.size()) { + std::ostringstream hex; + hex << std::uppercase << std::hex << std::setfill('0'); + for (std::size_t index = 0; index != 16; ++index) { + hex << std::setw(2) << static_cast(content[offset + 0x08 + index]); + } + append_parameter(parameters, "systemUuid", hex.str()); + } + break; + } + + case 2: + append_parameter(parameters, "baseboardManufacturer", from_offset(0x04)); + append_parameter(parameters, "baseboardProduct", from_offset(0x05)); + append_parameter(parameters, "baseboardVersion", from_offset(0x06)); + append_parameter(parameters, "baseboardSerialNumber", from_offset(0x07)); + append_parameter(parameters, "baseboardAssetTag", from_offset(0x08)); + break; + + case 4: + append_parameter(parameters, "processorManufacturer", from_offset(0x07)); + append_parameter(parameters, "processorVersion", from_offset(0x10)); + append_parameter(parameters, "processorAssetTag", from_offset(0x21)); + append_parameter(parameters, "processorPartNumber", from_offset(0x22)); + break; + + default: + break; + } + + offset = end_of_table; + } + } +#endif +}; + +} // namespace moonbase diff --git a/include/moonbase/detail/file_lock.hpp b/include/moonbase/detail/file_lock.hpp index 9dcaaa3..fc99e62 100644 --- a/include/moonbase/detail/file_lock.hpp +++ b/include/moonbase/detail/file_lock.hpp @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include @@ -9,24 +11,16 @@ #include "moonbase/errors.hpp" #if defined(_WIN32) +#include #include -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -// store.hpp pulls this header in via lock_for_update(), so any consumer of -// the public moonbase store API transitively sees . Without -// NOMINMAX, Win32 would inject min/max macros and clobber std::min/std::max -// in their translation units. -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include +#include +#include +#include #else #include #include #include #include -#include #endif namespace moonbase::detail { @@ -41,9 +35,10 @@ namespace moonbase::detail { // POSIX: flock(LOCK_EX) on the file's descriptor. Locks are per-open-file // description and cooperative; only callers that go through this class // observe each other. -// Windows: LockFileEx(LOCKFILE_EXCLUSIVE_LOCK) on a single-byte range — -// the range is required by the API but otherwise arbitrary; all callers -// using this class lock the same range. +// Windows: CRT _locking(_LK_LOCK) on a single-byte range. The range is +// required by the API but otherwise arbitrary; all callers using this class +// lock the same range. This intentionally avoids in public SDK +// headers because store.hpp includes this detail header. // // Throws moonbase::storage_error on unrecoverable open/lock failures. class file_lock { @@ -60,34 +55,45 @@ class file_lock { } #if defined(_WIN32) - handle_ = ::CreateFileW( + auto fd = -1; + const auto open_error = ::_wsopen_s( + &fd, path.wstring().c_str(), - GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - nullptr, - OPEN_ALWAYS, - FILE_ATTRIBUTE_NORMAL, - nullptr); - if (handle_ == INVALID_HANDLE_VALUE) { + _O_RDWR | _O_CREAT | _O_BINARY, + _SH_DENYNO, + _S_IREAD | _S_IWRITE); + if (open_error != 0) { + throw storage_error( + "Could not open license lock file: " + + std::string(std::strerror(open_error))); + } + fd_ = fd; + + const auto size_error = ::_chsize_s(fd_, lock_range_size_); + if (size_error != 0) { + ::_close(fd_); + fd_ = -1; + throw storage_error( + "Could not prepare license lock file: " + + std::string(std::strerror(size_error))); + } + + if (::_lseek(fd_, 0, SEEK_SET) < 0) { + const auto err = errno; + ::_close(fd_); + fd_ = -1; throw storage_error( - "Could not open license lock file (error " - + std::to_string(::GetLastError()) + ")"); + "Could not seek license lock file: " + + std::string(std::strerror(err))); } - OVERLAPPED overlapped{}; - if (!::LockFileEx( - handle_, - LOCKFILE_EXCLUSIVE_LOCK, - 0, - lock_range_low_, - lock_range_high_, - &overlapped)) { - const auto err = ::GetLastError(); - ::CloseHandle(handle_); - handle_ = INVALID_HANDLE_VALUE; + if (::_locking(fd_, _LK_LOCK, lock_range_size_) != 0) { + const auto err = errno; + ::_close(fd_); + fd_ = -1; throw storage_error( - "Could not acquire license file lock (error " - + std::to_string(err) + ")"); + "Could not acquire license file lock: " + + std::string(std::strerror(err))); } #else fd_ = ::open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0644); @@ -113,26 +119,16 @@ class file_lock { file_lock(file_lock&& other) noexcept { -#if defined(_WIN32) - handle_ = other.handle_; - other.handle_ = INVALID_HANDLE_VALUE; -#else fd_ = other.fd_; other.fd_ = -1; -#endif } file_lock& operator=(file_lock&& other) noexcept { if (this != &other) { release(); -#if defined(_WIN32) - handle_ = other.handle_; - other.handle_ = INVALID_HANDLE_VALUE; -#else fd_ = other.fd_; other.fd_ = -1; -#endif } return *this; } @@ -143,11 +139,11 @@ class file_lock { void release() noexcept { #if defined(_WIN32) - if (handle_ != INVALID_HANDLE_VALUE) { - OVERLAPPED overlapped{}; - ::UnlockFileEx(handle_, 0, lock_range_low_, lock_range_high_, &overlapped); - ::CloseHandle(handle_); - handle_ = INVALID_HANDLE_VALUE; + if (fd_ >= 0) { + ::_lseek(fd_, 0, SEEK_SET); + ::_locking(fd_, _LK_UNLCK, lock_range_size_); + ::_close(fd_); + fd_ = -1; } #else if (fd_ >= 0) { @@ -159,12 +155,9 @@ class file_lock { } #if defined(_WIN32) - HANDLE handle_ = INVALID_HANDLE_VALUE; - static constexpr DWORD lock_range_low_ = 1; - static constexpr DWORD lock_range_high_ = 0; -#else - int fd_ = -1; + static constexpr long lock_range_size_ = 1; #endif + int fd_ = -1; }; } // namespace moonbase::detail diff --git a/include/moonbase/detail/time.hpp b/include/moonbase/detail/time.hpp index af92f67..d9b37fb 100644 --- a/include/moonbase/detail/time.hpp +++ b/include/moonbase/detail/time.hpp @@ -1,7 +1,8 @@ #pragma once #include -#include +#include +#include #include #include #include @@ -30,23 +31,97 @@ inline long long to_epoch_seconds(std::chrono::system_clock::time_point time) time.time_since_epoch()).count(); } +inline int parse_fixed_digits(const std::string& value, std::size_t offset, std::size_t count) +{ + if (offset + count > value.size()) { + throw std::runtime_error("Invalid ISO-8601 timestamp"); + } + + int result = 0; + for (std::size_t index = 0; index != count; ++index) { + const auto c = value[offset + index]; + if (!std::isdigit(static_cast(c))) { + throw std::runtime_error("Invalid ISO-8601 timestamp"); + } + result = (result * 10) + (c - '0'); + } + return result; +} + +inline void require_timestamp_char(const std::string& value, std::size_t offset, char expected) +{ + if (offset >= value.size() || value[offset] != expected) { + throw std::runtime_error("Invalid ISO-8601 timestamp"); + } +} + +inline bool utc_fields_match( + std::time_t epoch, + int year, + int month, + int day, + int hour, + int minute, + int second) +{ + std::tm normalized{}; +#if defined(_WIN32) + if (gmtime_s(&normalized, &epoch) != 0) { + return false; + } +#else + if (gmtime_r(&epoch, &normalized) == nullptr) { + return false; + } +#endif + + return normalized.tm_year == year - 1900 && + normalized.tm_mon == month - 1 && + normalized.tm_mday == day && + normalized.tm_hour == hour && + normalized.tm_min == minute && + normalized.tm_sec == second; +} + inline std::chrono::system_clock::time_point parse_iso8601_utc(const std::string& value) { - int year = 0; - int month = 0; - int day = 0; - int hour = 0; - int minute = 0; - int second = 0; - if (std::sscanf( - value.c_str(), - "%d-%d-%dT%d:%d:%d", - &year, - &month, - &day, - &hour, - &minute, - &second) != 6) { + if (value.size() < 20) { + throw std::runtime_error("Invalid ISO-8601 timestamp"); + } + + const auto year = parse_fixed_digits(value, 0, 4); + require_timestamp_char(value, 4, '-'); + const auto month = parse_fixed_digits(value, 5, 2); + require_timestamp_char(value, 7, '-'); + const auto day = parse_fixed_digits(value, 8, 2); + require_timestamp_char(value, 10, 'T'); + const auto hour = parse_fixed_digits(value, 11, 2); + require_timestamp_char(value, 13, ':'); + const auto minute = parse_fixed_digits(value, 14, 2); + require_timestamp_char(value, 16, ':'); + const auto second = parse_fixed_digits(value, 17, 2); + + auto cursor = std::size_t{19}; + if (cursor < value.size() && value[cursor] == '.') { + ++cursor; + const auto fraction_start = cursor; + while (cursor < value.size() && std::isdigit(static_cast(value[cursor]))) { + ++cursor; + } + if (cursor == fraction_start) { + throw std::runtime_error("Invalid ISO-8601 timestamp"); + } + } + + if (cursor >= value.size() || value[cursor] != 'Z' || cursor + 1 != value.size()) { + throw std::runtime_error("Invalid ISO-8601 timestamp"); + } + + if (month < 1 || month > 12 || + day < 1 || day > 31 || + hour < 0 || hour > 23 || + minute < 0 || minute > 59 || + second < 0 || second > 59) { throw std::runtime_error("Invalid ISO-8601 timestamp"); } @@ -60,7 +135,7 @@ inline std::chrono::system_clock::time_point parse_iso8601_utc(const std::string tm.tm_isdst = 0; const auto epoch = timegm_utc(&tm); - if (epoch == static_cast(-1)) { + if (!utc_fields_match(epoch, year, month, day, hour, minute, second)) { throw std::runtime_error("Invalid UTC timestamp"); } return std::chrono::system_clock::from_time_t(epoch); diff --git a/include/moonbase/errors.hpp b/include/moonbase/errors.hpp index bb6312b..e7df001 100644 --- a/include/moonbase/errors.hpp +++ b/include/moonbase/errors.hpp @@ -2,6 +2,7 @@ #include #include +#include namespace moonbase { diff --git a/include/moonbase/fingerprint.hpp b/include/moonbase/fingerprint.hpp index 9613fd8..9d511ab 100644 --- a/include/moonbase/fingerprint.hpp +++ b/include/moonbase/fingerprint.hpp @@ -1,30 +1,7 @@ #pragma once -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include -#include #include -#include - -#if defined(_WIN32) -#define NOMINMAX -#include -#else -#include -#endif - -#include namespace moonbase { @@ -50,358 +27,4 @@ class static_fingerprint_provider : public fingerprint_provider { std::string id_; }; -class default_fingerprint_provider : public fingerprint_provider { -public: - using identity_parameter = std::pair; - - [[nodiscard]] static std::string platform_tag() - { -#if defined(__APPLE__) - return "mac"; -#elif defined(_WIN32) - return "windows"; -#elif defined(__ANDROID__) - return "android"; -#elif defined(__linux__) - return "linux"; -#elif defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) || defined(__DragonFly__) - return "bsd"; -#else - return "unknown"; -#endif - } - - [[nodiscard]] static std::string hash_identity_parameters( - const std::vector& parameters, - std::string_view platform = platform_tag()) - { - std::string material; - material += "moonbase-cpp:fingerprint:v1\n"; - material += "platform="; - material.append(platform.data(), platform.size()); - material += "\n"; - - for (const auto& parameter : parameters) { - auto name = trim_ascii(parameter.first); - auto value = trim_ascii(parameter.second); - if (!name.empty() && !value.empty()) { - material += name; - material += "="; - material += value; - material += "\n"; - } - } - - return sha256_hex(material); - } - - [[nodiscard]] static std::vector identity_parameters() - { - std::vector parameters; - -#if defined(_WIN32) - append_windows_identity_parameters(parameters); -#elif defined(__APPLE__) - auto uuid = trim_ascii(command_output( - "ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | " - "awk -F\\\" '/IOPlatformUUID/{print $4; exit}'")); - uuid.erase(std::remove(uuid.begin(), uuid.end(), '-'), uuid.end()); - append_parameter(parameters, "ioPlatformUuid", uuid); -#elif defined(__linux__) && !defined(__ANDROID__) - const auto board_serial = trim_ascii(read_file("/sys/class/dmi/id/board_serial")); - if (!board_serial.empty()) { - append_parameter(parameters, "boardSerial", board_serial); - } else { - append_parameter(parameters, "biosDate", read_file("/sys/class/dmi/id/bios_date")); - append_parameter(parameters, "biosRelease", read_file("/sys/class/dmi/id/bios_release")); - append_parameter(parameters, "biosVendor", read_file("/sys/class/dmi/id/bios_vendor")); - append_parameter(parameters, "biosVersion", read_file("/sys/class/dmi/id/bios_version")); - } - - const auto cpu_data = command_output("lscpu 2>/dev/null"); - if (!cpu_data.empty()) { - append_parameter(parameters, "cpuFamily", linux_cpu_field(cpu_data, "CPU family:")); - append_parameter(parameters, "cpuModel", linux_cpu_field(cpu_data, "Model:")); - append_parameter(parameters, "cpuModelName", linux_cpu_field(cpu_data, "Model name:")); - append_parameter(parameters, "cpuVendor", linux_cpu_field(cpu_data, "Vendor ID:")); - } -#endif - - return parameters; - } - - [[nodiscard]] std::string device_name() const override - { -#if defined(_WIN32) - char buffer[128]{}; - DWORD size = static_cast(sizeof(buffer)) - 1; - if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer, &size)) { - return std::string(buffer, size); - } - return {}; -#else - std::array buffer{}; - if (gethostname(buffer.data(), buffer.size() - 1) == 0) { - auto name = std::string(buffer.data()); -#if defined(__APPLE__) - const auto suffix = std::string(".local"); - if (name.size() >= suffix.size()) { - auto tail = name.substr(name.size() - suffix.size()); - std::transform(tail.begin(), tail.end(), tail.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - if (tail == suffix) { - name.erase(name.size() - suffix.size()); - } - } -#endif - return name; - } - return {}; -#endif - } - - [[nodiscard]] std::string device_id() const override - { - auto parameters = identity_parameters(); - if (parameters.empty()) { - append_parameter(parameters, "deviceName", device_name()); - } - return hash_identity_parameters(parameters); - } - -private: - [[nodiscard]] static std::string sha256_hex(std::string_view material) - { - unsigned char digest[SHA256_DIGEST_LENGTH]{}; - SHA256( - reinterpret_cast(material.data()), - material.size(), - digest); - - std::ostringstream output; - output << std::hex << std::setfill('0'); - for (const auto byte : digest) { - output << std::setw(2) << static_cast(byte); - } - return output.str(); - } - - [[nodiscard]] static std::string read_file(const std::string& path) - { - std::ifstream file(path); - if (!file) { - return {}; - } - std::ostringstream out; - out << file.rdbuf(); - return out.str(); - } - - [[nodiscard]] static std::string command_output(const std::string& command) - { -#if defined(_WIN32) - (void)command; - return {}; -#else - std::array buffer{}; - std::string result; - std::unique_ptr pipe(popen(command.c_str(), "r"), pclose); - if (!pipe) { - return {}; - } - while (fgets(buffer.data(), static_cast(buffer.size()), pipe.get()) != nullptr) { - result += buffer.data(); - } - return result; -#endif - } - - [[nodiscard]] static std::string trim_ascii(std::string value) - { - while (!value.empty() && - (value.back() == '\n' || value.back() == '\r' || value.back() == ' ' || value.back() == '\t')) { - value.pop_back(); - } - while (!value.empty() && - (value.front() == '\n' || value.front() == '\r' || value.front() == ' ' || value.front() == '\t')) { - value.erase(value.begin()); - } - return value; - } - - [[nodiscard]] static std::string linux_cpu_field(const std::string& lscpu_output, const std::string& key) - { - const auto key_index = lscpu_output.find(key); - if (key_index == std::string::npos) { - return {}; - } - - const auto colon = lscpu_output.find(':', key_index); - if (colon == std::string::npos) { - return {}; - } - - const auto end = lscpu_output.find('\n', colon); - return trim_ascii(lscpu_output.substr( - colon + 1, - end == std::string::npos ? std::string::npos : end - colon - 1)); - } - - static void append_parameter( - std::vector& parameters, - std::string name, - std::string value) - { - name = trim_ascii(std::move(name)); - value = trim_ascii(std::move(value)); - if (!name.empty() && !value.empty()) { - parameters.emplace_back(std::move(name), std::move(value)); - } - } - -#if defined(_WIN32) - [[nodiscard]] static std::string windows_string_from_offset( - const std::vector& content, - const std::vector& strings, - std::size_t byte_offset) - { - if (byte_offset >= content.size()) { - return {}; - } - - const auto index = static_cast(content[byte_offset]); - if (index == 0 || index > strings.size()) { - return {}; - } - - return std::string(strings[index - 1]); - } - - [[nodiscard]] static std::size_t windows_bounded_string_length(const char* value, std::size_t max_length) - { - std::size_t length = 0; - while (length < max_length && value[length] != '\0') { - ++length; - } - return length; - } - - static void append_windows_identity_parameters(std::vector& parameters) - { - constexpr DWORD signature = - static_cast('R') | - (static_cast('S') << 8U) | - (static_cast('M') << 16U) | - (static_cast('B') << 24U); - - const auto table_size = GetSystemFirmwareTable(signature, 0, nullptr, 0); - if (table_size == 0) { - return; - } - - std::vector smbios(table_size); - if (GetSystemFirmwareTable(signature, 0, smbios.data(), table_size) != table_size) { - return; - } - - struct raw_smbios_data { - std::uint8_t unused[4]; - std::uint32_t length; - }; - - struct smbios_header { - std::uint8_t id; - std::uint8_t length; - std::uint16_t handle; - }; - - if (smbios.size() < sizeof(raw_smbios_data)) { - return; - } - - raw_smbios_data raw{}; - std::memcpy(&raw, smbios.data(), sizeof(raw)); - if (smbios.size() < sizeof(raw_smbios_data) + raw.length) { - return; - } - - std::vector content( - smbios.begin() + static_cast(sizeof(raw_smbios_data)), - smbios.begin() + static_cast(sizeof(raw_smbios_data) + raw.length)); - - std::size_t offset = 0; - while (offset < content.size()) { - if (content.size() - offset < sizeof(smbios_header)) { - break; - } - - smbios_header header{}; - std::memcpy(&header, content.data() + offset, sizeof(header)); - if (header.length == 0 || content.size() - offset < header.length) { - break; - } - - std::vector strings; - auto string_offset = offset + header.length; - while (string_offset < content.size()) { - const auto* str = reinterpret_cast(content.data() + string_offset); - const auto max_length = content.size() - string_offset; - const auto length = windows_bounded_string_length(str, max_length); - if (length == 0) { - break; - } - strings.emplace_back(str, length); - string_offset += std::min(length + 1, max_length); - } - - const auto end_of_table = std::min( - content.size(), - std::max(offset + static_cast(header.length) + 2, string_offset + 1)); - - const auto from_offset = [&](std::size_t byte_offset) { - return windows_string_from_offset(content, strings, offset + byte_offset); - }; - - switch (header.id) { - case 1: { - append_parameter(parameters, "systemManufacturer", from_offset(0x04)); - append_parameter(parameters, "systemProductName", from_offset(0x05)); - - if (offset + 0x08 + 16 <= content.size()) { - std::ostringstream hex; - hex << std::uppercase << std::hex << std::setfill('0'); - for (std::size_t index = 0; index != 16; ++index) { - hex << std::setw(2) << static_cast(content[offset + 0x08 + index]); - } - append_parameter(parameters, "systemUuid", hex.str()); - } - break; - } - - case 2: - append_parameter(parameters, "baseboardManufacturer", from_offset(0x04)); - append_parameter(parameters, "baseboardProduct", from_offset(0x05)); - append_parameter(parameters, "baseboardVersion", from_offset(0x06)); - append_parameter(parameters, "baseboardSerialNumber", from_offset(0x07)); - append_parameter(parameters, "baseboardAssetTag", from_offset(0x08)); - break; - - case 4: - append_parameter(parameters, "processorManufacturer", from_offset(0x07)); - append_parameter(parameters, "processorVersion", from_offset(0x10)); - append_parameter(parameters, "processorAssetTag", from_offset(0x21)); - append_parameter(parameters, "processorPartNumber", from_offset(0x22)); - break; - - default: - break; - } - - offset = end_of_table; - } - } -#endif -}; - } // namespace moonbase diff --git a/include/moonbase/http.hpp b/include/moonbase/http.hpp index 7b83002..5dc2e86 100644 --- a/include/moonbase/http.hpp +++ b/include/moonbase/http.hpp @@ -1,14 +1,8 @@ #pragma once +#include #include -#include -#include #include -#include - -#include - -#include "moonbase/errors.hpp" namespace moonbase { @@ -16,6 +10,8 @@ struct http_request { std::string method = "GET"; std::string url; std::map headers; + std::chrono::milliseconds connect_timeout{0}; + std::chrono::milliseconds request_timeout{0}; std::string body; }; @@ -31,90 +27,4 @@ class http_transport { [[nodiscard]] virtual http_response send(const http_request& request) = 0; }; -class curl_http_transport : public http_transport { -public: - curl_http_transport() - { - static const int init = [] { - curl_global_init(CURL_GLOBAL_DEFAULT); - return 0; - }(); - (void)init; - } - - [[nodiscard]] http_response send(const http_request& request) override - { - std::unique_ptr curl(curl_easy_init(), curl_easy_cleanup); - if (!curl) { - throw api_error(0, "Could not initialize curl"); - } - - http_response response; - curl_easy_setopt(curl.get(), CURLOPT_URL, request.url.c_str()); - curl_easy_setopt(curl.get(), CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, &curl_http_transport::write_body); - curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &response.body); - curl_easy_setopt(curl.get(), CURLOPT_HEADERFUNCTION, &curl_http_transport::write_header); - curl_easy_setopt(curl.get(), CURLOPT_HEADERDATA, &response.headers); - - if (request.method == "POST") { - curl_easy_setopt(curl.get(), CURLOPT_POST, 1L); - curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, request.body.c_str()); - curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDSIZE, request.body.size()); - } else if (request.method != "GET") { - curl_easy_setopt(curl.get(), CURLOPT_CUSTOMREQUEST, request.method.c_str()); - if (!request.body.empty()) { - curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, request.body.c_str()); - curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDSIZE, request.body.size()); - } - } - - curl_slist* raw_headers = nullptr; - for (const auto& [key, value] : request.headers) { - const auto header = key + ": " + value; - raw_headers = curl_slist_append(raw_headers, header.c_str()); - } - std::unique_ptr headers(raw_headers, curl_slist_free_all); - if (headers) { - curl_easy_setopt(curl.get(), CURLOPT_HTTPHEADER, headers.get()); - } - - const auto result = curl_easy_perform(curl.get()); - if (result != CURLE_OK) { - throw api_error(0, curl_easy_strerror(result)); - } - curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &response.status_code); - return response; - } - -private: - static std::size_t write_body(char* ptr, std::size_t size, std::size_t nmemb, void* userdata) - { - const auto total = size * nmemb; - auto* body = static_cast(userdata); - body->append(ptr, total); - return total; - } - - static std::size_t write_header(char* buffer, std::size_t size, std::size_t nitems, void* userdata) - { - const auto total = size * nitems; - std::string line(buffer, total); - const auto separator = line.find(':'); - if (separator != std::string::npos) { - auto key = line.substr(0, separator); - auto value = line.substr(separator + 1); - while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { - value.erase(value.begin()); - } - while (!value.empty() && (value.back() == '\r' || value.back() == '\n')) { - value.pop_back(); - } - auto* headers = static_cast*>(userdata); - (*headers)[std::move(key)] = std::move(value); - } - return total; - } -}; - } // namespace moonbase diff --git a/include/moonbase/http_curl.hpp b/include/moonbase/http_curl.hpp new file mode 100644 index 0000000..eec0169 --- /dev/null +++ b/include/moonbase/http_curl.hpp @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "moonbase/errors.hpp" +#include "moonbase/http.hpp" + +namespace moonbase { + +class curl_http_transport : public http_transport { +public: + curl_http_transport() + { + static const auto init_result = curl_global_init(CURL_GLOBAL_DEFAULT); + if (init_result != CURLE_OK) { + throw api_error(0, curl_easy_strerror(init_result)); + } + } + + [[nodiscard]] http_response send(const http_request& request) override + { + std::unique_ptr curl(curl_easy_init(), curl_easy_cleanup); + if (!curl) { + throw api_error(0, "Could not initialize curl"); + } + + http_response response; + curl_easy_setopt(curl.get(), CURLOPT_URL, request.url.c_str()); + curl_easy_setopt(curl.get(), CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl.get(), CURLOPT_CONNECTTIMEOUT_MS, timeout_milliseconds(request.connect_timeout)); + curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT_MS, timeout_milliseconds(request.request_timeout)); + curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, &curl_http_transport::write_body); + curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &response.body); + curl_easy_setopt(curl.get(), CURLOPT_HEADERFUNCTION, &curl_http_transport::write_header); + curl_easy_setopt(curl.get(), CURLOPT_HEADERDATA, &response.headers); + + if (request.method == "POST") { + curl_easy_setopt(curl.get(), CURLOPT_POST, 1L); + curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, request.body.c_str()); + curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDSIZE, request.body.size()); + } else if (request.method != "GET") { + curl_easy_setopt(curl.get(), CURLOPT_CUSTOMREQUEST, request.method.c_str()); + if (!request.body.empty()) { + curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, request.body.c_str()); + curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDSIZE, request.body.size()); + } + } + + curl_slist* raw_headers = nullptr; + for (const auto& [key, value] : request.headers) { + const auto header = key + ": " + value; + raw_headers = curl_slist_append(raw_headers, header.c_str()); + } + std::unique_ptr headers(raw_headers, curl_slist_free_all); + if (headers) { + curl_easy_setopt(curl.get(), CURLOPT_HTTPHEADER, headers.get()); + } + + const auto result = curl_easy_perform(curl.get()); + if (result != CURLE_OK) { + throw api_error(0, curl_easy_strerror(result)); + } + curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &response.status_code); + return response; + } + +private: + static long timeout_milliseconds(std::chrono::milliseconds timeout) + { + if (timeout.count() <= 0) { + return 0L; + } + if (timeout.count() > std::numeric_limits::max()) { + throw api_error(0, "HTTP timeout is too large"); + } + return static_cast(timeout.count()); + } + + static std::size_t write_body(char* ptr, std::size_t size, std::size_t nmemb, void* userdata) + { + const auto total = size * nmemb; + auto* body = static_cast(userdata); + body->append(ptr, total); + return total; + } + + static std::size_t write_header(char* buffer, std::size_t size, std::size_t nitems, void* userdata) + { + const auto total = size * nitems; + std::string line(buffer, total); + const auto separator = line.find(':'); + if (separator != std::string::npos) { + auto key = line.substr(0, separator); + auto value = line.substr(separator + 1); + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { + value.erase(value.begin()); + } + while (!value.empty() && (value.back() == '\r' || value.back() == '\n')) { + value.pop_back(); + } + auto* headers = static_cast*>(userdata); + (*headers)[std::move(key)] = std::move(value); + } + return total; + } +}; + +} // namespace moonbase diff --git a/include/moonbase/licensing.hpp b/include/moonbase/licensing.hpp index 7547724..5b70e27 100644 --- a/include/moonbase/licensing.hpp +++ b/include/moonbase/licensing.hpp @@ -9,9 +9,11 @@ #include #include "moonbase/client.hpp" +#include "moonbase/default_fingerprint.hpp" #include "moonbase/errors.hpp" #include "moonbase/fingerprint.hpp" #include "moonbase/http.hpp" +#include "moonbase/http_curl.hpp" #include "moonbase/store.hpp" #include "moonbase/types.hpp" #include "moonbase/validator.hpp" diff --git a/include/moonbase/moonbase.hpp b/include/moonbase/moonbase.hpp index c4c729b..1cfc7f3 100644 --- a/include/moonbase/moonbase.hpp +++ b/include/moonbase/moonbase.hpp @@ -1,9 +1,11 @@ #pragma once #include "moonbase/client.hpp" +#include "moonbase/default_fingerprint.hpp" #include "moonbase/errors.hpp" #include "moonbase/fingerprint.hpp" #include "moonbase/http.hpp" +#include "moonbase/http_curl.hpp" #include "moonbase/licensing.hpp" #include "moonbase/store.hpp" #include "moonbase/types.hpp" diff --git a/include/moonbase/types.hpp b/include/moonbase/types.hpp index f0ee50c..8ddfaa1 100644 --- a/include/moonbase/types.hpp +++ b/include/moonbase/types.hpp @@ -3,23 +3,13 @@ #include #include #include -#include #include #include #include #include "moonbase/detail/time.hpp" - -// GCC predefines `linux` and `unix` as `1` on the respective platforms, which -// would mangle the `platform` enumerators below. Drop the legacy macros; modern -// code should use `__linux__` / `__unix__` instead. -#ifdef linux -#undef linux -#endif -#ifdef unix -#undef unix -#endif +#include "moonbase/errors.hpp" namespace moonbase { @@ -31,7 +21,7 @@ enum class activation_method { enum class platform { unknown, windows, - linux, + linux_os, mac, }; @@ -54,7 +44,7 @@ inline activation_method activation_method_from_string(const std::string& value) if (value == "Offline" || value == "offline") { return activation_method::offline; } - throw std::runtime_error("Unknown activation method: " + value); + throw license_invalid_error("Unknown activation method: " + value); } inline std::string to_string(platform value) @@ -62,7 +52,7 @@ inline std::string to_string(platform value) switch (value) { case platform::windows: return "Windows"; - case platform::linux: + case platform::linux_os: return "Linux"; case platform::mac: return "Mac"; @@ -79,7 +69,7 @@ inline platform current_platform() #elif defined(__APPLE__) return platform::mac; #elif defined(__linux__) - return platform::linux; + return platform::linux_os; #else return platform::unknown; #endif @@ -129,6 +119,8 @@ struct licensing_options { platform target_platform = current_platform(); std::optional application_version; std::map metadata; + std::chrono::milliseconds http_connect_timeout{std::chrono::seconds{10}}; + std::chrono::milliseconds http_request_timeout{std::chrono::seconds{30}}; std::chrono::seconds online_validation_grace_period{std::chrono::hours(24 * 7)}; std::chrono::seconds online_validation_min_interval{std::chrono::minutes(5)}; }; diff --git a/include/moonbase/validator.hpp b/include/moonbase/validator.hpp index 1f07602..d504fc4 100644 --- a/include/moonbase/validator.hpp +++ b/include/moonbase/validator.hpp @@ -285,7 +285,12 @@ inline std::chrono::system_clock::time_point require_validation_time(const nlohm return from_epoch_seconds(*epoch); } if (auto iso = optional_string(payload, "ver")) { - return parse_iso8601_utc(*iso); + try { + return parse_iso8601_utc(*iso); + } catch (const std::exception& ex) { + throw license_invalid_error( + std::string("License token validation timestamp is malformed: ") + ex.what()); + } } throw license_invalid_error("License token is missing validation timestamp"); } @@ -342,10 +347,17 @@ class license_validator { } const auto signing_input = parts[0] + "." + parts[1]; + std::vector signature; + try { + signature = detail::base64url_decode(parts[2]); + } catch (const std::exception& ex) { + throw license_invalid_error( + std::string("License token signature is malformed: ") + ex.what()); + } detail::verify_rs256( key_.get(), signing_input, - detail::base64url_decode(parts[2])); + signature); if (!detail::has_audience(payload, options_.product_id)) { throw license_invalid_error("License token audience does not match the configured product"); diff --git a/tests/client_tests.cpp b/tests/client_tests.cpp index 45d3591..d14ed5f 100644 --- a/tests/client_tests.cpp +++ b/tests/client_tests.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -41,6 +42,8 @@ struct client_fixture { options.target_platform = platform::mac; options.application_version = "1.2.3"; options.metadata = {{"channel", "test"}}; + options.http_connect_timeout = std::chrono::milliseconds{1234}; + options.http_request_timeout = std::chrono::milliseconds{5678}; return options; } @@ -79,6 +82,8 @@ TEST_CASE("request_activation posts device information and parses response") CHECK(request.headers.at("Content-Type") == "application/json"); CHECK(request.headers.at("x-mb-client") == "moonbase-cpp"); CHECK(request.headers.at("User-Agent").find("moonbase-cpp/") == 0); + CHECK(request.connect_timeout == std::chrono::milliseconds{1234}); + CHECK(request.request_timeout == std::chrono::milliseconds{5678}); const auto body = nlohmann::json::parse(request.body); CHECK(body.at("deviceName") == "Test Device"); @@ -107,6 +112,8 @@ TEST_CASE("get_requested_activation returns nullopt while pending or missing") CHECK_FALSE(fixture.client.get_requested_activation(request).has_value()); REQUIRE(fixture.transport->requests.size() == 2); CHECK(fixture.transport->requests[0].method == "GET"); + CHECK(fixture.transport->requests[0].connect_timeout == std::chrono::milliseconds{1234}); + CHECK(fixture.transport->requests[0].request_timeout == std::chrono::milliseconds{5678}); } TEST_CASE("get_requested_activation validates fulfilled JWT response") @@ -157,6 +164,8 @@ TEST_CASE("validate_token_online posts the JWT and parses the refreshed response CHECK(request.url.find("meta%5Bchannel%5D=test") != std::string::npos); CHECK(request.headers.at("Content-Type") == "text/plain"); CHECK(request.headers.at("x-mb-client") == "moonbase-cpp"); + CHECK(request.connect_timeout == std::chrono::milliseconds{1234}); + CHECK(request.request_timeout == std::chrono::milliseconds{5678}); CHECK(request.body == "original.jwt.token"); } @@ -207,6 +216,8 @@ TEST_CASE("revoke_activation posts the JWT to the revoke endpoint") CHECK(request.url.find("meta%5Bchannel%5D=test") != std::string::npos); CHECK(request.headers.at("Content-Type") == "text/plain"); CHECK(request.headers.at("x-mb-client") == "moonbase-cpp"); + CHECK(request.connect_timeout == std::chrono::milliseconds{1234}); + CHECK(request.request_timeout == std::chrono::milliseconds{5678}); CHECK(request.body == "original.jwt.token"); } diff --git a/tests/consumer_smoke/CMakeLists.txt b/tests/consumer_smoke/CMakeLists.txt new file mode 100644 index 0000000..bfd53b5 --- /dev/null +++ b/tests/consumer_smoke/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.20) + +project(moonbase_cpp_consumer_smoke LANGUAGES CXX) + +find_package(moonbase_cpp REQUIRED) + +add_executable(moonbase_cpp_consumer_smoke main.cpp) +target_link_libraries(moonbase_cpp_consumer_smoke PRIVATE moonbase::licensing) diff --git a/tests/consumer_smoke/main.cpp b/tests/consumer_smoke/main.cpp new file mode 100644 index 0000000..ee5ec71 --- /dev/null +++ b/tests/consumer_smoke/main.cpp @@ -0,0 +1,12 @@ +#include + +int main() +{ + moonbase::licensing_options options; + options.endpoint = "https://example.invalid"; + options.product_id = "product"; + options.public_key = "invalid"; + (void)options; + + return moonbase::to_string(moonbase::platform::linux_os).empty() ? 1 : 0; +} diff --git a/tests/fingerprint_tests.cpp b/tests/fingerprint_tests.cpp index 0a077bf..0aee116 100644 --- a/tests/fingerprint_tests.cpp +++ b/tests/fingerprint_tests.cpp @@ -1,5 +1,6 @@ #include +#include "moonbase/default_fingerprint.hpp" #include "moonbase/fingerprint.hpp" using namespace moonbase; diff --git a/tests/header_smoke_tests.cpp b/tests/header_smoke_tests.cpp new file mode 100644 index 0000000..2d1bac5 --- /dev/null +++ b/tests/header_smoke_tests.cpp @@ -0,0 +1,19 @@ +#include + +#define linux 1 +#define unix 1 +#include "moonbase/moonbase.hpp" + +static_assert(linux == 1, "moonbase headers must not undefine linux"); +static_assert(unix == 1, "moonbase headers must not undefine unix"); +static_assert( + static_cast(moonbase::platform::linux_os) >= 0, + "linux_os platform enumerator must remain usable while linux is a macro"); + +#undef linux +#undef unix + +TEST_CASE("public headers tolerate legacy linux and unix macros") +{ + CHECK(moonbase::to_string(moonbase::platform::linux_os) == "Linux"); +} diff --git a/tests/validator_tests.cpp b/tests/validator_tests.cpp index c244411..de51cf1 100644 --- a/tests/validator_tests.cpp +++ b/tests/validator_tests.cpp @@ -3,6 +3,7 @@ #include #include +#include "moonbase/detail/time.hpp" #include "moonbase/fingerprint.hpp" #include "moonbase/validator.hpp" @@ -83,6 +84,31 @@ TEST_CASE("validated timestamp can fall back to legacy ver claim") CHECK(moonbase::detail::format_iso8601_utc(result.validated_at) == "2026-05-08T12:34:56Z"); } +TEST_CASE("strict UTC timestamp parser accepts only explicit UTC timestamps") +{ + CHECK( + moonbase::detail::format_iso8601_utc( + moonbase::detail::parse_iso8601_utc("2026-05-08T12:34:56Z")) == + "2026-05-08T12:34:56Z"); + CHECK( + moonbase::detail::format_iso8601_utc( + moonbase::detail::parse_iso8601_utc("2026-05-08T12:34:56.1234567Z")) == + "2026-05-08T12:34:56Z"); + + CHECK_THROWS_AS( + (void)moonbase::detail::parse_iso8601_utc("2026-05-08T12:34:56"), + std::runtime_error); + CHECK_THROWS_AS( + (void)moonbase::detail::parse_iso8601_utc("2026-05-08T12:34:56+01:00"), + std::runtime_error); + CHECK_THROWS_AS( + (void)moonbase::detail::parse_iso8601_utc("2026-05-08T12:34:56Z trailing"), + std::runtime_error); + CHECK_THROWS_AS( + (void)moonbase::detail::parse_iso8601_utc("2026-02-30T12:34:56Z"), + std::runtime_error); +} + TEST_CASE("trial tokens use trial properties") { auto key = moonbase::tests::generate_key(); @@ -156,6 +182,34 @@ TEST_CASE("invalid JWTs are rejected") CHECK_THROWS_AS((void)make_validator(key.public_pem).validate_token(token), license_expired_error); } + SUBCASE("unknown activation method") + { + auto claims = moonbase::tests::default_claims(); + claims["method"] = "Sideways"; + const auto token = moonbase::tests::make_token(key.key.get(), claims); + CHECK_THROWS_AS((void)make_validator(key.public_pem).validate_token(token), license_invalid_error); + } + + SUBCASE("malformed legacy validation timestamp") + { + auto claims = moonbase::tests::default_claims(); + claims.erase("validated"); + claims["ver"] = "2026-02-30T12:34:56Z"; + const auto token = moonbase::tests::make_token(key.key.get(), claims); + CHECK_THROWS_AS((void)make_validator(key.public_pem).validate_token(token), license_invalid_error); + } + + SUBCASE("malformed signature encoding") + { + const auto token = moonbase::tests::make_token( + key.key.get(), + moonbase::tests::default_claims()); + const auto signature = token.rfind('.'); + REQUIRE(signature != std::string::npos); + const auto corrupted = token.substr(0, signature + 1) + "not@base64"; + CHECK_THROWS_AS((void)make_validator(key.public_pem).validate_token(corrupted), license_invalid_error); + } + SUBCASE("missing required claim") { auto claims = moonbase::tests::default_claims();