diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 0000000..125e859 --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,30 @@ +[bumpversion] +current_version = 0.2.0-rc2 +commit = False +message = Bump version: {current_version} → {new_version} +tag_message = Release v{new_version} +tag_name = v{new_version} +tag = True +parse = (?P\d+)\.(?P\d+)\.(?P\d+)([-](?P(dev|rc))(?P\d+))? +serialize = + {major}.{minor}.{patch}-{release}{build} + {major}.{minor}.{patch} + +[bumpversion:part:release] +first_value = dev +optional_value = ga +values = + dev + rc + ga + +[bumpversion:part:build] +first_value = 0 + +[bumpversion:file:CMakeLists.txt] +search = {current_version} +replace = {new_version} +parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\.(?P\d+))? +serialize = + {major}.{minor}.{patch}.{build} + {major}.{minor}.{patch} diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..50807a7 --- /dev/null +++ b/.clang-format @@ -0,0 +1,21 @@ +--- +BasedOnStyle: Google +IndentWidth: 2 +UseTab: Never +--- +Language: Cpp +Standard: c++17 +# Standard: Auto +AlignAfterOpenBracket: false +AlignEscapedNewlinesLeft: true +AlwaysBreakAfterDefinitionReturnType: None +BreakBeforeBraces: Allman +BreakConstructorInitializersBeforeComma: false +ColumnLimit: 123 +ConstructorInitializerAllOnOneLineOrOnePerLine: true +ConstructorInitializerIndentWidth: 0 +IndentCaseLabels: false +SortIncludes: true +AlignTrailingComments: false + +SpacesInAngles: true diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..0392d1d --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,56 @@ +--- +Checks: "-*,\ +bugprone-*,\ +bugprone-reserved-identifier,\ +-bugprone-exception-escape,\ +-bugprone-unused-return-value,\ +boost-*,\ +-boost-use-ranges,\ +cert-*,\ +-cert-err33-c,\ +-cert-err58-cpp,\ +clang-analyzer-*,\ +-clang-analyzer-unix.BlockInCriticalSection,\ +cppcoreguidelines-*,\ +-cppcoreguidelines-avoid-*,\ +-cppcoreguidelines-init-variables,\ +-cppcoreguidelines-macro-*,\ +-cppcoreguidelines-narrowing-conversions,\ +-cppcoreguidelines-owning-memory,\ +-cppcoreguidelines-prefer-member-initializer,\ +-cppcoreguidelines-pro-bounds-pointer-arithmetic,\ +-cppcoreguidelines-pro-type-reinterpret-cast,\ +hicpp-*,\ +misc-*,\ +-misc-const-correctness,\ +-misc-include-cleaner,\ +-misc-no-recursion,\ +modernize-*,\ +-modernize-macro-to-enum,\ +-modernize-use-designated-initializers,\ +performance-*,\ +-performance-enum-size,\ +portability-*,\ +-portability-avoid-pragma-once,\ +readability-*,\ +readability-identifier-length,\ +readability-identifier-naming,\ +-*magic-numbers,\ +-*avoid-c-arrays,\ +" +WarningsAsErrors: 'clang-*' +HeaderFilterRegex: '.*' +FormatStyle: file +User: clausklein +# options: hicpp-signed-bitwise.IgnorePositiveIntegerLiterals +CheckOptions: + - { key: readability-identifier-naming.NamespaceCase, value: lower_case } + - { key: readability-identifier-naming.ClassCase, value: lower_case } + - { key: readability-identifier-naming.MethodCase, value: lower_case } + - { key: readability-identifier-naming.MemberCase, value: lower_case } + - { key: readability-identifier-naming.MemberSuffix, value: _ } + - { key: readability-identifier-naming.ConstexprVariableCase, value: UPPER_CASE } + - { key: readability-identifier-length.MinimumVariableNameLength, value: 2 } + - { key: readability-identifier-length.MinimumParameterNameLength, value: 1 } + - { key: hicpp-signed-bitwise.IgnorePositiveIntegerLiterals, value: true } +... diff --git a/.codespellignore b/.codespellignore new file mode 100644 index 0000000..918696e --- /dev/null +++ b/.codespellignore @@ -0,0 +1,12 @@ +QUE +UInt +WRONLY +WS +cancelled +cancelling +claus +copyable +deque +fo +pullrequest +statics diff --git a/.codespellrc b/.codespellrc new file mode 100644 index 0000000..537dabf --- /dev/null +++ b/.codespellrc @@ -0,0 +1,6 @@ +[codespell] +builtin = clear,rare,en-GB_to_en-US,names,informal,code +check-hidden = +skip = ./.git,./.direnv,./build/*,./prefix/*,./coverage/*,./stagedir/*,*.html,*.xsd,*.xsl,*.pdf,*.log,.*.swp,*~,*.bak,./tags +quiet-level = 2 +ignore-words = .codespellignore diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..1bb8612 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +# We use ubuntu-24.04 (noble) +FROM mcr.microsoft.com/devcontainers/cpp:1-ubuntu-24.04 + +USER vscode + +# ------------------------------- +# Install latest CMake and Boost +# ------------------------------- +RUN sudo apt-get update -qq \ + && sudo apt-get install -y -qq wget gpg software-properties-common \ + && wget -O - https://apt.kitware.com/keys/kitware-archive-latest.asc \ + | gpg --dearmor \ + | sudo tee /usr/share/keyrings/kitware-archive-keyring.gpg >/dev/null \ + && echo "deb [signed-by=/usr/share/keyrings/kitware-archive-keyring.gpg] https://apt.kitware.com/ubuntu/ noble main" \ + | sudo tee /etc/apt/sources.list.d/kitware.list >/dev/null \ + && sudo apt-get update -qq \ + && sudo apt-get install -y -qq cmake libboost-all-dev python3-pip \ + && sudo rm -rf /var/lib/apt/lists/* + +# ------------------------------- +# Install pre-commit, gcovr, ninja +# ------------------------------- +RUN pip3 install --no-cache-dir pre-commit gcovr ninja cmake + +# ------------------------------- +# Avoid ASAN stalling +# ------------------------------- +# Reduces mmap randomization slightly so AddressSanitizer works reliably +RUN sudo sysctl -w vm.mmap_rnd_bits=28 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e0cbb9c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,15 @@ +{ + "name": "Beman Project Generic Devcontainer", + "build": { + "dockerfile": "Dockerfile" + }, + "postCreateCommand": "bash .devcontainer/postcreate.sh", + "customizations": { + "vscode": { + "extensions": [ + "ms-vscode.cpptools", + "ms-vscode.cmake-tools" + ] + } + } +} diff --git a/.devcontainer/postcreate.sh b/.devcontainer/postcreate.sh new file mode 100644 index 0000000..4293f7e --- /dev/null +++ b/.devcontainer/postcreate.sh @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# Setup pre-commit +pre-commit +pre-commit install diff --git a/.github/workflows/clang.yml b/.github/workflows/clang.yml new file mode 100644 index 0000000..d2c94ca --- /dev/null +++ b/.github/workflows/clang.yml @@ -0,0 +1,48 @@ +name: Clang on Ubuntu + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ develop ] + workflow_dispatch: + schedule: + - cron: '30 15 * * 6' + +jobs: + clang: + strategy: + fail-fast: false + matrix: + version: [20, 21] + + runs-on: ubuntu-latest + + container: + image: ghcr.io/mattkretz/cplusplus-ci/clang${{ matrix.version }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: { python-version: "3.13" } + + - name: Install Boost + run: apt-get update -qq && apt-get install -y -qq libboost-all-dev + + - name: Setup Cpp + uses: aminya/setup-cpp@v1 + with: + # compiler: llvm-${{ matrix.version }} + cmake: 4.1.2 + ninja: 1.13.0 + gcovr: true + + - name: Run test suite + env: + CXX: clang++-${{ matrix.version }} + run: | + export PATH=$HOME/.local/bin:$PATH + cmake --workflow --preset llvm-release + gcovr + # PRESET_NAME=llvm-release make all diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml new file mode 100644 index 0000000..80c7e22 --- /dev/null +++ b/.github/workflows/cmake-multi-platform.yml @@ -0,0 +1,75 @@ +name: CMake on multiple platforms + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ develop ] + workflow_dispatch: + schedule: + - cron: '30 15 * * 6' + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + build_type: [debug, release] + c_compiler: [gcc, clang, cl] + include: + # Windows - MSVC + - os: windows-latest + c_compiler: cl + cpp_compiler: cl + preset: msvc + + # Ubuntu - GCC + - os: ubuntu-latest + c_compiler: gcc + cpp_compiler: g++ + preset: gcc + + # Ubuntu - Clang + - os: ubuntu-latest + c_compiler: clang + cpp_compiler: clang++ + preset: llvm + + # macOS - Clang (default compiler) + - os: macos-latest + c_compiler: clang + cpp_compiler: clang++ + preset: appleclang + + exclude: + - os: windows-latest + c_compiler: gcc + - os: windows-latest + c_compiler: clang + - os: ubuntu-latest + c_compiler: cl + - os: macos-latest + c_compiler: gcc + - os: macos-latest + c_compiler: cl + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + # # see https://github.com/marketplace/actions/enable-developer-command-prompt + - uses: ilammy/msvc-dev-cmd@v1 + if: matrix.os == 'windows-latest' + with: + vsversion: 2022 + arch: x64 + + - name: Workflow preset ${{ matrix.preset }}-${{ matrix.build_type }} + env: + CC: ${{ matrix.c_compiler }} + CXX: ${{ matrix.cpp_compiler }} + run: | + cmake --workflow --preset ${{ matrix.preset }}-${{ matrix.build_type }} diff --git a/.github/workflows/gcc.yml b/.github/workflows/gcc.yml new file mode 100644 index 0000000..97c2a08 --- /dev/null +++ b/.github/workflows/gcc.yml @@ -0,0 +1,48 @@ +name: GCC on Ubuntu + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ develop ] + workflow_dispatch: + schedule: + - cron: '30 15 * * 6' + +jobs: + gcc: + strategy: + fail-fast: false + matrix: + version: [15, 16] + + runs-on: ubuntu-latest + + container: + image: ghcr.io/mattkretz/cplusplus-ci/gcc${{ matrix.version }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: { python-version: "3.13" } + + - name: Install Boost + run: apt-get update -qq && apt-get install -y -qq libboost-all-dev + + - name: Setup Cpp + uses: aminya/setup-cpp@v1 + with: + # compiler: gnu-${{ matrix.version }} + cmake: 4.1.2 + ninja: 1.13.0 + gcovr: true + + - name: Run test suite + env: + CXX: g++-${{ matrix.version }} + run: | + export PATH=$HOME/.local/bin:$PATH + cmake --workflow --preset gcc-release + gcovr + # PRESET_NAME=gcc-release make all diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..1665291 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,14 @@ +name: Lint Check (pre-commit) + +on: + # We have to use pull_request_target here as pull_request does not grant + # enough permission for reviewdog + pull_request_target: + push: + branches: + - main + - develop + +jobs: + pre-commit: + uses: bemanproject/infra-workflows/.github/workflows/reusable-beman-pre-commit.yml@1.1.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78955a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.log +.*swp +/CMakeUserPresets.json +CODEOWNERS +build/ +coverage/* +tags diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..81f5fcd --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,9 @@ +# MD033/no-inline-html : Inline HTML : https://github.com/DavidAnson/markdownlint/blob/v0.35.0/doc/md033.md +# Disable inline html linter is needed for
+MD033: false + +# MD013/line-length : Line length : https://github.com/DavidAnson/markdownlint/blob/v0.35.0/doc/md013.md +# Conforms to .clang-format ColumnLimit +# Update the comment in .clang-format if we no-longer tie these two column limits. +MD013: + line_length: 119 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..21cf883 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + exclude: ^\.bumpversion.cfg$ + - id: end-of-file-fixer + - id: check-json + - id: check-yaml + exclude: ^\.clang-(format|tidy)$ + - id: check-added-large-files + + # This brings in a portable version of clang-format. + # See also: https://github.com/ssciwr/clang-format-wheel + - repo: https://github.com/pre-commit/mirrors-clang-format + rev: v21.1.2 + hooks: + - id: clang-format + types_or: [c++, c, json] + exclude: docs/TODO.json + + # CMake linting and formatting + - repo: https://github.com/BlankSpruce/gersemi + rev: 0.22.3 + hooks: + - id: gersemi + name: CMake linting + + # TODO: Markdown linting + # Config file: .markdownlint.yaml + # - repo: https://github.com/igorshubovych/markdownlint-cli + # rev: v0.43.0 + # hooks: + # - id: markdownlint + + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + files: ^.*\.(cmake|cpp|hpp|txt|md|json|in|yaml|yml)$ + args: ["-w", "--ignore-words", ".codespellignore" ] diff --git a/Base64.cpp b/Base64.cpp new file mode 100644 index 0000000..52ccd2d --- /dev/null +++ b/Base64.cpp @@ -0,0 +1,249 @@ +#include "Base64.hpp" + +#define USE_BOOST_BEAST +#ifdef USE_BOOST_BEAST +// with homebrew /usr/local/include/boost/beast/core/detail/base64.hpp +// and its impl. /usr/local/include/boost/beast/core/detail/base64.ipp +// /Users/clausklein/.local/include/boost/beast/core/detail/base64.hpp +#include +#endif + +#ifdef USE_BOOST_BEAST + +#include +#include + +#ifdef __cpp_lib_ranges +#include +#endif + +namespace +{ + +using boost::beast::detail::base64::decode; +using boost::beast::detail::base64::decoded_size; +using boost::beast::detail::base64::encode; +using boost::beast::detail::base64::encoded_size; + +class base64 +{ +#ifndef __cpp_lib_ranges + // Function to remove all whitespace characters from a std::string (C++17) + static auto remove_whitespace(std::string_view input) -> std::string + { + std::string result{input.data(), input.length()}; + result.erase( + std::remove_if(result.begin(), result.end(), [](unsigned char c) { return std::isspace(c); }), result.end()); + return result; + } +#else + // Function to remove all whitespace characters from a std::string_view (C++20) + static auto remove_whitespace(std::string_view input) -> std::string + { + auto filtered = input | std::views::filter([](unsigned char c) -> bool { return !std::isspace(c); }); + return {filtered.begin(), filtered.end()}; + } +#endif + + static auto base64_encode(std::uint8_t const* data, std::size_t len) -> std::string + { + std::string dest; + dest.resize(encoded_size(len)); + dest.resize(encode(dest.data(), data, len)); + return dest; + } + + public: + static auto base64_encode(std::string_view s) -> std::string + { + return base64_encode(reinterpret_cast< std::uint8_t const* >(s.data()), s.size()); + } + + static auto base64_decode(std::string_view data) -> std::string + { + std::string dest; + dest.resize(decoded_size(data.size())); + + // TODO(CK): remove first at least all "\n\r" or better all non printable chars! + std::string striped = remove_whitespace(data); + auto const result = decode(dest.data(), striped.data(), striped.size()); + dest.resize(result.first); + return dest; + } +}; + +} // namespace + +#else + +// XXX #include +#include + +#endif + +namespace rrcp::common +{ + +auto base64::encode(std::string_view data) -> std::string +{ + if (data.empty()) + { + return {}; + } + +#ifdef USE_BOOST_BEAST + + return ::base64::base64_encode(data); + +#else + + std::string encoded; + size_t linelen = 0; + + // Encode all complete 3 octet blocks + for (size_t i = 0; i < (data.size() / 3); ++i) + { + size_t const pos = 3 * i; + auto i1 = std::uint8_t((data[pos] & 0xfc) >> 2U); + auto i2 = std::uint8_t(((data[pos] & 0x03) << 4U) | ((data[pos + 1] & 0xf0) >> 4U)); + auto i3 = std::uint8_t(((data[pos + 1] & 0x0f) << 2U) | ((data[pos + 2] & 0xfc) >> 6U)); + auto i4 = std::uint8_t((data[pos + 2] & 0x3f)); + // XXX assert((i1 < 64) && (i2 < 64) && (i3 < 64) && (i4 < 64)); + + encoded.append(1, BaseChars_[i1]); + encoded.append(1, BaseChars_[i2]); + encoded.append(1, BaseChars_[i3]); + encoded.append(1, BaseChars_[i4]); + if (encodeWithLinebreak_) + { + linelen += 4; + if (linelen >= 76) + { + linelen = 0; + encoded.append("\n"); + } + } + } + + // Handle remaining octets + if ((data.size() % 3) == 1) + { + // One octet remaining. + auto i1 = std::uint8_t((data[data.size() - 1] & 0xfc) >> 2U); + auto i2 = std::uint8_t(((data[data.size() - 1] & 0x03) << 4U)); + // XXX assert((i1 < 64) && (i2 < 64)); + + encoded.append(1, BaseChars_[i1]); + encoded.append(1, BaseChars_[i2]); + encoded.append(2, '='); + } + else if ((data.size() % 3) == 2) + { + // Two octets remaining. + auto i1 = std::uint8_t((data[data.size() - 2] & 0xfc) >> 2U); + auto i2 = std::uint8_t(((data[data.size() - 2] & 0x03) << 4U) | ((data[data.size() - 1] & 0xf0) >> 4U)); + auto i3 = std::uint8_t(((data[data.size() - 1] & 0x0f) << 2U)); + // XXX assert((i1 < 64) && (i2 < 64) && (i3 < 64)); + + encoded.append(1, BaseChars_[i1]); + encoded.append(1, BaseChars_[i2]); + encoded.append(1, BaseChars_[i3]); + encoded.append(1, '='); + } + + return encoded; + +#endif +} + +auto base64::decode(std::string_view in) -> std::string +{ +#ifdef USE_BOOST_BEAST + + return ::base64::base64_decode(in); + +#else + + std::string decoded; + std::string fourChars; + + // Iterate over the input string + for (const char i : in) + { + if (isBase64Char(i) || (i == '=')) + { + fourChars += i; + } + else if ((i != '\n') && (i != '\r')) + { + // Invalid character, throw an exception + throw std::invalid_argument("Invalid Base64 character"); + } + + // If we have four characters, decode them + if (fourChars.size() == 4) + { + std::uint8_t const i1 = base64CharValue(fourChars[0]); + std::uint8_t const i2 = base64CharValue(fourChars[1]); + std::uint8_t const i3 = (fourChars[2] == '=') ? 0 : base64CharValue(fourChars[2]); + std::uint8_t const i4 = (fourChars[3] == '=') ? 0 : base64CharValue(fourChars[3]); + + decoded += static_cast< char >((i1 << 2U) | (i2 >> 4U)); + if (i3 != 0) + { + decoded += static_cast< char >(((i2 << 4U) & 0xf0) | (i3 >> 2U)); + } + if (i4 != 0) + { + decoded += static_cast< char >(((i3 << 6U) & 0xc0) | i4); + } + + fourChars.clear(); + } + } + + // Check if there are any remaining characters + if (!fourChars.empty()) + { + throw std::invalid_argument("Invalid Base64 string"); + } + + return decoded; + +#endif +} + +#ifndef USE_BOOST_BEAST +auto Base64::isBase64Char(char c) const -> bool +{ + return ( + ((c >= 'A') && (c <= 'Z')) || ((c >= 'a') && (c <= 'z')) || ((c >= '0') && (c <= '9')) || (c == '+') || (c == '/')); +} + +auto Base64::base64CharValue(char c) const -> std::uint8_t +{ + if ((c >= 'A') && (c <= 'Z')) + { + return std::uint8_t(c - 'A'); + } + if ((c >= 'a') && (c <= 'z')) + { + return std::uint8_t(c - 'a' + 26U); + } + if ((c >= '0') && (c <= '9')) + { + return std::uint8_t(c - '0' + 52U); + } + if (c == '+') + { + return 62U; + } + if (c == '/') + { + return 63U; + } + throw std::invalid_argument("Invalid Base64 character"); +} +#endif + +} // namespace rrcp::common diff --git a/Base64.hpp b/Base64.hpp new file mode 100644 index 0000000..60a5849 --- /dev/null +++ b/Base64.hpp @@ -0,0 +1,65 @@ +#ifndef BASE64_HPP +#define BASE64_HPP + +#include +#include +#include + +namespace rrcp::common +{ + +class base64 +{ + public: + /** + * Constructor for the Base64 class. + */ + base64() = default; + + /** + * Destructor for the Base64 class. + */ + ~base64() = default; + + /** + * Set the line break flag for encoding. + * @param lbrk If true, the encoded string will have a maximum line length of 80 characters. + */ + void set_line_break(bool lbrk) { encode_with_linebreak_ = lbrk; } + + /** + * Encode binary data to base64. + * @param data The data to be encoded. + * @return The corresponding base64 encoded string. + */ + [[nodiscard]] static auto encode(std::string_view data) -> std::string; + + /** + * Decode a Base64 encoded string. + * @param in The base64 encoded string. + * @return The decoded string. + */ + [[nodiscard]] static auto decode(std::string_view in) -> std::string; + + private: + /** + * Check if a character is a valid Base64 character. + * @param c The character to check. + * @return True if the character is a valid Base64 character, false otherwise. + */ + [[nodiscard]] auto is_base64_char(char c) const -> bool; + + /** + * Get the value of a Base64 character. + * @param c The Base64 character. + * @return The value of the character (0-63). + */ + [[nodiscard]] auto base64_char_value(char c) const -> std::uint8_t; + + const std::string_view base_chars_{"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"}; + bool encode_with_linebreak_{false}; +}; + +} // namespace rrcp::common + +#endif // BASE64_HPP diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..db374d1 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,232 @@ +cmake_minimum_required(VERSION 3.25...4.2) + +project(RRCP-client VERSION 0.2.0.2 LANGUAGES CXX) + +# ---- add dependencies ---- + +find_package(Threads) + +set(BOOST_INCLUDE_LIBRARIES algorithm asio beast signals2) + +# see too: https://cmake.org/cmake/help/latest/module/FindBoost.html +set(Boost_DEBUG ON) +find_package(Boost CONFIG) # XXX COMPONENTS ${BOOST_INCLUDE_LIBRARIES} HINTS $ENV{HOME}/.local) +if(Boost_FOUND) + set(BOOST_LIBRARIES Boost::headers) +else() + include(cmake/CPM.cmake) + + set(BOOST_LIBRARIES ${BOOST_INCLUDE_LIBRARIES}) + list(TRANSFORM BOOST_LIBRARIES PREPEND Boost::) + + if(NOT TARGET Boost::headers) + # + # build only the requested Boost components + # + cpmaddpackage( + NAME boost-cmake + VERSION 1.87.0.6 + GIT_TAG v1.87.0-rc6 + GITHUB_REPOSITORY ClausKlein/boost-cmake + EXCLUDE_FROM_ALL NO + SYSTEM YES + ) + endif() +endif() + +include(FetchContent) + +FetchContent_Declare( + fmt + GIT_TAG 12.0.0 + GIT_REPOSITORY https://github.com/fmtlib/fmt.git + EXCLUDE_FROM_ALL + SYSTEM + FIND_PACKAGE_ARGS 12.0.0 NAMES fmt HINTS $ENV{HOME}/.local +) + +FetchContent_MakeAvailable(fmt) + +# ---- default settings ---- + +if(DEFINED ENV{CI}) + message(STATUS "Running inside a CI environment") +else() + message(STATUS "Running locally") + + if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + if(APPLE) + execute_process( + OUTPUT_VARIABLE LLVM_PREFIX + COMMAND brew --prefix llvm + COMMAND_ECHO STDOUT + ) + string(STRIP ${LLVM_PREFIX} LLVM_PREFIX) + elseif(LINUX) + set(LLVM_PREFIX $ENV{LLVM_ROOT}) + endif() + + add_compile_options(-fexperimental-library) + add_link_options(-L${LLVM_PREFIX}/lib/c++ -lc++experimental) + endif() +endif() + +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# ---- code coverage ---- + +option(BUILD_TESTING "Build ctest" ${PROJECT_IS_TOP_LEVEL}) +option(BUILD_EXAMPLES "Compile examples too" NO) +option( + ENABLE_TEST_COVERAGE + "Compile with test-coverage flags" + ${PROJECT_IS_TOP_LEVEL} +) +if(UNIX AND ENABLE_TEST_COVERAGE AND CMAKE_BUILD_TYPE STREQUAL Debug) + message(WARNING "ENABLE_TEST_COVERAGE is set!") + add_compile_options(-O0 -g -fprofile-arcs -ftest-coverage) + add_link_options(-fprofile-arcs -ftest-coverage) + # FIXME: add_compile_definitions(TARGET_CODE_COVERAGE) +endif() + +# ---- ctest ---- + +enable_testing() + +function(do_test target arg result) + if(BUILD_TESTING) + add_test(NAME ${target}${arg} COMMAND ${target} ${arg}) + set_tests_properties( + ${target}${arg} + PROPERTIES PASS_REGULAR_EXPRESSION ${result} + ) + endif() +endfunction() + +# ---- echo server needed for tests ---- + +add_executable(async_tcp_echo_server examples/async_tcp_echo_server.cpp) +target_link_libraries( + async_tcp_echo_server + PUBLIC Threads::Threads ${BOOST_LIBRARIES} +) +do_test(async_tcp_echo_server "" port) + +# ---- rrcp class sources and helpers as a library ---- + +find_program( + PYTHON_EXECUTABLE + NAMES python3 python + REQUIRED + HINTS $ENV{VIRTUAL_ENV}/bin +) + +add_library(rrcp_helper STATIC) +target_sources( + rrcp_helper + PRIVATE rrcp_helper.cpp + PUBLIC + FILE_SET + HEADERS # + FILES + async_rrcp_client.hpp + async_rrcp_client_threadsafe.hpp + rrcp_helper.hpp +) +target_link_libraries( + rrcp_helper + PUBLIC Threads::Threads ${BOOST_LIBRARIES} fmt::fmt-header-only +) + +# ---- simple rrcp client class usage examples ---- + +if(BUILD_EXAMPLES AND NOT ENABLE_TEST_COVERAGE) + add_executable(async_tcp_echo_client examples/async_tcp_echo_client.cpp) + target_link_libraries(async_tcp_echo_client PRIVATE rrcp_helper) + do_test(async_tcp_echo_client --help Usage) + add_test( + NAME async_tcp_echo_client-test + COMMAND + ${PYTHON_EXECUTABLE} # + ${CMAKE_CURRENT_SOURCE_DIR}/run_test.py # + --client $ # + --server $ + ) + add_test( + NAME async_tcp_echo_client-test-no_server + COMMAND + ${PYTHON_EXECUTABLE} # + ${CMAKE_CURRENT_SOURCE_DIR}/run_test.py # + --client $ # + --timeout 9 + ) + + add_executable( + blocking_tcp_echo_client + examples/blocking_tcp_echo_client.cpp + ) + target_link_libraries(blocking_tcp_echo_client PRIVATE rrcp_helper) + do_test(blocking_tcp_echo_client --help Usage) + + add_executable(rrcp_client rrcp_client.cpp rrcp_message.hpp) + target_link_libraries(rrcp_client PRIVATE rrcp_helper) + do_test(rrcp_client --help Usage) + + # TODO(CK): mv to examples too! + add_executable(timer timer.cpp) + target_link_libraries( + timer + PUBLIC Threads::Threads ${BOOST_LIBRARIES} fmt::fmt-header-only + ) + add_test(NAME timer COMMAND timer) +endif() + +# ---- theadsafe rrcp client class usage examples main ---- + +add_executable(rrcp_async_tcp_client_threadsafe rrcp_async_tcp_client.cpp) +target_link_libraries(rrcp_async_tcp_client_threadsafe PRIVATE rrcp_helper) +do_test(rrcp_async_tcp_client_threadsafe --help Usage) +add_test( + NAME rrcp_async_tcp_client_threadsafe-test-no_server + COMMAND + ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_test.py # + --client $ # + --timeout 9 +) +add_test( + NAME rrcp_async_tcp_client_threadsafe-test + COMMAND + ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_test.py # + --client $ # + --server $ # + --input ${CMAKE_CURRENT_SOURCE_DIR}/rrcp.txt # +) + +add_executable(rrcp_async_tcp_client rrcp_async_tcp_client.cpp) +target_link_libraries(rrcp_async_tcp_client PRIVATE rrcp_helper) +target_compile_definitions(rrcp_async_tcp_client PRIVATE USE_SIMPLE_RRCP_CLIENT) +do_test(rrcp_async_tcp_client --help Usage) +add_test( + NAME rrcp_async_tcp_client-test-no_server + COMMAND + ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_test.py # + --client $ # + --timeout 9 +) +add_test( + NAME rrcp_async_tcp_client-test + COMMAND + ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_test.py # + --client $ # + --server $ # + --input ${CMAKE_CURRENT_SOURCE_DIR}/rrcp.txt # +) + +if(BUILD_TESTING) + add_subdirectory(tests) +endif() + +if(APPLE AND BUILD_EXAMPLES) + add_subdirectory(examples) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..a662c06 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,383 @@ +{ + "version": 6, + "configurePresets": [ + { + "name": "_root-config", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_CXX_STANDARD": "23", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", + "CMAKE_PROJECT_TOP_LEVEL_INCLUDES": "./infra/cmake/use-fetch-content.cmake" + } + }, + { + "name": "_debug-base", + "hidden": true, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "BEMAN_BUILDSYS_SANITIZER": "TSan" + } + }, + { + "name": "_release-base", + "hidden": true, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo" + } + }, + { + "name": "gcc-debug", + "displayName": "GCC Debug Build", + "inherits": [ + "_root-config", + "_debug-base" + ], + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "infra/cmake/gnu-toolchain.cmake" + } + }, + { + "name": "gcc-release", + "displayName": "GCC Release Build", + "inherits": [ + "_root-config", + "_release-base" + ], + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "infra/cmake/gnu-toolchain.cmake" + } + }, + { + "name": "llvm-debug", + "displayName": "Clang Debug Build", + "inherits": [ + "_root-config", + "_debug-base" + ], + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "infra/cmake/llvm-toolchain.cmake" + } + }, + { + "name": "llvm-release", + "displayName": "Clang Release Build", + "inherits": [ + "_root-config", + "_release-base" + ], + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "infra/cmake/llvm-toolchain.cmake" + } + }, + { + "name": "appleclang-debug", + "displayName": "Appleclang Debug Build", + "inherits": [ + "_root-config", + "_debug-base" + ], + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "infra/cmake/appleclang-toolchain.cmake" + } + }, + { + "name": "appleclang-release", + "displayName": "Appleclang Release Build", + "inherits": [ + "_root-config", + "_release-base" + ], + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "infra/cmake/appleclang-toolchain.cmake" + } + }, + { + "name": "msvc-debug", + "displayName": "MSVC Debug Build", + "inherits": [ + "_root-config", + "_debug-base" + ], + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "infra/cmake/msvc-toolchain.cmake" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "msvc-release", + "displayName": "MSVC Release Build", + "inherits": [ + "_root-config", + "_release-base" + ], + "cacheVariables": { + "CMAKE_TOOLCHAIN_FILE": "infra/cmake/msvc-toolchain.cmake" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + } + ], + "buildPresets": [ + { + "name": "_root-build", + "hidden": true, + "jobs": 0 + }, + { + "name": "gcc-debug", + "configurePreset": "gcc-debug", + "inherits": [ + "_root-build" + ] + }, + { + "name": "gcc-release", + "configurePreset": "gcc-release", + "inherits": [ + "_root-build" + ] + }, + { + "name": "llvm-debug", + "configurePreset": "llvm-debug", + "inherits": [ + "_root-build" + ] + }, + { + "name": "llvm-release", + "configurePreset": "llvm-release", + "inherits": [ + "_root-build" + ] + }, + { + "name": "appleclang-debug", + "configurePreset": "appleclang-debug", + "inherits": [ + "_root-build" + ] + }, + { + "name": "appleclang-release", + "configurePreset": "appleclang-release", + "inherits": [ + "_root-build" + ] + }, + { + "name": "msvc-debug", + "configurePreset": "msvc-debug", + "inherits": [ + "_root-build" + ] + }, + { + "name": "msvc-release", + "configurePreset": "msvc-release", + "inherits": [ + "_root-build" + ] + } + ], + "testPresets": [ + { + "name": "_test_base", + "hidden": true, + "output": { + "outputOnFailure": true + }, + "execution": { + "noTestsAction": "error", + "stopOnFailure": true + } + }, + { + "name": "gcc-debug", + "inherits": "_test_base", + "configurePreset": "gcc-debug" + }, + { + "name": "gcc-release", + "inherits": "_test_base", + "configurePreset": "gcc-release" + }, + { + "name": "llvm-debug", + "inherits": "_test_base", + "configurePreset": "llvm-debug" + }, + { + "name": "llvm-release", + "inherits": "_test_base", + "configurePreset": "llvm-release" + }, + { + "name": "appleclang-debug", + "inherits": "_test_base", + "configurePreset": "appleclang-debug" + }, + { + "name": "appleclang-release", + "inherits": "_test_base", + "configurePreset": "appleclang-release" + }, + { + "name": "msvc-debug", + "inherits": "_test_base", + "configurePreset": "msvc-debug" + }, + { + "name": "msvc-release", + "inherits": "_test_base", + "configurePreset": "msvc-release" + } + ], + "workflowPresets": [ + { + "name": "gcc-debug", + "steps": [ + { + "type": "configure", + "name": "gcc-debug" + }, + { + "type": "build", + "name": "gcc-debug" + }, + { + "type": "test", + "name": "gcc-debug" + } + ] + }, + { + "name": "gcc-release", + "steps": [ + { + "type": "configure", + "name": "gcc-release" + }, + { + "type": "build", + "name": "gcc-release" + }, + { + "type": "test", + "name": "gcc-release" + } + ] + }, + { + "name": "llvm-debug", + "steps": [ + { + "type": "configure", + "name": "llvm-debug" + }, + { + "type": "build", + "name": "llvm-debug" + }, + { + "type": "test", + "name": "llvm-debug" + } + ] + }, + { + "name": "llvm-release", + "steps": [ + { + "type": "configure", + "name": "llvm-release" + }, + { + "type": "build", + "name": "llvm-release" + }, + { + "type": "test", + "name": "llvm-release" + } + ] + }, + { + "name": "appleclang-debug", + "steps": [ + { + "type": "configure", + "name": "appleclang-debug" + }, + { + "type": "build", + "name": "appleclang-debug" + }, + { + "type": "test", + "name": "appleclang-debug" + } + ] + }, + { + "name": "appleclang-release", + "steps": [ + { + "type": "configure", + "name": "appleclang-release" + }, + { + "type": "build", + "name": "appleclang-release" + }, + { + "type": "test", + "name": "appleclang-release" + } + ] + }, + { + "name": "msvc-debug", + "steps": [ + { + "type": "configure", + "name": "msvc-debug" + }, + { + "type": "build", + "name": "msvc-debug" + }, + { + "type": "test", + "name": "msvc-debug" + } + ] + }, + { + "name": "msvc-release", + "steps": [ + { + "type": "configure", + "name": "msvc-release" + }, + { + "type": "build", + "name": "msvc-release" + }, + { + "type": "test", + "name": "msvc-release" + } + ] + } + ] +} diff --git a/GNUmakefile b/GNUmakefile new file mode 100644 index 0000000..4739d12 --- /dev/null +++ b/GNUmakefile @@ -0,0 +1,109 @@ +# Standard stuff + +.SUFFIXES: + +MAKEFLAGS+= --no-builtin-rules +MAKEFLAGS+= --warn-undefined-variables + +export hostSystemName=$(shell uname) +export GCOV=llvm-cov gcov +export CPM_USE_LOCAL_PACKAGES=YES + +ifeq (${hostSystemName},Darwin) + export LLVM_PREFIX:=$(shell brew --prefix llvm) + export LLVM_DIR?=$(shell realpath ${LLVM_PREFIX}) + export PATH:=${LLVM_DIR}/bin:${PATH} + export CXX:=clang++ + + # to test g++-15: + #XXX export CXX:=g++-15 + #XXX export CXXFLAGS:=-stdlib=libstdc++ +else ifeq (${hostSystemName},Linux) + export LLVM_DIR?=/usr/lib/llvm-19 + export PATH:=${LLVM_DIR}/bin:${PATH} + export CXX:=clang++-19 +endif + +CPPFILES:= $(shell git ls-files ::*.cpp | grep -vw tests) + +PRESET_NAME?=debug +BUILD_DIR:=build/$(PRESET_NAME) + +.PHONY: all format test check distclean + +all: $(BUILD_DIR) + cmake --workflow --preset $(PRESET_NAME) + +clean: $(BUILD_DIR) + -ninja -C $< $@ + -find $< -name '*.gcda' -delete + +distclean: # XXX clean + rm -rf $(BUILD_DIR) build coverage/* *~ ctags + +$(BUILD_DIR): CMakeLists.txt + -test -f CMakeUserPresets.json || ln -f -s cmake/CMakeUserPresets.json . + cmake --preset $(PRESET_NAME) --log-level=VERBOSE # --fresh + # -test -d build/Debug && ln -f -s $(CURDIR)/build/Debug $(CURDIR)/$(BUILD_DIR) + +check: all + run-clang-tidy -p $(BUILD_DIR) $(CPPFILES) + +fix: all + run-clang-tidy -p $(BUILD_DIR) -fix -checks='-*,\ +hicpp-explicit-conversions,\ +hicpp-member-init,\ +hicpp-named-parameter,\ +modernize-deprecated-headers,\ +modernize-loop-convert,\ +modernize-return-braced-init-list,\ +modernize-use-nodiscard,\ +modernize-use-std-print,\ +modernize-use-trailing-return-type,\ +performance-avoid-endl,\ +performance-unnecessary-value-param,\ +readability-avoid-const-params-in-decls,\ +readability-braces-around-statements,\ +readability-container-data-pointer,\ +readability-container-size-empty,\ +-readability-convert-member-functions-to-static,\ +readability-else-after-return,\ +readability-identifier-naming,\ +readability-implicit-bool-conversion,\ +readability-make-member-function-const,\ +readability-redundant-member-init,\ +readability-simplify-boolean-expr,\ +readability-static-accessed-through-instance,\ +readability-use-concise-preprocessor-directives,\ +readability-use-std-min-max,\ +' \ + $(CPPFILES) + +test: all + # NOTE: simple examples only! + # $(BUILD_DIR)/async_tcp_echo_server 8000 + # cat rrcp.txt | $(BUILD_DIR)/async_tcp_echo_client localhost 8000 + # -$(BUILD_DIR)/ async_tcp_echo_client localhost + # -echo | $(BUILD_DIR)/async_tcp_echo_client localhost 8001 + # -killall async_tcp_echo_server + ctest --test-dir $(BUILD_DIR) --rerun-failed --output-on-failure + gcovr + +format: .clang-format + -codespell + git ls-files ::*.cpp ::*.hpp ::*.json | xargs clang-format -i + git ls-files ::*CMakeLists.txt | xargs gersemi -i --no-warn-about-unknown-commands + git ls-files ::*.py | xargs black + +# These rules keep make from trying to use the match-anything rule below +# to rebuild the makefiles--ouch! + +CMakeLists.txt :: ; +GNUmakefile :: ; +.clang-tidy :: ; +.clang-format :: ; + +# Anything we don't know how to build will use this rule. The command is +# a do-nothing command. +% :: $(BUILD_DIR) + ninja -C $< $@ diff --git a/LICENSE_1_0.txt b/LICENSE_1_0.txt new file mode 100644 index 0000000..36b7cd9 --- /dev/null +++ b/LICENSE_1_0.txt @@ -0,0 +1,23 @@ +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/async_rrcp_client.hpp b/async_rrcp_client.hpp new file mode 100644 index 0000000..447e639 --- /dev/null +++ b/async_rrcp_client.hpp @@ -0,0 +1,312 @@ +#pragma once + +/*** + * async_rrcp_client.hpp + * ~~~~~~~~~~~~~~~~~~~~~ + * + * Copyright (c) 2003-2024 Christopher M. Kohlhoff (chris at kohlhoff dot com) + * + * Distributed under the Boost Software License, Version 1.0. (See accompanying + * file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) + * + * Moderniced from Claus Klein and ChatGPT + ***/ + +#include + +#include +#include // for starts_with +#include // for trim_left, trim_right +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rrcp_helper.hpp" + +namespace rrcp +{ + +using boost::asio::ip::tcp; +using namespace std::chrono_literals; + +constexpr size_t MAX_LENGTH = 65432; +constexpr auto TIMEOUT_DURATION = 3s; +constexpr auto HEARTBEAT_INTERVAL = 10s; + +class async_rrcp_client : public std::enable_shared_from_this< async_rrcp_client > +{ + using message_queue = std::deque< std::string >; + using signal_string_type = boost::signals2::signal< void(std::string) >; + + public: + explicit async_rrcp_client(boost::asio::io_context& io_context) + : io_context_(io_context), socket_(io_context), deadline_(io_context), heartbeat_timer_(io_context) + { + deadline_.expires_at(boost::asio::steady_timer::time_point::max()); + } + + void start(const tcp::resolver::results_type& endpoints) + { + deadline_.expires_after(TIMEOUT_DURATION); + check_deadline(); + + boost::asio::async_connect(socket_, endpoints, + [self = shared_from_this()](const boost::system::error_code& ec, const tcp::endpoint&) -> void + { + if (!ec) + { + fmt::print(stderr, "Connected to server.\n"); // TRACE + self->connected_ = true; + self->do_read(); + self->send_heartbeat(); + } + else + { + fmt::print(stderr, "Failed to connect: {}\n", ec.message()); + } + }); + } + + void register_trap_handler(const std::function< void(std::string) >& handler) { trap_handler_.connect(handler); } + + [[nodiscard]] auto connected() const -> bool { return connected_; } + + // This function write the message into the send msg queue and starts the write actor. + // It wait for the response message and return this. + // + // TODO(CK): we should have two input strings: the MIB name and the command string! + // + [[nodiscard]] auto write(const std::string& message) -> std::string + { + while (!connected_) + { + if (stopped_) + { + return {}; + } + + fmt::print(stderr, "Client is not connected yet.\n"); // TRACE + std::this_thread::sleep_for(TIMEOUT_DURATION); + } + + std::string msg_id_str; + msg_id_ = ++msg_id_ % INVALID_ID; + auto command = rrcp::create_command_msg(message, msg_id_str, msg_id_); + + boost::asio::post(io_context_, + [this, command]() -> void + { + bool const write_in_progress{!write_msgs_.empty()}; + write_msgs_.push_back(command); + + if (!write_in_progress) + { + deadline_.expires_after(TIMEOUT_DURATION); + do_write(); + } + }); + + return read(msg_id_str); + } + + // This function try to read the response message from the receive msg queue + auto read(const std::string& msg_id) -> std::string + { + std::string response; + auto count = TIMEOUT_DURATION / 125ms; + + do + { + boost::asio::post(io_context_, + [this, &response]() -> void + { + if (!read_msgs_.empty()) + { + response = read_msgs_.front(); + read_msgs_.pop_front(); + } + }); + + if (!response.empty()) + { + // helper which returns true if the msg with matching msg_id was found + if (rrcp::find_response_msg(response, msg_id)) + { + break; + } + } + std::this_thread::sleep_for(125ms); + } while (!stopped_ && --count); + if (!count) + { + fmt::print(stderr, "Error: Timeout read!\n"); + } + + return response; + } + + void stop() + { + if (stopped_) + { + return; + } + + boost::asio::post(io_context_, + [this, self = shared_from_this()]() -> void + { + fmt::print(stderr, "Stopped, disconnecting ...\n"); + stopped_ = true; + connected_ = false; + + boost::system::error_code ec; + socket_.close(ec); + heartbeat_timer_.cancel(); + deadline_.cancel(); + }); + } + + private: + void do_read() + { + boost::asio::async_read_until(socket_, boost::asio::dynamic_buffer(input_buffer_), STOP, + [self = shared_from_this()](const boost::system::error_code& ec, std::size_t length) -> void + { + if (!ec) + { + //========================== RRCP ============================ + std::string line = esc2char(self->input_buffer_.substr(1, length - 1)); // w/o START, STOP + self->input_buffer_.erase(0, length); + //========================== END ============================ + + // TODO(CK): maby refactored to helper class? + //========================== RRCP ============================ + // Process different message types + if (boost::algorithm::starts_with(line, "d")) // Trap data message + { + // Handle trap data messages + fmt::print(stderr, "trap data: {}\n", line); // TRACE + self->trap_handler_(line); + } + else if (!boost::algorithm::starts_with(line, "gPing")) + { + // Other responses than Trap and Ping messages + fmt::print(stderr, "{}\n", line); // TRACE + self->read_msgs_.push_back(line); + } + //========================== END ============================ + + self->deadline_.expires_after(HEARTBEAT_INTERVAL + TIMEOUT_DURATION); + self->do_read(); + } + else + { + fmt::print(stderr, "Error: reading message: {}\n", ec.message()); + self->stop(); + } + }); + } + + void do_write() + { + boost::asio::async_write(socket_, boost::asio::buffer(write_msgs_.front()), + [self = shared_from_this()](boost::system::error_code ec, std::size_t /*length*/) -> void + { + if (!ec) + { + self->write_msgs_.pop_front(); + if (!self->write_msgs_.empty()) + { + self->do_write(); + } + } + else + { + // There are no more endpoints to try. Shut down the client. + fmt::print(stderr, "Error: writing message: {}\n", ec.message()); + self->stop(); + } + }); + } + + void send_heartbeat() + { + if (stopped_) + { + return; + } + + //========================== RRCP ============================ + std::string heartbeat_message{START + char2esc("M:Utility GPing\"async client\"") + STOP}; + //========================== END ============================ + + fmt::print(stderr, "Send heartbeat: {}\n", heartbeat_message); // TRACE + boost::asio::async_write(socket_, boost::asio::buffer(heartbeat_message), + [self = shared_from_this()](const boost::system::error_code& ec, std::size_t) -> void + { + if (!ec) + { + self->heartbeat_timer_.expires_after(HEARTBEAT_INTERVAL); + self->heartbeat_timer_.async_wait( + [self](const boost::system::error_code&) -> void { self->send_heartbeat(); }); + } + else + { + fmt::print(stderr, "Error: sending heartbeat: {}\n", ec.message()); + self->stop(); + } + }); + } + + void check_deadline() + { + if (stopped_) + { + return; + } + + if (deadline_.expiry() <= boost::asio::steady_timer::clock_type::now()) + { + fmt::print(stderr, "No response from server, stopping ...\n"); + stop(); + return; + } + + deadline_.async_wait( + [self = shared_from_this()](const boost::system::error_code&) -> void { self->check_deadline(); }); + } + + boost::asio::io_context& io_context_; + tcp::socket socket_; + boost::asio::steady_timer deadline_; + boost::asio::steady_timer heartbeat_timer_; + + // I/O buffers (protected by io_context) + std::string input_buffer_; + message_queue read_msgs_; + message_queue write_msgs_; + + // Thread-safe state + std::atomic< bool > connected_{false}; + std::atomic< bool > stopped_{false}; + std::atomic< int > msg_id_{10000}; + + // Signal handling + signal_string_type trap_handler_; +}; + +} // namespace rrcp diff --git a/async_rrcp_client_threadsafe.hpp b/async_rrcp_client_threadsafe.hpp new file mode 100644 index 0000000..3336bd5 --- /dev/null +++ b/async_rrcp_client_threadsafe.hpp @@ -0,0 +1,486 @@ +#pragma once + +/*** + * async_rrcp_client_threadsafe.hpp + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * + * Copyright (c) 2003-2024 Christopher M. Kohlhoff (chris at kohlhoff dot com) + * + * Distributed under the Boost Software License, Version 1.0. (See accompanying + * file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) + * + * Moderniced from Claus Klein and ChatGPT + * Thread-safe implementation with preserved interface + ***/ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rrcp_helper.hpp" + +namespace rrcp +{ + +using boost::asio::ip::tcp; +using namespace std::chrono_literals; + +constexpr size_t MAX_LENGTH = 65432; +constexpr auto TIMEOUT_DURATION = 3s; +constexpr auto HEARTBEAT_INTERVAL = 10s; + +class async_rrcp_client : public std::enable_shared_from_this< async_rrcp_client > +{ + using message_queue = std::deque< std::string >; + using signal_string_type = boost::signals2::signal< void(std::string) >; + using response_promise_type = std::promise< std::string >; + + public: + explicit async_rrcp_client(boost::asio::io_context& io_context) + : io_context_(io_context), + strand_(boost::asio::make_strand(io_context)), + socket_(strand_), + deadline_(strand_), + heartbeat_timer_(strand_) + { + deadline_.expires_at(boost::asio::steady_timer::time_point::max()); + } + + void start(const tcp::resolver::results_type& endpoints) + { + boost::asio::post(strand_, + [this, endpoints, self = shared_from_this()]() -> void + { + deadline_.expires_after(TIMEOUT_DURATION); + check_deadline(); + + boost::asio::async_connect(socket_, endpoints, + [self](const boost::system::error_code& ec, const tcp::endpoint&) -> void + { + if (!ec) + { + fmt::print(stderr, "Connected to server.\n"); // TRACE + self->connected_.store(true); + self->notify_connection_waiters(); + self->do_read(); + self->send_heartbeat(); + } + else + { + fmt::print(stderr, "Failed to connect: {}\n", ec.message()); + self->notify_connection_waiters(); + } + }); + }); + } + + void register_trap_handler(const std::function< void(std::string) >& handler) + { + boost::asio::post(strand_, [this, handler, self = shared_from_this()]() -> void { trap_handler_.connect(handler); }); + } + + [[nodiscard]] auto connected() const -> bool { return connected_.load(); } + + // Thread-safe synchronous write with preserved interface + [[nodiscard]] auto write(const std::string& message) -> std::string + { + auto promise = std::make_shared< response_promise_type >(); + auto future = promise->get_future(); + + boost::asio::post(strand_, + [this, message, promise, self = shared_from_this()]() -> void + { + if (!connected_.load()) + { + // Queue request until connected + pending_writes_.emplace_back(message, promise); + return; + } + + execute_write_request(message, promise); + }); + + // Wait for response with timeout + if (future.wait_for(TIMEOUT_DURATION) == std::future_status::timeout) + { + fmt::print(stderr, "Error: Timeout {}!\n", __func__); + return {}; // Timeout - return empty string + } + + try + { + const auto response = future.get(); + fmt::print(stderr, "Returning {}\n", response); // TRACE + return response; + } + catch (const std::exception&) + { + fmt::print(stderr, "Exception: {}!\n", __func__); + return {}; // Error - return empty string + } + } + + void stop() + { + if (stopped_.load()) + { + return; + } + + boost::asio::post(strand_, + [this, self = shared_from_this()]() -> void + { + fmt::print(stderr, "Stopped, disconnecting ...\n"); // TRACE + stopped_.store(true); + connected_.store(false); + + boost::system::error_code ec; + socket_.close(ec); + heartbeat_timer_.cancel(); + deadline_.cancel(); + + // Clear pending operations and notify waiters + clear_pending_operations(); + notify_connection_waiters(); + }); + } + + private: +#define USE_PEDANTIC_CHECKES +#ifdef USE_PEDANTIC_CHECKES + // Helper method to safely parse RRCP message with bounds checking + static auto parse_rrcp_message(const std::string& buffer, std::size_t length, std::string& parsed_line) -> bool + { + // Minimum RRCP message: START + at least 1 char + STOP = 3 bytes + if (length < 3) + { + fmt::print(stderr, "Warning: Message too short (length={}) - expected minimum 3 bytes\n", length); + return false; + } + + // Validate buffer size + if (buffer.size() < length) + { + fmt::print(stderr, "Error: Buffer size ({}) smaller than expected length ({})\n", buffer.size(), length); + return false; + } + + // Check for START delimiter at beginning + if (buffer[0] != START) + { + fmt::print(stderr, "Warning: Missing START delimiter (found 0x{:02X})\n", static_cast< unsigned char >(buffer[0])); + return false; + } + + // Check for STOP delimiter at expected position + if (buffer[length - 1] != STOP) + { + fmt::print(stderr, "Warning: Missing STOP delimiter at position {} (found 0x{:02X})\n", length - 1, + static_cast< unsigned char >(buffer[length - 1])); + return false; + } + + // Extract message content (without START and STOP) + if (length >= 3) + { + parsed_line = esc2char(buffer.substr(1, length - 2)); + return true; + } + + return false; + } +#endif + + void execute_write_request(const std::string& message, std::shared_ptr< response_promise_type > promise) + { + std::string msg_id_str; + int current_id = next_message_id_.fetch_add(1); + auto command = rrcp::create_command_msg(message, msg_id_str, current_id); + + // Store promise for response correlation + pending_responses_[msg_id_str] = std::move(promise); + + bool const write_in_progress{!write_msgs_.empty()}; + write_msgs_.push_back(command); + + if (!write_in_progress) + { + deadline_.expires_after(TIMEOUT_DURATION); + do_write(); + } + } + + void notify_connection_waiters() + { + // Process pending writes that were waiting for connection + for (auto& [message, promise] : pending_writes_) + { + if (connected_.load()) + { + execute_write_request(message, promise); + } + else + { + // Connection failed - fulfill promise with empty response + try + { + promise->set_value(""); + } + // NOLINTNEXTLINE(bugprone-empty-catch) + catch (const std::exception&) + { + // Promise already fulfilled - ignore + fmt::print(stderr, "Exception: {}!\n", __func__); + } + } + } + pending_writes_.clear(); + } + + void clear_pending_operations() + { + // Fulfill all pending promises with empty responses + for (auto& [msg_id, promise] : pending_responses_) + { + try + { + promise->set_value(""); + } + // NOLINTNEXTLINE(bugprone-empty-catch) + catch (const std::exception&) + { + // Promise already fulfilled - ignore + fmt::print(stderr, "Exception: {}!\n", __func__); + } + } + pending_responses_.clear(); + + for (auto& [message, promise] : pending_writes_) + { + try + { + promise->set_value(""); + } + // NOLINTNEXTLINE(bugprone-empty-catch) + catch (const std::exception&) + { + // Promise already fulfilled - ignore + fmt::print(stderr, "Exception: {}!\n", __func__); + } + } + pending_writes_.clear(); + + write_msgs_.clear(); + } + + void do_read() + { + boost::asio::async_read_until(socket_, boost::asio::dynamic_buffer(input_buffer_), STOP, + [self = shared_from_this()](const boost::system::error_code& ec, std::size_t length) -> void + { + if (!ec) + { + //========================== RRCP ============================ +#ifdef USE_PEDANTIC_CHECKES + std::string parsed_line; + // Use safe parsing helper with comprehensive bounds checking + if (!rrcp::async_rrcp_client::parse_rrcp_message(self->input_buffer_, length, parsed_line)) + { + // Parsing failed - message was malformed, skip it + self->input_buffer_.erase(0, length); + self->deadline_.expires_after(HEARTBEAT_INTERVAL + TIMEOUT_DURATION); + self->do_read(); + return; + } +#else + std::string parsed_line = esc2char(self->input_buffer_.substr(1, length - 1)); // TODO(CK): check START, STOP? +#endif + // Successfully parsed, remove processed data from buffer + self->input_buffer_.erase(0, length); + //========================== END ============================ + +#ifdef USE_PEDANTIC_CHECKES + // Validate parsed content is not empty + if (parsed_line.empty()) + { + fmt::print(stderr, "Warning: Parsed empty message content - skipping\n"); + self->deadline_.expires_after(HEARTBEAT_INTERVAL + TIMEOUT_DURATION); + self->do_read(); + return; + } +#endif + + // TODO(CK): maby refactored to helper class? + //========================== RRCP ============================ + // Process different message types + if (boost::algorithm::starts_with(parsed_line, "d")) // Trap data message + { + // Handle trap data messages + fmt::print(stderr, "trap data: {}\n", parsed_line); // TRACE + self->trap_handler_(parsed_line); + } + else if (!boost::algorithm::starts_with(parsed_line, "gPing")) + { + // Handle response messages (but ignore heartbeat responses) + fmt::print(stderr, "{}\n", parsed_line); // TRACE + self->handle_response(parsed_line); + } + // Note: gPing messages are silently ignored (heartbeat responses) + //========================== END ============================ + + self->deadline_.expires_after(HEARTBEAT_INTERVAL + TIMEOUT_DURATION); + self->do_read(); + } + else + { + fmt::print(stderr, "Error: reading message: {}\n", ec.message()); + self->stop(); + } + }); + } + + void handle_response(const std::string& response) + { + // Find matching pending response + for (auto it = pending_responses_.begin(); it != pending_responses_.end(); ++it) + { + std::string clean_response = response; + if (rrcp::find_response_msg(clean_response, it->first)) + { + auto promise = it->second; + pending_responses_.erase(it); + + // Fulfill promise with response + try + { + promise->set_value(clean_response); + } + // NOLINTNEXTLINE(bugprone-empty-catch) + catch (const std::exception&) + { + // Promise already fulfilled - ignore + fmt::print(stderr, "Exception: {}!\n", __func__); + } + return; + } + } + } + + void do_write() + { + boost::asio::async_write(socket_, boost::asio::buffer(write_msgs_.front()), + [self = shared_from_this()](boost::system::error_code ec, std::size_t /*length*/) -> void + { + if (!ec) + { + self->write_msgs_.pop_front(); + if (!self->write_msgs_.empty()) + { + self->do_write(); + } + } + else + { + // There are no more endpoints to try. Shut down the client. + fmt::print(stderr, "Error: writing message: {}\n", ec.message()); + self->stop(); + } + }); + } + + void send_heartbeat() + { + if (stopped_.load()) + { + return; + } + + //========================== RRCP ============================ + std::string heartbeat_message{START + char2esc("M:Utility GPing\"async client\"") + STOP}; + //========================== END ============================ + + fmt::print(stderr, "Send heartbeat: {}\n", heartbeat_message); // TRACE + boost::asio::async_write(socket_, boost::asio::buffer(heartbeat_message), + [self = shared_from_this()](const boost::system::error_code& ec, std::size_t) -> void + { + if (!ec) + { + self->heartbeat_timer_.expires_after(HEARTBEAT_INTERVAL); + self->heartbeat_timer_.async_wait( + [self](const boost::system::error_code&) -> void { self->send_heartbeat(); }); + } + else + { + fmt::print(stderr, "Error: sending heartbeat: {}\n", ec.message()); + self->stop(); + } + }); + } + + void check_deadline() + { + if (stopped_.load()) + { + return; + } + + if (deadline_.expiry() <= boost::asio::steady_timer::clock_type::now()) + { + fmt::print(stderr, "No response from server, stopping ...\n"); + stop(); + return; + } + + deadline_.async_wait( + [self = shared_from_this()](const boost::system::error_code&) -> void { self->check_deadline(); }); + } + + // Core networking components + boost::asio::io_context& io_context_; + boost::asio::strand< boost::asio::io_context::executor_type > strand_; + tcp::socket socket_; + boost::asio::steady_timer deadline_; + boost::asio::steady_timer heartbeat_timer_; + + // I/O buffers (protected by strand) + std::string input_buffer_; + message_queue write_msgs_; + + // Thread-safe state + std::atomic< bool > connected_{false}; + std::atomic< bool > stopped_{false}; + std::atomic< int > next_message_id_{10000}; + + // Response correlation system (protected by strand) + std::unordered_map< std::string, std::shared_ptr< response_promise_type > > pending_responses_; + std::vector< std::pair< std::string, std::shared_ptr< response_promise_type > > > pending_writes_; + + // Signal handling + signal_string_type trap_handler_; +}; + +} // namespace rrcp diff --git a/cmake/CMakeUserPresets.json b/cmake/CMakeUserPresets.json new file mode 100644 index 0000000..331c063 --- /dev/null +++ b/cmake/CMakeUserPresets.json @@ -0,0 +1,107 @@ +{ + "version": 9, + "cmakeMinimumRequired": { + "major": 3, + "minor": 30, + "patch": 0 + }, + "include": [ + "cmake/presets/CMake${hostSystemName}Presets.json" + ], + "buildPresets": [ + { + "name": "debug", + "configurePreset": "debug", + "configuration": "Debug", + "targets": [ + "all" + ] + }, + { + "name": "release", + "configurePreset": "release", + "configuration": "Release", + "targets": [ + "all_verify_interface_header_sets", + "all" + ] + } + ], + "testPresets": [ + { + "name": "test_base", + "hidden": true, + "output": { + "outputOnFailure": true + }, + "execution": { + "noTestsAction": "error", + "stopOnFailure": false + } + }, + { + "name": "debug", + "inherits": "test_base", + "configuration": "Debug", + "configurePreset": "debug" + }, + { + "name": "release", + "inherits": "test_base", + "configuration": "Release", + "configurePreset": "release" + } + ], + "packagePresets": [ + { + "name": "release", + "configurePreset": "release", + "configurations": [ + "Release" + ], + "generators": [ + "TGZ" + ] + } + ], + "workflowPresets": [ + { + "name": "debug", + "steps": [ + { + "type": "configure", + "name": "debug" + }, + { + "type": "build", + "name": "debug" + }, + { + "type": "test", + "name": "debug" + } + ] + }, + { + "name": "release", + "steps": [ + { + "type": "configure", + "name": "release" + }, + { + "type": "build", + "name": "release" + }, + { + "type": "test", + "name": "release" + }, + { + "type": "package", + "name": "release" + } + ] + } + ] +} diff --git a/cmake/CPM.cmake b/cmake/CPM.cmake new file mode 100644 index 0000000..bc61a2e --- /dev/null +++ b/cmake/CPM.cmake @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MIT +# +# SPDX-FileCopyrightText: Copyright (c) 2019-2023 Lars Melchior and contributors + +set(CPM_DOWNLOAD_VERSION 0.42.0) +set(CPM_HASH_SUM + "2020b4fc42dba44817983e06342e682ecfc3d2f484a581f11cc5731fbe4dce8a" +) + +if(CPM_SOURCE_CACHE) + set(CPM_DOWNLOAD_LOCATION + "${CPM_SOURCE_CACHE}/cpm/CPM_${CPM_DOWNLOAD_VERSION}.cmake" + ) +elseif(DEFINED ENV{CPM_SOURCE_CACHE}) + set(CPM_DOWNLOAD_LOCATION + "$ENV{CPM_SOURCE_CACHE}/cpm/CPM_${CPM_DOWNLOAD_VERSION}.cmake" + ) +else() + set(CPM_DOWNLOAD_LOCATION + "${CMAKE_BINARY_DIR}/cmake/CPM_${CPM_DOWNLOAD_VERSION}.cmake" + ) +endif() + +# Expand relative path. This is important if the provided path contains a tilde (~) +get_filename_component(CPM_DOWNLOAD_LOCATION ${CPM_DOWNLOAD_LOCATION} ABSOLUTE) + +file( + DOWNLOAD + https://github.com/cpm-cmake/CPM.cmake/releases/download/v${CPM_DOWNLOAD_VERSION}/CPM.cmake + ${CPM_DOWNLOAD_LOCATION} + EXPECTED_HASH SHA256=${CPM_HASH_SUM} +) + +include(${CPM_DOWNLOAD_LOCATION}) diff --git a/cmake/presets/CMakeDarwinPresets.json b/cmake/presets/CMakeDarwinPresets.json new file mode 100644 index 0000000..78efdf4 --- /dev/null +++ b/cmake/presets/CMakeDarwinPresets.json @@ -0,0 +1,48 @@ +{ + "version": 6, + "include": [ + "CMakeGenericPresets.json" + ], + "configurePresets": [ + { + "name": "debug-base-Darwin", + "hidden": true, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + } + }, + { + "name": "release-base-Darwin", + "hidden": true, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + } + }, + { + "name": "debug", + "displayName": "Debug Build", + "inherits": [ + "root-config", + "debug-base-Darwin" + ] + }, + { + "name": "release", + "displayName": "Release Build", + "inherits": [ + "root-config", + "release-base-Darwin" + ] + } + ] +} diff --git a/cmake/presets/CMakeGenericPresets.json b/cmake/presets/CMakeGenericPresets.json new file mode 100644 index 0000000..2f6711b --- /dev/null +++ b/cmake/presets/CMakeGenericPresets.json @@ -0,0 +1,34 @@ +{ + "version": 6, + "configurePresets": [ + { + "name": "root-config", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "installDir": "${sourceDir}/stagedir", + "cacheVariables": { + "CMAKE_PREFIX_PATH": { + "type": "path", + "value": "${sourceDir}/stagedir" + }, + "CMAKE_CXX_EXTENSIONS": true, + "CMAKE_CXX_STANDARD": "23", + "CMAKE_CXX_STANDARD_REQUIRED": true, + "CMAKE_EXPORT_COMPILE_COMMANDS": true, + "CMAKE_SKIP_TEST_ALL_DEPENDENCY": false + }, + "warnings": { + "dev": true, + "deprecated": true, + "uninitialized": true, + "unusedCli": true, + "systemVars": false + }, + "errors": { + "dev": false, + "deprecated": true + } + } + ] +} diff --git a/cmake/presets/CMakeLinuxPresets.json b/cmake/presets/CMakeLinuxPresets.json new file mode 100644 index 0000000..7a91735 --- /dev/null +++ b/cmake/presets/CMakeLinuxPresets.json @@ -0,0 +1,48 @@ +{ + "version": 6, + "include": [ + "CMakeGenericPresets.json" + ], + "configurePresets": [ + { + "name": "debug-base-Linux", + "hidden": true, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + }, + { + "name": "release-base-Linux", + "hidden": true, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + }, + { + "name": "debug", + "displayName": "Debug Build", + "inherits": [ + "root-config", + "debug-base-Linux" + ] + }, + { + "name": "release", + "displayName": "Release Build", + "inherits": [ + "root-config", + "release-base-Linux" + ] + } + ] +} diff --git a/cmake/presets/CMakeWindowsPresets.json b/cmake/presets/CMakeWindowsPresets.json new file mode 100644 index 0000000..d8834f2 --- /dev/null +++ b/cmake/presets/CMakeWindowsPresets.json @@ -0,0 +1,32 @@ +{ + "version": 6, + "include": [ + "CMakeGenericPresets.json" + ], + "configurePresets": [ + { + "name": "release", + "description": "Windows preset for library developers", + "generator": "Ninja Multi-Config", + "binaryDir": "${sourceDir}/build", + "inherits": [ + "root-config" + ], + "cacheVariables": { + "CMAKE_CXX_COMPILER": "cl" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "debug", + "description": "Windows preset for library developers", + "inherits": [ + "release" + ] + } + ] +} diff --git a/coverage/.keep b/coverage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..cc8cd48 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,8 @@ +*.jar +*.interp +*.tokens +*Parser.* +*Lexer.* +*Listener.* +*.class +__pycache__ diff --git a/docs/GNUmakefile b/docs/GNUmakefile new file mode 100644 index 0000000..fb7b583 --- /dev/null +++ b/docs/GNUmakefile @@ -0,0 +1,24 @@ +PYTHONPATH:=$(CURDIR):${PYTHONPATH} +export PYTHONPATH + +ANTLR_HOME?=$(CURDIR) +CLASSPATH:="${ANTLR_HOME}/antlr-4.13.0-complete.jar:${CLASSPATH}" +export CLASSPATH + +ANTLR4:=java -jar ${ANTLR_HOME}/antlr-4.13.2-complete.jar + +.PHONY: all test format clean +all: rrcpParser.py rrcpLexer.py + +rrcpParser.py rrcpLexer.py: rrcp.g4 + $(ANTLR4) -Dlanguage=Python3 $< + +format: all + black *.py + +test: format + pygrun --trace rrcp rrcp rrcp.txt + pygrun --trace rrcp rrcp ../rrcp.txt + +clean: + $(RM) -r *.interp *.tokens *Parser.* *Lexer.* *Listener.* *.class *~ __pycache__ diff --git a/docs/rrcp-generate.md b/docs/rrcp-generate.md new file mode 100644 index 0000000..abc5cae --- /dev/null +++ b/docs/rrcp-generate.md @@ -0,0 +1,65 @@ +# RRCP with ANTLR + +To test the ANTLR grammar with Python, you'll need to follow several steps to set up your environment, generate the parser +and lexer, and then write a Python script to test the grammar. Below are the detailed steps to accomplish this: + +## Step 1: Install ANTLR + +### Download ANTLR: + +Download the ANTLR jar file from the [ANTLR](https://www.antlr.org/download.html) website. + + wget https://www.antlr.org/download/antlr-4.13.2-complete.jar + +### Set Up ANTLR in Your Environment: + +You can place the ANTLR jar file in a directory of your choice. +For example, let's say you place it in `~/antlr/antlr-4.13.0-complete.jar`. + +Set Up Environment Variables (Optional but recommended)! + +You can set an environment variable for ANTLR. Add the following lines to your `~/.bashrc` or `~/.bash_profile` (or +equivalent for your shell): + + export ANTLR_HOME=~/antlr + export CLASSPATH="$ANTLR_HOME/antlr-4.13.0-complete.jar:$CLASSPATH" + alias antlr4='java -jar $ANTLR_HOME/antlr-4.13.0-complete.jar' + alias grun='java org.antlr.v4.gui.TestRig' + +Reload your shell: + + source ~/.bashrc # or source ~/.bash_profile + +## Step 2: Install Python and Required Libraries + +### Install Python. + +Make sure you have Python 3 installed. You can check by running: + + python3 --version + +### Install ANTLR4 Python Runtime. + +Use pip to install the ANTLR4 runtime for Python: + + pip install antlr4-python3-runtime + +## Step 3: Generate Lexer and Parser + +### Create a File for Your Grammar. + +Save your ANTLR grammar in a file named `rrcp.g4`. + +### Generate the Lexer and Parser. + +Run the following command in the terminal to generate the lexer and parser: + + antlr4 rrcp.g4 -Dlanguage=Python3 + +This will generate several Python files in the same directory, including `rrcpLexer.py`, `rrcpParser.py`, and others. + +## Step 4: Write a Test Script + +see [rrcp.py](rrcp.py) + +and [GNUmakefile](GNUmakefile) diff --git a/docs/rrcp.g4 b/docs/rrcp.g4 new file mode 100644 index 0000000..9ae3beb --- /dev/null +++ b/docs/rrcp.g4 @@ -0,0 +1,152 @@ +// +// Remote Radio Control Protocol (RRCP) +// +// Define a grammar called rrcp +grammar rrcp; + +// +// rules: +// + +//TODO(CK): rrcp: (LF MU CR)+ EOF ; +rrcp : (Newline* MU Newline?)+ EOF ; + +//TODO(CK): MU: TU (SP TU)* ; +// NOTE: with optional LineComment for test +MU + : MIB_PATH SP Optional? TU (SP TU)* LineComment? + | Optional? RESP_TU (SP RESP_TU)* LineComment? + | MuErrorStatus SP? Optional? LineComment? + | SP? LineComment + ; + +MIB_PATH: + 'M:' ALPHANUM ('.' ALPHANUM)* + ; + +// NOTE: the LogicalAddress must not used with RESP_TU? +fragment +Optional: + (LogicalAddress SP)? (MessageID SP)? + ; + +LogicalAddress: + 'L:' NUM + ; + +MessageID: + NUM + ; + +TU + : REQ SP* CU // with optional space before CU! + ; + +RESP_TU + : RESP SP* TuErrorStatus CMD + | RESP SP* CU + | ACK // NOTE: trap or set response without CU! + ; + +REQ: 'G' | 'S' | 'T' ; + +RESP: 'g' | 's' | 'd' ; + +ACK: [ts] ; + +MuErrorStatus: + 'E:' NUM + ; + +TuErrorStatus: + NUM + ; + +CU + : CMD PU? (';' CMD PU?)* + ; + +CMD: + ALPHA + ; + +fragment +PU: + SP* PARAMETER (',' PARAMETER)* // with optional space after command! + ; + +PARAMETER + : STRING + | INT + | FLOAT + | BINARY + ; + +BINARY + : '#' NUM ':' Base64Digit+ + ; + +fragment +Base64Digit + : [0-9a-zA-Z+/=] + ; + +//XXX STRING : '"'~('"')*'"' ; // NOTE: without EscapeSequence +STRING : StringLiteral ; + +fragment +EscapeSequence + : SimpleEscapeSequence + ; + +fragment +SimpleEscapeSequence + : '\\' ['"?abfnrtv\\] + ; + +StringLiteral + : '"' SCharSequence? '"' + ; + +fragment +SCharSequence + : SChar+ + ; + +fragment +SChar + : ~["\\\n] + | EscapeSequence + | '\\\n' // Added line + ; + +Newline + : [\n] + -> skip + ; + +LineComment + : '//' ~[\n]* + -> skip + ; + +//====================================================== +fragment +NUM : [0-9]+ ; // match unsigned decimal numbers +fragment +INT : [+-]?[0-9]+ ; // signed decimal numbers +fragment +FLOAT : [+-]?[0-9]+'.'[0-9]+ ; // rational numbers +fragment +ALPHA : [a-zA-Z]+ ; // match alpha identifiers +fragment +ALPHANUM : [0-9a-zA-Z]+ ; // match alphaNum words +//XXX NO! WS : [ \t\r\n]+ -> skip ; // skip spaces, tabs, newlines +SP : ' ' -> skip ; +//TODO(CK): CR : '\n' ; # 0x0d +//TODO(CK): LF : '\r' ; # 0x0a +//====================================================== + +// +// vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 syntax=antlr +// diff --git a/docs/rrcp.py b/docs/rrcp.py new file mode 100755 index 0000000..7ad1464 --- /dev/null +++ b/docs/rrcp.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 + +import sys +from antlr4 import * +from rrcpLexer import rrcpLexer +from rrcpParser import rrcpParser + + +def main(argv): + input = FileStream(argv[1]) + lexer = rrcpLexer(input) + stream = CommonTokenStream(lexer) + parser = rrcpParser(stream) + tree = parser.rrcp() // startRule + + print(tree.toStringTree(recog=parser)) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/docs/rrcp.txt b/docs/rrcp.txt new file mode 100644 index 0000000..2eed4d3 --- /dev/null +++ b/docs/rrcp.txt @@ -0,0 +1,27 @@ +// GET-request TU SET-request TU GET-request TU: +M:Test GFRQ;MOD SFRQ10000000;MOD13;BW80000 GFRQ;MOD + +// GET-response TU SET-response TU GET-response TU: +gFRQ18000000;MOD12 sBW gFRQ18000000;MOD12 +// NOTE: +// There is an error within the BW command, the complete SET-request TU is cancelled. +// The GET-request TU is replied by the corresponding GET-response TU. + +M:WF.FF.Main 123456 T Octet 1 // TRAP command with optional message number, but without Logical Address: +123456 t // TRAP acknowlage +M:Bit L:1 123456 G Octet +M:Audio SOctet // SET without optionl parts +M:Radio SString"\rHallo\t\"World\"\n" // quoted string with escape chars +M:Log SStruct1,-1,3.14 // multiple parameters + +M:MultilCmd S Octet 1;Long-1;String"Hallo World\r\n";Struct 1,+1,+3.14 // multiple commands's + +M:Test 123456 S FREQ123456;MOD12;BW80000 G FREQ;MOD;STATUS // multiple TU with GET and SET! +123456 gFREQ180000;MOD12 s5BW gFREQ180000;MOD12;STATUS"Running" // TU partly failed! + +M:RADIO S FREQUENCY 123456789 +E:12 // MU error +M:RADIO 112368 T FREQUENCY 1 +112368 t +112368 d FREQUENCY 123456789 // trap data +M:Utility S Binary #36:AAECAwQFBgcICQoLDA0OD8KAwoHDvsO/Cg== // bas64 encoded binary data diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 0000000..8ee6a28 --- /dev/null +++ b/examples/CMakeLists.txt @@ -0,0 +1,61 @@ +cmake_minimum_required(VERSION 3.25...4.2) + +project(Base64-examples VERSION 0.1.1 LANGUAGES CXX) + +find_package(Threads) + +find_package(Poco 1.14 COMPONENTS Foundation REQUIRED) + +if(NOT TARGET Boost::headers) + find_package(Boost) +endif() + +if(NOT TARGET fmt::fmt-header-only) + find_package(fmt 12 REQUIRED HINTS $ENV{HOME}/.local) +endif() + +enable_testing() + +add_executable(base64decode base64decode.cpp) +target_link_libraries(base64decode PUBLIC Poco::Foundation) + +add_executable(base64encode base64encode.cpp) +target_link_libraries(base64encode PUBLIC Poco::Foundation) + +if(UNIX) + add_test( + NAME base64-test + COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/test.sh + WORKING_DIRECTORY ${CMAKE__CURRENT_BINARY_DIR} + ) +endif() + +if(PROJECT_IS_TOP_LEVEL) + return() +endif() + +do_test(base64decode --help input) +do_test(base64encode --help input) + +if(APPLE AND NOT ENABLE_TEST_COVERAGE) + add_executable(async_tcp_client_v20 async_tcp_client_v20.cpp) + target_link_libraries( + async_tcp_client_v20 + PUBLIC Boost::headers fmt::fmt-header-only + ) + do_test(async_tcp_client_v20 --help Usage) + + add_executable(async_tcp_client async_tcp_client.cpp) + target_link_libraries( + async_tcp_client + PUBLIC Boost::headers fmt::fmt-header-only + ) + do_test(async_tcp_client --help Usage) + + add_executable(blocking_tcp_echo_server blocking_tcp_echo_server.cpp) + target_link_libraries( + blocking_tcp_echo_server + PUBLIC Threads::Threads Boost::headers + ) + # TODO(CK): do_test(blocking_tcp_echo_server port Usage) +endif() diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..65c3b31 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,24 @@ +# Usage examples + +## Build with CMake + + cmake -S . -G build -G Ninja + cd build + ninja + +## Tests + + echo '!@#$%^&*()_~<>' > base64.dat + cat base64.dat | ./base64encode - > base64.txt + cat base64.dat | ./base64encode - | ./base64decode - | diff base64.dat - + ./base64encode base64.dat base64.txt + ./base64decode base64.txt base64.dat + +hexdump -C base64.dat + + 00000000 21 40 23 24 25 5e 26 2a 28 29 5f 7e 3c 3e 0a |!@#$%^&*()_~<>.| + 0000000f + +cat base64.txt + + IUAjJCVeJiooKV9+PD4K diff --git a/examples/async_tcp_client.cpp b/examples/async_tcp_client.cpp new file mode 100644 index 0000000..ad35df6 --- /dev/null +++ b/examples/async_tcp_client.cpp @@ -0,0 +1,332 @@ +// +// async_tcp_client.cpp +// ~~~~~~~~~~~~~~~~~~~~ +// +// Copyright (c) 2003-2024 Christopher M. Kohlhoff (chris at kohlhoff dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Moderniced from Claus Klein and ChatGPT + +#include +#include +#include +#include +#include +#include +#include +#include // NOLINT(misc-include-cleaner) +#include +#include +#include +#include +#include +#include +#include + +using boost::asio::steady_timer; +using boost::asio::ip::tcp; +using namespace std::chrono_literals; + +// +// This class manages socket timeouts by applying the concept of a deadline. +// Some asynchronous operations are given deadlines by which they must complete. +// Deadlines are enforced by an "actor" that persists for the lifetime of the +// client object: +// +// +----------------+ +// | | +// | check_deadline |<---+ +// | | | +// +----------------+ | async_wait() +// | | +// +---------+ +// +// If the deadline actor determines that the deadline has expired, the socket +// is closed and any outstanding operations are consequently cancelled. +// +// Connection establishment involves trying each endpoint in turn until a +// connection is successful, or the available endpoints are exhausted. If the +// deadline actor closes the socket, the connect actor is woken up and moves to +// the next endpoint. +// +// +---------------+ +// | | +// | start_connect |<---+ +// | | | +// +---------------+ | +// | | +// async_- | +----------------+ +// connect() | | | +// +--->| handle_connect | +// | | +// +----------------+ +// : +// Once a connection is : +// made, the connect : +// actor forks in two - : +// : +// an actor for reading : and an actor for +// inbound messages: : sending heartbeats: +// : +// +------------+ : +-------------+ +// | |<- - - - -+- - - - ->| | +// | start_read | | start_write |<---+ +// | |<---+ | | | +// +------------+ | +-------------+ | async_wait() +// | | | | +// async_- | +-------------+ async_- | +--------------+ +// read_- | | | write() | | | +// until() +--->| handle_read | +--->| handle_write | +// | | | | +// +-------------+ +--------------+ +// +// The input actor reads messages from the socket, where messages are delimited +// by the newline character. The deadline for a complete message is 30 seconds. +// +// The heartbeat actor sends a heartbeat (a message that consists of a single +// newline character) every 10 seconds. In this example, no deadline is applied +// to message sending. +// +class client : public std::enable_shared_from_this< client > +{ + public: + explicit client(boost::asio::io_context& io_context) + : socket_(io_context), deadline_(io_context), heartbeat_timer_(io_context) + { + } + + // Called by the user of the client class to initiate the connection + // process. The endpoints will have been obtained using a tcp::resolver. + void start(tcp::resolver::results_type endpoints) + { + // Start the connect actor. + endpoints_ = std::move(endpoints); + start_connect(endpoints_.begin()); + + // Start the deadline actor. You will note that we're not setting any + // particular deadline here. Instead, the connect and input actors will + // update the deadline prior to each asynchronous operation. + deadline_.async_wait([this](const boost::system::error_code& /*e*/) { check_deadline(); }); + } + + // This function terminates all the actors to shut down the connection. It + // may be called by the user of the client class, or by the class itself in + // response to graceful termination or an unrecoverable error. + void stop() + { + stopped_ = true; + boost::system::error_code ignored_error; + socket_.close(ignored_error); + deadline_.cancel(); + heartbeat_timer_.cancel(); + } + + private: + void start_connect(const tcp::resolver::results_type::iterator& endpoint_iter) + { + if (endpoint_iter != endpoints_.end()) + { + std::cout << "Trying " << endpoint_iter->endpoint() << "...\n"; + + // Set a deadline for the connect operation. + deadline_.expires_after(3s); + + // Start the asynchronous connect operation. + socket_.async_connect(endpoint_iter->endpoint(), + [this, endpoint_iter](const boost::system::error_code& error) { handle_connect(error, endpoint_iter); }); + } + else + { + // There are no more endpoints to try. Shut down the client. + stop(); + } + } + + void handle_connect(const boost::system::error_code& error, tcp::resolver::results_type::iterator endpoint_iter) + { + if (stopped_) + { + return; + } + + // The async_connect() function automatically opens the socket at the + // start of the asynchronous operation. If the socket is closed at this + // time then the timeout handler must have run first. + if (!socket_.is_open()) + { + std::cerr << "Connect timed out\n"; + + // Try the next available endpoint. + start_connect(++endpoint_iter); + } + + // Check if the connect operation failed before the deadline expired. + else if (error) + { + std::cerr << "Connect error: " << error.message() << "\n"; + + // We need to close the socket used in the previous connection + // attempt before starting a new one. + socket_.close(); + + // Try the next available endpoint. + start_connect(++endpoint_iter); + } + + // Otherwise we have successfully established a connection. + else + { + std::cout << "Connected to " << endpoint_iter->endpoint() << "\n"; + + // Start the input actor. + start_read(); + + // Start the heartbeat actor. + start_write(); + } + } + + void start_read() + { + if (stopped_) + { + return; + } + + // Set a deadline for the read operation. + deadline_.expires_after(13s); + + // Start an asynchronous operation to read a newline-delimited message. + boost::asio::async_read_until(socket_, boost::asio::dynamic_buffer(input_buffer_), '\n', + [self = shared_from_this()](const boost::system::error_code& error, std::size_t n) + { self->handle_read(error, n); }); + } + + void handle_read(const boost::system::error_code& error, std::size_t n) + { + if (stopped_) + { + return; + } + + if (!error) + { + // Extract the newline-delimited message from the buffer. + std::string const line = input_buffer_.substr(0, n - 1); // NOTE: w/o '\n' + input_buffer_.erase(0, n); + + // Empty messages are heartbeats and so ignored. + if (!line.empty()) + { + std::cout << "Received: " << line << "\n"; + } + + start_read(); + } + else + { + std::cerr << "Error on receive: " << error.message() << "\n"; + + stop(); + } + } + + void start_write() + { + if (stopped_) + { + return; + } + + std::string message{'\n'}; + std::cerr << "Sending: heartbeat\n"; + + // Start an asynchronous operation to send a heartbeat message. + boost::asio::async_write(socket_, boost::asio::buffer(message), + [self = shared_from_this()](const boost::system::error_code& error, std::size_t) { self->handle_write(error); }); + } + + void handle_write(const boost::system::error_code& error) + { + if (stopped_) + { + return; + } + + if (!error) + { + // Wait 10 seconds before sending the next heartbeat. + heartbeat_timer_.expires_after(10s); + heartbeat_timer_.async_wait([this](const boost::system::error_code& /*e*/) { start_write(); }); + } + else + { + std::cerr << "Error on heartbeat: " << error.message() << "\n"; + + stop(); + } + } + + void check_deadline() + { + if (stopped_) + { + return; + } + + // Check whether the deadline has passed. We compare the deadline + // against the current time since a new asynchronous operation may have + // moved the deadline before this actor had a chance to run. + if (deadline_.expiry() <= steady_timer::clock_type::now()) + { + // The deadline has passed. The socket is closed so that any + // outstanding asynchronous operations are cancelled. + socket_.close(); + + // There is no longer an active deadline. The expiry is set to the + // maximum time point so that the actor takes no action until a new + // deadline is set. + deadline_.expires_at(steady_timer::time_point::max()); + } + + // Put the actor back to sleep. + deadline_.async_wait([self = shared_from_this()](const boost::system::error_code& /*e*/) { self->check_deadline(); }); + } + + bool stopped_{false}; + tcp::resolver::results_type endpoints_; + tcp::socket socket_; + std::string input_buffer_; + steady_timer deadline_; + steady_timer heartbeat_timer_; +}; + +auto main(int argc, char* argv[]) -> int +{ + try + { + if (argc != 3) + { + std::cerr << "Usage: async_tcp_client \n"; + return EXIT_FAILURE; + } + + boost::asio::io_context io_context; + tcp::resolver resolver(io_context); + + auto c = std::make_shared< client >(io_context); + + c->start(resolver.resolve(argv[1], argv[2])); + + io_context.run(); + } + catch (std::exception& e) + { + std::cerr << "Exception: " << e.what() << "\n"; + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} diff --git a/examples/async_tcp_client_v20.cpp b/examples/async_tcp_client_v20.cpp new file mode 100644 index 0000000..db3050c --- /dev/null +++ b/examples/async_tcp_client_v20.cpp @@ -0,0 +1,315 @@ +/*** + * async_tcp_client_v20.cpp + * ~~~~~~~~~~~~~~~~~~~~~~~~ + * + * Copyright (c) 2003-2024 Christopher M. Kohlhoff (chris at kohlhoff dot com) + * + * Distributed under the Boost Software License, Version 1.0. (See accompanying + * file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) + * + * Moderniced from Claus Klein and ChatGPT + ***/ + +#include +#include +#include +#include +#include +#include +#include +#include // NOLINT(misc-include-cleaner) +#include +#include +#include +#include // For std::getline +#include +#include +#include +#include +#include + +using boost::asio::steady_timer; +using boost::asio::ip::tcp; +using namespace std::chrono_literals; + +class client : public std::enable_shared_from_this< client > +{ + public: + explicit client(boost::asio::io_context& io_context) + : socket_(io_context), deadline_(io_context), heartbeat_timer_(io_context) + { + } + + // Called by the user of the client class to initiate the connection + // process. The endpoints will have been obtained using a tcp::resolver. + void start(tcp::resolver::results_type endpoints) + { + // Start the connect actor. + endpoints_ = std::move(endpoints); + start_connect(endpoints_.begin()); + + // Start the deadline actor. You will note that we're not setting any + // particular deadline here. Instead, the connect and input actors will + // update the deadline prior to each asynchronous operation. + deadline_.async_wait([this](const boost::system::error_code& /*e*/) { check_deadline(); }); + } + + void write() + { + auto self(shared_from_this()); + + for (std::string message; !stopped_ && std::getline(std::cin, message); std::print("Enter message to send: ")) + { + if (message.empty()) + { + continue; + } + + if (message.back() != '\n') + { + message += '\n'; + } + + std::print(stderr, "Sending: {}\n", message); + + // Start an asynchronous operation to send the message. + boost::asio::async_write(socket_, boost::asio::buffer(message), + [self](const boost::system::error_code& error, std::size_t) { self->handle_write(error); }); + } + } + + // This function terminates all the actors to shut down the connection. It + // may be called by the user of the client class, or by the class itself in + // response to graceful termination or an unrecoverable error. + void stop() + { + stopped_ = true; + boost::system::error_code ignored_error; + socket_.close(ignored_error); + deadline_.cancel(); + heartbeat_timer_.cancel(); + } + + private: + void start_connect(const tcp::resolver::results_type::iterator& endpoint_iter) + { + if (endpoint_iter != endpoints_.end()) + { + std::print("Trying {}:{}...\n", endpoint_iter->endpoint().address().to_string(), endpoint_iter->endpoint().port()); + + // Set a deadline for the connect operation. + deadline_.expires_after(3s); + + // Start the asynchronous connect operation. + socket_.async_connect(endpoint_iter->endpoint(), + [this, endpoint_iter](const boost::system::error_code& error) { handle_connect(error, endpoint_iter); }); + } + else + { + // There are no more endpoints to try. Shut down the client. + stop(); + } + } + + void handle_connect(const boost::system::error_code& error, tcp::resolver::results_type::iterator endpoint_iter) + { + if (stopped_) + { + return; + } + + // The async_connect() function automatically opens the socket at the + // start of the asynchronous operation. If the socket is closed at this + // time then the timeout handler must have run first. + if (!socket_.is_open()) + { + std::print(stderr, "Connect timed out\n"); + + // Try the next available endpoint. + start_connect(++endpoint_iter); + } + + // Check if the connect operation failed before the deadline expired. + else if (error) + { + std::print(stderr, "Error on Connect: {}\n", error.message()); + + // We need to close the socket used in the previous connection + // attempt before starting a new one. + socket_.close(); + + // Try the next available endpoint. + start_connect(++endpoint_iter); + } + + // Otherwise we have successfully established a connection. + else + { + std::print( + "Connected to {}:{}\n", endpoint_iter->endpoint().address().to_string(), endpoint_iter->endpoint().port()); + + // Start the input actor. + start_read(); + + // Start the heartbeat actor. + start_write(); + } + } + + void start_read() + { + if (stopped_) + { + return; + } + + // Set a deadline for the read operation. + deadline_.expires_after(13s); + + // Start an asynchronous operation to read a newline-delimited message. + boost::asio::async_read_until(socket_, boost::asio::dynamic_buffer(input_buffer_), '\n', + [self = shared_from_this()](const boost::system::error_code& error, std::size_t n) + { self->handle_read(error, n); }); + } + + void handle_read(const boost::system::error_code& error, std::size_t n) + { + if (stopped_) + { + return; + } + + if (!error) + { + // Extract the newline-delimited message from the buffer. + std::string const line = input_buffer_.substr(0, n - 1); // NOTE: w/o '\n' + input_buffer_.erase(0, n); + + // Empty messages are heartbeats and so ignored. + if (line.empty()) + { + std::print(stderr, "Received: {}\n", "hartbeat"); + } + else + { + std::print("Received: {}\n", line); + } + + start_read(); + } + else + { + std::print(stderr, "Error on receive: {}\n", error.message()); + + stop(); + } + } + + void start_write() + { + if (stopped_) + { + return; + } + + std::string message{'\n'}; + std::print(stderr, "Sending: {}\n", "hartbeat"); + + // Start an asynchronous operation to send a heartbeat message. + boost::asio::async_write(socket_, boost::asio::buffer(message), + [self = shared_from_this()](const boost::system::error_code& error, std::size_t) { self->handle_write(error); }); + } + + void handle_write(const boost::system::error_code& error) + { + if (stopped_) + { + return; + } + + if (!error) + { + if (heartbeat_timer_.expiry() <= steady_timer::clock_type::now()) + { + std::print(stderr, "Waiting for next to send: {}\n", "hartbeat"); + } + + // Wait 10 seconds before sending the next heartbeat. + heartbeat_timer_.cancel(); + heartbeat_timer_.expires_after(10s); + heartbeat_timer_.async_wait([this](const boost::system::error_code& /*e*/) { start_write(); }); + } + else + { + std::print(stderr, "Error on sending heartbeat: {}\n", error.message()); + + stop(); + } + } + + void check_deadline() + { + if (stopped_) + { + return; + } + + // Check whether the deadline has passed. We compare the deadline + // against the current time since a new asynchronous operation may have + // moved the deadline before this actor had a chance to run. + if (deadline_.expiry() <= steady_timer::clock_type::now()) + { + // The deadline has passed. The socket is closed so that any + // outstanding asynchronous operations are cancelled. + socket_.close(); + + // There is no longer an active deadline. The expiry is set to the + // maximum time point so that the actor takes no action until a new + // deadline is set. + deadline_.expires_at(steady_timer::time_point::max()); + } + + // Put the actor back to sleep. + deadline_.async_wait([self = shared_from_this()](const boost::system::error_code& /*e*/) { self->check_deadline(); }); + } + + bool stopped_{false}; + tcp::resolver::results_type endpoints_; + tcp::socket socket_; + std::string input_buffer_; + steady_timer deadline_; + steady_timer heartbeat_timer_; +}; + +auto main(int argc, char* argv[]) -> int +{ + try + { + if (argc != 3) + { + std::print("Usage: {} \n", *argv); + return EXIT_FAILURE; + } + + boost::asio::io_context io_context; + tcp::resolver resolver(io_context); + + auto c = std::make_shared< client >(io_context); + + c->start(resolver.resolve(argv[1], argv[2])); + + std::thread io_thread([&io_context]() { io_context.run(); }); + + c->write(); + + c->stop(); + io_thread.join(); + } + catch (std::exception& e) + { + std::print("Exception: {}\n", e.what()); + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} diff --git a/examples/async_tcp_echo_client.cpp b/examples/async_tcp_echo_client.cpp new file mode 100644 index 0000000..f2c60c0 --- /dev/null +++ b/examples/async_tcp_echo_client.cpp @@ -0,0 +1,244 @@ +/*** + * async_tcp_echo_client.cpp + * ~~~~~~~~~~~~~~~~~~~~~~~~~ + * + * Copyright (c) 2003-2024 Christopher M. Kohlhoff (chris at kohlhoff dot com) + * + * Distributed under the Boost Software License, Version 1.0. (See accompanying + * file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) + * + * Moderniced from Claus Klein and ChatGPT + ***/ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rrcp_helper.hpp" + +using boost::asio::ip::tcp; +using namespace std::chrono_literals; +using message_queue = std::deque< std::string >; + +using namespace rrcp; + +constexpr size_t MAX_LENGTH = 65432; +constexpr auto TIMEOUT_DURATION = 1s; + +class asynchronous_tcp_client : public std::enable_shared_from_this< asynchronous_tcp_client > +{ + public: + asynchronous_tcp_client(boost::asio::io_context& io_context, const std::string& host, const std::string& port) + : io_context_(io_context), resolver_(io_context), socket_(io_context), timer_(io_context) + { + resolver_.async_resolve(host, port, + [this](boost::system::error_code ec, const tcp::resolver::results_type& results) -> void + { + if (!ec) + { + boost::asio::async_connect(socket_, results, + [this](boost::system::error_code ec, const tcp::endpoint&) -> void + { + if (!ec) + { + fmt::print(stderr, "Connected to server.\n"); + connected_ = true; + do_read(); + } + else + { + // There are no more endpoints to try. Shut down the client. + stop(); + } + }); + } + }); + } + + // This function write the message into the msg queue and starts the write actor + void write(const std::string& message) + { + while (!connected_) + { + if (stopped_) + { + return; + } + fmt::print(stderr, "Client is not connected yet.\n"); + std::this_thread::sleep_for(TIMEOUT_DURATION); // NOLINT(misc-include-cleaner) + } + + boost::asio::post(io_context_, + [this, message]() -> void + { + bool const write_in_progress{!write_msgs_.empty()}; + write_msgs_.push_back(message); + if (!write_in_progress) + { + do_write(); + } + }); + } + + // This function terminates all the actors to shut down the connection. It + // may be called by the user of the client class, or by the class itself in + // response to graceful termination or an unrecoverable error. + void stop() + { + boost::system::error_code ignored_error; + socket_.close(ignored_error); + timer_.cancel(); + connected_ = true; + stopped_ = true; + } + + private: + void do_write() + { + auto self(shared_from_this()); + boost::asio::async_write(socket_, boost::asio::buffer(write_msgs_.front()), + [this, self](boost::system::error_code ec, std::size_t /*length*/) -> void + { + if (!ec) + { + fmt::print(stderr, "Message sent.\n"); + write_msgs_.pop_front(); + if (!write_msgs_.empty()) + { + do_write(); + } + } + else + { + // There are no more endpoints to try. Shut down the client. + stop(); + } + }); + } + + void do_read() + { + if (stopped_) + { + return; + } + + auto self(shared_from_this()); + + if (!connected_) + { + timer_.expires_after(TIMEOUT_DURATION); + timer_.async_wait( + [this, self](const boost::system::error_code& ec) -> void + { + if (!ec) + { + fmt::print(stderr, "Error: Read operation timed out.\n"); + stop(); + } + }); + } + + boost::asio::async_read_until(socket_, boost::asio::dynamic_buffer(data_), STOP, + [this, self](boost::system::error_code ec, std::size_t length) -> void + { + timer_.cancel(); + if (!ec) + { + std::string response = esc2char(data_.substr(1, length)); // NOTE: w/o START! + + // NOTE: data_.erase(0, length); is used instead of data_.clear() because: + + // - Partial Data Handling: The async_read_until() function reads + // data up to the delimiter (STOP) but doesn’t guarantee it consumes + // all the data in the socket. + // There might be extra data left in the buffer after the delimiter! + + // - Efficient Buffer Management: By erasing only the portion of the + // string that has been processed (length), we keep any remaining + // data intact for future reads instead of discarding it. + data_.erase(0, length); + + fmt::print("Response is: {}\n", response); + do_read(); + } + else + { + // There are no more endpoints to try. Shut down the client. + stop(); + } + }); + } + + boost::asio::io_context& io_context_; + tcp::resolver resolver_; + tcp::socket socket_; + boost::asio::steady_timer timer_; + std::string data_; + message_queue write_msgs_; + bool connected_{false}; + bool stopped_{false}; +}; + +// NOLINTNEXTLINE(bugprone-exception-escape) +auto main(int argc, char* argv[]) -> int +{ + if (argc != 3) + { + fmt::print(stderr, "Usage: {} \n", argv[0]); + return EXIT_FAILURE; + } + + try + { + boost::asio::io_context io_context; + + auto client = std::make_shared< asynchronous_tcp_client >(io_context, argv[1], argv[2]); + + std::thread io_thread([&io_context]() -> void { io_context.run(); }); + + for (std::string line; std::getline(std::cin, line); fmt::print(stderr, "Enter command: ")) + { + const std::string::size_type sz = line.find("//"); + if ((sz != std::string::npos)) + { + line.resize(sz); // NOTE: w/o c++ comments + } + + boost::trim(line); + if (line.empty()) + { + continue; + } + + std::string command = char2esc(line); + command.insert(0, 1, START); + command += STOP; + + client->write(command); + } + std::this_thread::sleep_for(TIMEOUT_DURATION); + + client->stop(); + io_thread.join(); + } + catch (std::exception& e) + { + fmt::print(stderr, "Exception: {}\n", e.what()); + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} diff --git a/examples/async_tcp_echo_server.cpp b/examples/async_tcp_echo_server.cpp new file mode 100644 index 0000000..934daf9 --- /dev/null +++ b/examples/async_tcp_echo_server.cpp @@ -0,0 +1,217 @@ +// +// async_tcp_echo_server.cpp +// ~~~~~~~~~~~~~~~~~~~~~~~~~ +// +// Copyright (c) 2003-2025 Christopher M. Kohlhoff (chris at kohlhoff dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Moderniced from Claus Klein and ChatGPT + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef TARGET_CODE_COVERAGE +// Forward declaration of flush api +// extern "C" { +extern void __gcov_flush(); +// } +#endif + +using boost::asio::ip::tcp; + +class session : public std::enable_shared_from_this< session > +{ + static constexpr std::size_t MAX_LENGTH{1024}; + + public: + explicit session(tcp::socket socket) : my_socket_(std::move(socket)) {} + + void start() { do_read(); } + + private: + void do_read() + { + auto self(shared_from_this()); + my_socket_.async_read_some(boost::asio::buffer(data_.data(), MAX_LENGTH), + [this, self](boost::system::error_code ec, std::size_t length) -> void + { + if (!ec) + { + if ((std::string_view(data_.data(), length).contains("M:Utility")) || + (std::string_view(data_.data(), length).contains("M:A")) || + (std::string_view(data_.data(), length).contains("M:C"))) + { + do_write(length); + } + else + { + std::size_t new_len = gen_random(length); + +#define CHANGE_ECHO_MSG +#ifndef CHANGE_ECHO_MSG + if ((new_len % 2) == 0) + { + data_, data()[0] = 0x20; // change content + new_len = length; // but not size! + } + if ((new_len % 2) == 1) + { + data_, data()[1] = 0x21; // change content + new_len = length; // but not size! + } +#endif + + do_write(new_len); + } + } + else + { + my_socket_.close(); + } + }); + } + + void do_write(std::size_t length) + { + auto self(shared_from_this()); + boost::asio::async_write(my_socket_, boost::asio::buffer(data_.data(), length), + [this, self](boost::system::error_code ec, std::size_t /*length*/) -> void + { + if (!ec) + { + do_read(); + } + else + { + my_socket_.close(); + } + }); + } + + static std::size_t gen_random(std::size_t input) + { + static std::random_device rd; // a seed source for the random number engine + static std::mt19937 gen(rd()); // mersenne_twister_engine seeded with rd() + static std::uniform_int_distribution<> distrib(27, MAX_LENGTH); + + ++input; + // Use distrib to transform the random unsigned int + // generated by gen into an value in [3, input] + return std::max((distrib(gen) % input), static_cast< std::size_t >(3)); + } + + tcp::socket my_socket_; + std::array< char, MAX_LENGTH > data_{}; +}; + +class server +{ + public: + server(boost::asio::io_context& io_context, short port) + : acceptor_(io_context, tcp::endpoint(tcp::v4(), port)), socket_(io_context), signals_(io_context) + { + // Register to handle the signals that indicate when the server should exit. + // It is safe to register for the same signal multiple times in a program, + // provided all registration for the specified signal is made through Asio. + signals_.add(SIGINT); + signals_.add(SIGTERM); + +#ifdef SIGQUIT + signals_.add(SIGQUIT); +#endif // defined(SIGQUIT) + + do_await_stop(); + + do_accept(); + } + + private: + void do_accept() + { + acceptor_.async_accept(socket_, + [this](boost::system::error_code ec) -> void + { + if (!ec) + { + std::make_shared< session >(std::move(socket_))->start(); + do_accept(); + } + else + { + acceptor_.close(); + socket_.close(); + } + }); + } + + // Signal handler definition which flushes profiling data + void do_await_stop() + { + signals_.async_wait( + [this](std::error_code ec, int signo) -> void + { + std::cerr << "Signal handler called for " << signo << "\n"; + if (!ec) + { + // The server is stopped by cancelling all outstanding asynchronous + // operations. Once all operations have finished the io_context::run() + // call will exit. + acceptor_.close(); + socket_.close(); + } + else + { + acceptor_.close(); + socket_.close(); + +#ifdef TARGET_CODE_COVERAGE + __gcov_flush(); +#endif + + exit(EXIT_FAILURE); + } + }); + } + + tcp::acceptor acceptor_; + tcp::socket socket_; + boost::asio::signal_set signals_; +}; + +auto main(int argc, char* argv[]) -> int +{ + try + { + if (argc != 2) + { + std::cerr << "Usage: async_tcp_echo_server \n"; + return EXIT_FAILURE; + } + + boost::asio::io_context io_context; + + server const serv(io_context, static_cast< short >(std::strtol(argv[1], nullptr, 10))); + + io_context.run(); + std::cout << "io_service.run complete, shutdown successful\n"; + } + catch (std::exception& e) + { + std::cerr << "Exception: " << e.what() << "\n"; + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} diff --git a/examples/base64decode.cpp b/examples/base64decode.cpp new file mode 100644 index 0000000..37c8a90 --- /dev/null +++ b/examples/base64decode.cpp @@ -0,0 +1,63 @@ +// +// base64decode.cpp +// +// This sample demonstrates the Base64Decoder and StreamCopier classes. +// +// Copyright (c) 2004-2006, Applied Informatics Software Engineering GmbH. +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + +#include +#include + +#include "Poco/Base64Decoder.h" +#include "Poco/StreamCopier.h" + +using Poco::Base64Decoder; +using Poco::StreamCopier; + +int main(int argc, char** argv) +{ + if (argc < 2) + { + std::cerr << "usage: " << argv[0] << ": " << std::endl + << " read base64-encoded , decode it and write the result to " << std::endl + << " read from stdin if is '-', decode it and write the result to stdout" << std::endl; + return 1; + } + + if (argv[1] == std::string("-")) + { + Base64Decoder decoder(std::cin); + StreamCopier::copyStream(decoder, std::cout); + } + else + { + std::ifstream istr(argv[1]); + if (!istr) + { + std::cerr << "cannot open input file: " << argv[1] << std::endl; + return 2; + } + + std::ofstream ostr(argv[2], std::ios::binary); + if (!ostr) + { + std::cerr << "cannot open output file: " << argv[2] << std::endl; + return 3; + } + + Base64Decoder decoder(istr); + StreamCopier::copyStream(decoder, ostr); + + if (!ostr) + { + std::cerr << "error writing output file: " << argv[2] << std::endl; + return 4; + } + } + + return 0; +} diff --git a/examples/base64encode.cpp b/examples/base64encode.cpp new file mode 100644 index 0000000..2e761ab --- /dev/null +++ b/examples/base64encode.cpp @@ -0,0 +1,63 @@ +// +// base64encode.cpp +// +// This sample demonstrates the Base64Encoder and StreamCopier classes. +// +// Copyright (c) 2004-2006, Applied Informatics Software Engineering GmbH. +// and Contributors. +// +// SPDX-License-Identifier: BSL-1.0 +// + +#include +#include + +#include "Poco/Base64Encoder.h" +#include "Poco/StreamCopier.h" + +using Poco::Base64Encoder; +using Poco::StreamCopier; + +int main(int argc, char** argv) +{ + if (argc < 2) + { + std::cerr << "usage: " << argv[0] << ": " << std::endl + << " read , base64-encode it and write the result to " << std::endl + << " read from stdin if is '-', decode it and write the result to stdout" << std::endl; + return 1; + } + + if (argv[1] == std::string("-")) + { + Base64Encoder encoder(std::cout); + StreamCopier::copyStream(std::cin, encoder); + } + else + { + std::ifstream istr(argv[1], std::ios::binary); + if (!istr) + { + std::cerr << "cannot open input file: " << argv[1] << std::endl; + return 2; + } + + std::ofstream ostr(argv[2]); + if (!ostr) + { + std::cerr << "cannot open output file: " << argv[2] << std::endl; + return 3; + } + + Base64Encoder encoder(ostr); + StreamCopier::copyStream(istr, encoder); + + if (!ostr) + { + std::cerr << "error writing output file: " << argv[2] << std::endl; + return 4; + } + } + + return 0; +} diff --git a/examples/blocking_tcp_echo_client.cpp b/examples/blocking_tcp_echo_client.cpp new file mode 100644 index 0000000..fa46bf2 --- /dev/null +++ b/examples/blocking_tcp_echo_client.cpp @@ -0,0 +1,96 @@ +// +// blocking_tcp_echo_client.cpp +// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +// Copyright (c) 2003-2024 Christopher M. Kohlhoff (chris at kohlhoff dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Moderniced from Claus Klein and ChatGPT + +#include // for trim_right +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rrcp_helper.hpp" + +using boost::asio::ip::tcp; + +static constexpr int MAX_LENGTH{1024}; + +auto main(int argc, char* argv[]) -> int +{ + try + { + using namespace rrcp; + + if (argc != 3) + { + std::cerr << "Usage: blocking_tcp_echo_client \n"; + return EXIT_FAILURE; + } + + boost::asio::io_context io_context; + + tcp::socket socket(io_context); + tcp::resolver resolver(io_context); + boost::asio::connect(socket, resolver.resolve(argv[1], argv[2])); + + for (std::string line; std::getline(std::cin, line); std::cerr << "Enter command: ") + { + const std::string::size_type sz = line.find("//"); + if ((sz != std::string::npos)) + { + line.resize(sz); + } + + boost::trim_right(line); + if (line.empty()) + { + continue; + } + + // TODO(CK): check boost::system::error_code ec; + std::string command = char2esc(line); + command.insert(0, 1, START); + command += STOP; + boost::asio::write(socket, boost::asio::buffer(command.c_str(), command.length())); + + // TODO(CK): wait for endchar with timeout! + std::string data; + boost::asio::dynamic_string_buffer< char, std::string::traits_type, std::string::allocator_type > const sb2 = + boost::asio::dynamic_buffer(data, MAX_LENGTH); + + do + { + // NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores) + size_t const reply_length = boost::asio::read_until(socket, sb2, STOP); + std::string const response = esc2char(data); + if (response.empty()) + { + break; + } + + std::cerr << "Response is: "; + std::cout << response << "\n"; + } while (false); + }; + } + catch (std::exception& e) + { + std::cerr << "Exception: " << e.what() << "\n"; + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} diff --git a/examples/blocking_tcp_echo_server.cpp b/examples/blocking_tcp_echo_server.cpp new file mode 100644 index 0000000..06eeada --- /dev/null +++ b/examples/blocking_tcp_echo_server.cpp @@ -0,0 +1,93 @@ +// +// blocking_tcp_echo_server.cpp +// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +// Copyright (c) 2003-2024 Christopher M. Kohlhoff (chris at kohlhoff dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Moderniced from Claus Klein and ChatGPT + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using boost::asio::ip::tcp; + +namespace +{ + +constexpr size_t max_length{1024}; + +void session(tcp::socket sock) +{ + try + { + for (;;) + { + std::array< char, max_length > data{}; + + boost::system::error_code error; + size_t const length = sock.read_some(boost::asio::buffer(data), error); + if (error == boost::asio::stream_errc::eof) + { + break; // Connection closed cleanly by peer. + } + if (error) + { + throw boost::system::system_error(error); // Some other error. + } + + boost::asio::write(sock, boost::asio::buffer(data, length)); + } + } + catch (std::exception& e) + { + std::cerr << "Exception in thread: " << e.what() << "\n"; + } +} + +void server(boost::asio::io_context& io_context, short port) +{ + tcp::acceptor a(io_context, tcp::endpoint(tcp::v4(), port)); + for (;;) + { + tcp::socket sock(io_context); + a.accept(sock); + std::thread(session, std::move(sock)).detach(); + } +} + +} // namespace + +auto main(int argc, char* argv[]) -> int +{ + try + { + if (argc != 2) + { + std::cerr << "Usage: blocking_tcp_echo_server \n"; + return EXIT_FAILURE; + } + + boost::asio::io_context io_context; + + server(io_context, static_cast< short >(std::strtol(argv[1], nullptr, 10))); + } + catch (std::exception& e) + { + std::cerr << "Exception: " << e.what() << "\n"; + } + + return EXIT_SUCCESS; +} diff --git a/examples/test.sh b/examples/test.sh new file mode 100755 index 0000000..a24f45a --- /dev/null +++ b/examples/test.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +set -x +set -u +set -e + +echo '!@#$%^&*()_~<>' > base64.dat +cat base64.dat | ./base64encode - > base64.txt +cat base64.dat | ./base64encode - | ./base64decode - | diff base64.dat - +./base64encode 2>&1 | grep -w usage +./base64decode 2>&1 | grep -w usage +rm -rf tmp +./base64encode base64.txt tmp/output_file 2>&1 | grep -w output_file +./base64decode base64.dat tmp/output_file 2>&1 | grep -w output_file +./base64encode base64.dat base64-out.txt +./base64decode base64.txt base64-out.dat +diff -u base64.txt base64-out.txt +diff -u base64.dat base64-out.dat diff --git a/gcovr.cfg b/gcovr.cfg new file mode 100644 index 0000000..c00744f --- /dev/null +++ b/gcovr.cfg @@ -0,0 +1,26 @@ +root = . +search-path = build + +filter = Base64* +filter = RRCP* +filter = async_* +filter = examples/* +filter = rrcp_* +# filter = tests/* +exclude = tests + +# exclude-directories = build/_deps +exclude-directories = coverage +exclude-directories = doc +exclude-directories = stagedir +exclude-directories = .cache +exclude-directories = .direnv +exclude-directories = .venv + +gcov-ignore-parse-errors = all +print-summary = yes + +html-details = coverage/gcovr.html + +# cobertura-pretty = yes +# cobertura = build/cobertura.xml diff --git a/infra/.beman_submodule b/infra/.beman_submodule new file mode 100644 index 0000000..bfed167 --- /dev/null +++ b/infra/.beman_submodule @@ -0,0 +1,3 @@ +[beman_submodule] +remote=https://github.com/bemanproject/infra.git +commit_hash=bb58b2a1cc894d58a55bf745be78f5d27029e245 diff --git a/infra/.github/workflows/beman-submodule.yml b/infra/.github/workflows/beman-submodule.yml new file mode 100644 index 0000000..8435086 --- /dev/null +++ b/infra/.github/workflows/beman-submodule.yml @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +name: beman-submodule tests + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + beman-submodule-script-ci: + name: beman_module.py ci + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.13 + + - name: Install pytest + run: | + python3 -m pip install pytest + + - name: Run pytest + run: | + cd tools/beman-submodule/ + pytest diff --git a/infra/.github/workflows/pre-commit.yml b/infra/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..9646831 --- /dev/null +++ b/infra/.github/workflows/pre-commit.yml @@ -0,0 +1,78 @@ +name: Lint Check (pre-commit) + +on: + # We have to use pull_request_target here as pull_request does not grant + # enough permission for reviewdog + pull_request_target: + push: + branches: + - main + +jobs: + pre-commit-push: + name: Pre-Commit check on Push + runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.13 + + # We wish to run pre-commit on all files instead of the changes + # only made in the push commit. + # + # So linting error persists when there's formatting problem. + - uses: pre-commit/action@v3.0.1 + + pre-commit-pr: + name: Pre-Commit check on PR + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request_target' }} + + permissions: + contents: read + checks: write + issues: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # pull_request_target checkout the base of the repo + # We need to checkout the actual pr to lint the changes. + - name: Checkout pr + run: gh pr checkout ${{ github.event.number }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.13 + + # we only lint on the changed file in PR. + - name: Get Changed Files + id: changed-files + uses: tj-actions/changed-files@v45 + + # See: + # https://github.com/tj-actions/changed-files?tab=readme-ov-file#using-local-git-directory- + - uses: pre-commit/action@v3.0.1 + id: run-pre-commit + with: + extra_args: --files ${{ steps.changed-files.outputs.all_changed_files }} + + # Review dog posts the suggested change from pre-commit to the pr. + - name: suggester / pre-commit + uses: reviewdog/action-suggester@v1 + if: ${{ failure() && steps.run-pre-commit.conclusion == 'failure' }} + with: + tool_name: pre-commit + level: warning + reviewdog_flags: "-fail-level=error" diff --git a/infra/.github/workflows/reusable-beman-create-issue-when-fault.yml b/infra/.github/workflows/reusable-beman-create-issue-when-fault.yml new file mode 100644 index 0000000..024a51f --- /dev/null +++ b/infra/.github/workflows/reusable-beman-create-issue-when-fault.yml @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +name: 'Beman issue creation workflow' +on: + workflow_call: + workflow_dispatch: +jobs: + create-issue: + runs-on: ubuntu-latest + steps: + # See https://github.com/cli/cli/issues/5075 + - uses: actions/checkout@v4 + - name: Create issue + run: | + issue_num=$(gh issue list -s open -S "[SCHEDULED-BUILD] infra repo CI job failure" -L 1 --json number | jq 'if length == 0 then -1 else .[0].number end') + body="**CI job failure Report** + - **Time of Failure**: $(date -u '+%B %d, %Y, %H:%M %Z') + - **Commit**: [${{ github.sha }}](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}) + - **Action Run**: [View logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + The scheduled job triggered by cron has failed. + Please investigate the logs and recent changes associated with this commit or rerun the workflow if you believe this is an error." + if [[ $issue_num -eq -1 ]]; then + gh issue create --repo ${{ github.repository }} --title "[SCHEDULED-BUILD] infra repo CI job failure" --body "$body" --assignee ${{ github.actor }} + else + gh issue comment --repo ${{ github.repository }} $issue_num --body "$body" + fi + env: + GH_TOKEN: ${{ github.token }} diff --git a/infra/.gitignore b/infra/.gitignore new file mode 100644 index 0000000..b7cdbb5 --- /dev/null +++ b/infra/.gitignore @@ -0,0 +1,59 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +# Python +__pycache__/ +.pytest_cache/ +*.pyc +*.pyo +*.pyd +*.pyw +*.pyz +*.pywz +*.pyzw +*.pyzwz +*.delete_me + +# MAC OS +*.DS_Store + +# Editor files +.vscode/ +.idea/ + +# Build directories +infra.egg-info/ +beman_tidy.egg-info/ +*.egg-info/ +build/ +dist/ diff --git a/infra/.pre-commit-config.yaml b/infra/.pre-commit-config.yaml new file mode 100644 index 0000000..e806e59 --- /dev/null +++ b/infra/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + + # CMake linting and formatting + - repo: https://github.com/BlankSpruce/gersemi + rev: 0.22.3 + hooks: + - id: gersemi + name: CMake linting + exclude: ^.*/tests/.*/data/ # Exclude test data directories + + # Python linting and formatting + # config file: ruff.toml (not currently present but add if needed) + # https://docs.astral.sh/ruff/configuration/ + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.13.2 + hooks: + - id: ruff-check + files: ^tools/beman-tidy/ + - id: ruff-format + files: ^tools/beman-tidy/ diff --git a/infra/.pre-commit-hooks.yaml b/infra/.pre-commit-hooks.yaml new file mode 100644 index 0000000..d327587 --- /dev/null +++ b/infra/.pre-commit-hooks.yaml @@ -0,0 +1,7 @@ +- id: beman-tidy + name: "beman-tidy: bemanification your repo" + entry: ./tools/beman-tidy/beman-tidy + language: script + pass_filenames: false + always_run: true + args: [".", "--verbose"] diff --git a/infra/LICENSE b/infra/LICENSE new file mode 100644 index 0000000..f6db814 --- /dev/null +++ b/infra/LICENSE @@ -0,0 +1,219 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +---- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..16b2672 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,55 @@ +# Beman Project Infrastructure Repository + + + +This repository contains the infrastructure for The Beman Project. This is NOT a library repository, +so it does not respect the usual structure of a Beman library repository nor The Beman Standard! + +## Description + +* `cmake/`: CMake modules and toolchain files used by Beman libraries. +* `containers/`: Containers used for CI builds and tests in the Beman org. +* `tools/`: Tools used to manage the infrastructure and the codebase (e.g., linting, formatting, etc.). + +## Usage + +This repository is intended to be used as a beman-submodule in other Beman repositories. See +[the Beman Submodule documentation](./tools/beman-submodule/README.md) for details. + + +### CMake Modules + + +#### `beman_install_library` + +The CMake modules in this repository are intended to be used by Beman libraries. Use the +`beman_add_install_library_config()` function to install your library, along with header +files, any metadata files, and a CMake config file for `find_package()` support. + +```cmake +add_library(beman.something) +add_library(beman::something ALIAS beman.something) + +# ... configure your target as needed ... + +find_package(beman-install-library REQUIRED) +beman_install_library(beman.something) +``` + +Note that the target must be created before calling `beman_install_library()`. The module +also assumes that the target is named using the `beman.something` convention, and it +uses that assumption to derive the names to match other Beman standards and conventions. +If your target does not follow that convention, raise an issue or pull request to add +more configurability to the module. + +The module will configure the target to install: + +* The library target itself +* Any public headers associated with the target +* CMake files for `find_package(beman.something)` support + +Some options for the project and target will also be supported: + +* `BEMAN_INSTALL_CONFIG_FILE_PACKAGES` - a list of package names (e.g., `beman.something`) for which to install the config file + (default: all packages) +* `_INSTALL_CONFIG_FILE_PACKAGE` - a per-project option to enable/disable config file installation (default: `ON` if the project is top-level, `OFF` otherwise). For instance for `beman.something`, the option would be `BEMAN_SOMETHING_INSTALL_CONFIG_FILE_PACKAGE`. diff --git a/infra/cmake/appleclang-toolchain.cmake b/infra/cmake/appleclang-toolchain.cmake new file mode 100644 index 0000000..70ef548 --- /dev/null +++ b/infra/cmake/appleclang-toolchain.cmake @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +# This toolchain file is not meant to be used directly, +# but to be invoked by CMake preset and GitHub CI. +# +# This toolchain file configures for apple clang family of compiler. +# Note this is different from LLVM toolchain. +# +# BEMAN_BUILDSYS_SANITIZER: +# This optional CMake parameter is not meant for public use and is subject to +# change. +# Possible values: +# - MaxSan: configures clang and clang++ to use all available non-conflicting +# sanitizers. Note that apple clang does not support leak sanitizer. +# - TSan: configures clang and clang++ to enable the use of thread sanitizer. + +include_guard(GLOBAL) + +# Prevent PATH collision with an LLVM clang installation by using the system +# compiler shims +set(CMAKE_C_COMPILER cc) +set(CMAKE_CXX_COMPILER c++) + +if(BEMAN_BUILDSYS_SANITIZER STREQUAL "MaxSan") + set(SANITIZER_FLAGS + "-fsanitize=address -fsanitize=pointer-compare -fsanitize=pointer-subtract -fsanitize=undefined" + ) +elseif(BEMAN_BUILDSYS_SANITIZER STREQUAL "TSan") + set(SANITIZER_FLAGS "-fsanitize=thread") +endif() + +set(CMAKE_C_FLAGS_DEBUG_INIT "${SANITIZER_FLAGS}") +set(CMAKE_CXX_FLAGS_DEBUG_INIT "${SANITIZER_FLAGS}") + +set(RELEASE_FLAGS "-O3 ${SANITIZER_FLAGS}") + +set(CMAKE_C_FLAGS_RELWITHDEBINFO_INIT "${RELEASE_FLAGS}") +set(CMAKE_CXX_FLAGS_RELWITHDEBINFO_INIT "${RELEASE_FLAGS}") + +set(CMAKE_C_FLAGS_RELEASE_INIT "${RELEASE_FLAGS}") +set(CMAKE_CXX_FLAGS_RELEASE_INIT "${RELEASE_FLAGS}") + +# Add this dir to the module path so that `find_package(beman-install-library)` works +list(APPEND CMAKE_PREFIX_PATH "${CMAKE_CURRENT_LIST_DIR}") diff --git a/infra/cmake/beman-install-library-config.cmake b/infra/cmake/beman-install-library-config.cmake new file mode 100644 index 0000000..e7fd0ad --- /dev/null +++ b/infra/cmake/beman-install-library-config.cmake @@ -0,0 +1,169 @@ +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +include_guard(GLOBAL) + +# This file defines the function `beman_install_library` which is used to +# install a library target and its headers, along with optional CMake +# configuration files. +# +# The function is designed to be reusable across different Beman libraries. + +function(beman_install_library name) + # Usage + # ----- + # + # beman_install_library(NAME) + # + # Brief + # ----- + # + # This function installs the specified library target and its headers. + # It also handles the installation of the CMake configuration files if needed. + # + # CMake variables + # --------------- + # + # Note that configuration of the installation is generally controlled by CMake + # cache variables so that they can be controlled by the user or tool running the + # `cmake` command. Neither `CMakeLists.txt` nor `*.cmake` files should set these + # variables directly. + # + # - BEMAN_INSTALL_CONFIG_FILE_PACKAGES: + # List of packages that require config file installation. + # If the package name is in this list, it will install the config file. + # + # - _INSTALL_CONFIG_FILE_PACKAGE: + # Boolean to control config file installation for the specific library. + # The prefix `` is the uppercased name of the library with dots + # replaced by underscores. + # + if(NOT TARGET "${name}") + message(FATAL_ERROR "Target '${name}' does not exist.") + endif() + + if(NOT ARGN STREQUAL "") + message( + FATAL_ERROR + "beman_install_library does not accept extra arguments: ${ARGN}" + ) + endif() + + # Given foo.bar, the component name is bar + string(REPLACE "." ";" name_parts "${name}") + # fail if the name doesn't look like foo.bar + list(LENGTH name_parts name_parts_length) + if(NOT name_parts_length EQUAL 2) + message( + FATAL_ERROR + "beman_install_library expects a name of the form 'beman.', got '${name}'" + ) + endif() + + set(target_name "${name}") + set(install_component_name "${name}") + set(export_name "${name}") + set(package_name "${name}") + list(GET name_parts -1 component_name) + + install( + TARGETS "${target_name}" + COMPONENT "${install_component_name}" + EXPORT "${export_name}" + FILE_SET HEADERS + ) + + set_target_properties( + "${target_name}" + PROPERTIES EXPORT_NAME "${component_name}" + ) + + include(GNUInstallDirs) + + # Determine the prefix for project-specific variables + string(TOUPPER "${name}" project_prefix) + string(REPLACE "." "_" project_prefix "${project_prefix}") + + option( + ${project_prefix}_INSTALL_CONFIG_FILE_PACKAGE + "Enable building examples. Default: ${PROJECT_IS_TOP_LEVEL}. Values: { ON, OFF }." + ${PROJECT_IS_TOP_LEVEL} + ) + + # By default, install the config package + set(install_config_package ON) + + # Turn OFF installation of config package by default if, + # in order of precedence: + # 1. The specific package variable is set to OFF + # 2. The package name is not in the list of packages to install config files + if(DEFINED BEMAN_INSTALL_CONFIG_FILE_PACKAGES) + if( + NOT "${install_component_name}" + IN_LIST + BEMAN_INSTALL_CONFIG_FILE_PACKAGES + ) + set(install_config_package OFF) + endif() + endif() + if(DEFINED ${project_prefix}_INSTALL_CONFIG_FILE_PACKAGE) + set(install_config_package + ${${project_prefix}_INSTALL_CONFIG_FILE_PACKAGE} + ) + endif() + + if(install_config_package) + message( + DEBUG + "beman-install-library: Installing a config package for '${name}'" + ) + + include(CMakePackageConfigHelpers) + + find_file( + config_file_template + NAMES "${package_name}-config.cmake.in" + PATHS "${CMAKE_CURRENT_SOURCE_DIR}" + NO_DEFAULT_PATH + NO_CACHE + REQUIRED + ) + set(config_package_file + "${CMAKE_CURRENT_BINARY_DIR}/${package_name}-config.cmake" + ) + set(package_install_dir "${CMAKE_INSTALL_LIBDIR}/cmake/${package_name}") + configure_package_config_file( + "${config_file_template}" + "${config_package_file}" + INSTALL_DESTINATION "${package_install_dir}" + PATH_VARS PROJECT_NAME PROJECT_VERSION + ) + + set(config_version_file + "${CMAKE_CURRENT_BINARY_DIR}/${package_name}-config-version.cmake" + ) + write_basic_package_version_file( + "${config_version_file}" + VERSION "${PROJECT_VERSION}" + COMPATIBILITY ExactVersion + ) + + install( + FILES "${config_package_file}" "${config_version_file}" + DESTINATION "${package_install_dir}" + COMPONENT "${install_component_name}" + ) + + set(config_targets_file "${package_name}-targets.cmake") + install( + EXPORT "${export_name}" + DESTINATION "${package_install_dir}" + NAMESPACE beman:: + FILE "${config_targets_file}" + COMPONENT "${install_component_name}" + ) + else() + message( + DEBUG + "beman-install-library: Not installing a config package for '${name}'" + ) + endif() +endfunction() diff --git a/infra/cmake/gnu-toolchain.cmake b/infra/cmake/gnu-toolchain.cmake new file mode 100644 index 0000000..d3b9f92 --- /dev/null +++ b/infra/cmake/gnu-toolchain.cmake @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +# This toolchain file is not meant to be used directly, +# but to be invoked by CMake preset and GitHub CI. +# +# This toolchain file configures for GNU family of compiler. +# +# BEMAN_BUILDSYS_SANITIZER: +# This optional CMake parameter is not meant for public use and is subject to +# change. +# Possible values: +# - MaxSan: configures gcc and g++ to use all available non-conflicting +# sanitizers. +# - TSan: configures gcc and g++ to enable the use of thread sanitizer + +include_guard(GLOBAL) + +set(CMAKE_C_COMPILER gcc) +set(CMAKE_CXX_COMPILER g++) + +if(BEMAN_BUILDSYS_SANITIZER STREQUAL "MaxSan") + set(SANITIZER_FLAGS + "-fsanitize=address -fsanitize=leak -fsanitize=pointer-compare -fsanitize=pointer-subtract -fsanitize=undefined -fsanitize-undefined-trap-on-error" + ) +elseif(BEMAN_BUILDSYS_SANITIZER STREQUAL "TSan") + set(SANITIZER_FLAGS "-fsanitize=thread") +endif() + +set(CMAKE_C_FLAGS_DEBUG_INIT "${SANITIZER_FLAGS}") +set(CMAKE_CXX_FLAGS_DEBUG_INIT "${SANITIZER_FLAGS}") + +set(RELEASE_FLAGS "-O3 ${SANITIZER_FLAGS}") + +set(CMAKE_C_FLAGS_RELWITHDEBINFO_INIT "${RELEASE_FLAGS}") +set(CMAKE_CXX_FLAGS_RELWITHDEBINFO_INIT "${RELEASE_FLAGS}") + +set(CMAKE_C_FLAGS_RELEASE_INIT "${RELEASE_FLAGS}") +set(CMAKE_CXX_FLAGS_RELEASE_INIT "${RELEASE_FLAGS}") + +# Add this dir to the module path so that `find_package(beman-install-library)` works +list(APPEND CMAKE_PREFIX_PATH "${CMAKE_CURRENT_LIST_DIR}") diff --git a/infra/cmake/llvm-libc++-toolchain.cmake b/infra/cmake/llvm-libc++-toolchain.cmake new file mode 100644 index 0000000..76264c6 --- /dev/null +++ b/infra/cmake/llvm-libc++-toolchain.cmake @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: BSL-1.0 + +# This toolchain file is not meant to be used directly, +# but to be invoked by CMake preset and GitHub CI. +# +# This toolchain file configures for LLVM family of compiler. +# +# BEMAN_BUILDSYS_SANITIZER: +# This optional CMake parameter is not meant for public use and is subject to +# change. +# Possible values: +# - MaxSan: configures clang and clang++ to use all available non-conflicting +# sanitizers. +# - TSan: configures clang and clang++ to enable the use of thread sanitizer. + +include(${CMAKE_CURRENT_LIST_DIR}/llvm-toolchain.cmake) + +if(NOT CMAKE_CXX_FLAGS MATCHES "-stdlib=libc\\+\\+") + string(APPEND CMAKE_CXX_FLAGS " -stdlib=libc++") +endif() diff --git a/infra/cmake/llvm-toolchain.cmake b/infra/cmake/llvm-toolchain.cmake new file mode 100644 index 0000000..f1623b7 --- /dev/null +++ b/infra/cmake/llvm-toolchain.cmake @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +# This toolchain file is not meant to be used directly, +# but to be invoked by CMake preset and GitHub CI. +# +# This toolchain file configures for LLVM family of compiler. +# +# BEMAN_BUILDSYS_SANITIZER: +# This optional CMake parameter is not meant for public use and is subject to +# change. +# Possible values: +# - MaxSan: configures clang and clang++ to use all available non-conflicting +# sanitizers. +# - TSan: configures clang and clang++ to enable the use of thread sanitizer. + +include_guard(GLOBAL) + +set(CMAKE_C_COMPILER clang) +set(CMAKE_CXX_COMPILER clang++) + +if(BEMAN_BUILDSYS_SANITIZER STREQUAL "MaxSan") + set(SANITIZER_FLAGS + "-fsanitize=address -fsanitize=leak -fsanitize=pointer-compare -fsanitize=pointer-subtract -fsanitize=undefined -fsanitize-undefined-trap-on-error" + ) +elseif(BEMAN_BUILDSYS_SANITIZER STREQUAL "TSan") + set(SANITIZER_FLAGS "-fsanitize=thread") +endif() + +set(CMAKE_C_FLAGS_DEBUG_INIT "${SANITIZER_FLAGS}") +set(CMAKE_CXX_FLAGS_DEBUG_INIT "${SANITIZER_FLAGS}") + +set(RELEASE_FLAGS "-O3 ${SANITIZER_FLAGS}") + +set(CMAKE_C_FLAGS_RELWITHDEBINFO_INIT "${RELEASE_FLAGS}") +set(CMAKE_CXX_FLAGS_RELWITHDEBINFO_INIT "${RELEASE_FLAGS}") + +set(CMAKE_C_FLAGS_RELEASE_INIT "${RELEASE_FLAGS}") +set(CMAKE_CXX_FLAGS_RELEASE_INIT "${RELEASE_FLAGS}") + +# Add this dir to the module path so that `find_package(beman-install-library)` works +list(APPEND CMAKE_PREFIX_PATH "${CMAKE_CURRENT_LIST_DIR}") diff --git a/infra/cmake/msvc-toolchain.cmake b/infra/cmake/msvc-toolchain.cmake new file mode 100644 index 0000000..bdc24de --- /dev/null +++ b/infra/cmake/msvc-toolchain.cmake @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +# This toolchain file is not meant to be used directly, +# but to be invoked by CMake preset and GitHub CI. +# +# This toolchain file configures for MSVC family of compiler. +# +# BEMAN_BUILDSYS_SANITIZER: +# This optional CMake parameter is not meant for public use and is subject to +# change. +# Possible values: +# - MaxSan: configures cl to use all available non-conflicting sanitizers. +# +# Note that in other toolchain files, TSan is also a possible value for +# BEMAN_BUILDSYS_SANITIZER, however, MSVC does not support thread sanitizer, +# thus this value is omitted. + +include_guard(GLOBAL) + +set(CMAKE_C_COMPILER cl) +set(CMAKE_CXX_COMPILER cl) + +if(BEMAN_BUILDSYS_SANITIZER STREQUAL "MaxSan") + # /Zi flag (add debug symbol) is needed when using address sanitizer + # See C5072: https://learn.microsoft.com/en-us/cpp/error-messages/compiler-warnings/compiler-warning-c5072 + set(SANITIZER_FLAGS "/fsanitize=address /Zi") +endif() + +set(CMAKE_CXX_FLAGS_DEBUG_INIT "/EHsc /permissive- ${SANITIZER_FLAGS}") +set(CMAKE_C_FLAGS_DEBUG_INIT "/EHsc /permissive- ${SANITIZER_FLAGS}") + +set(RELEASE_FLAGS "/EHsc /permissive- /O2 ${SANITIZER_FLAGS}") + +set(CMAKE_C_FLAGS_RELWITHDEBINFO_INIT "${RELEASE_FLAGS}") +set(CMAKE_CXX_FLAGS_RELWITHDEBINFO_INIT "${RELEASE_FLAGS}") + +set(CMAKE_C_FLAGS_RELEASE_INIT "${RELEASE_FLAGS}") +set(CMAKE_CXX_FLAGS_RELEASE_INIT "${RELEASE_FLAGS}") + +# Add this dir to the module path so that `find_package(beman-install-library)` works +list(APPEND CMAKE_PREFIX_PATH "${CMAKE_CURRENT_LIST_DIR}") diff --git a/infra/cmake/use-fetch-content.cmake b/infra/cmake/use-fetch-content.cmake new file mode 100644 index 0000000..4ed4839 --- /dev/null +++ b/infra/cmake/use-fetch-content.cmake @@ -0,0 +1,187 @@ +cmake_minimum_required(VERSION 3.24) + +include(FetchContent) + +if(NOT BEMAN_EXEMPLAR_LOCKFILE) + set(BEMAN_EXEMPLAR_LOCKFILE + "lockfile.json" + CACHE FILEPATH + "Path to the dependency lockfile for the Beman Exemplar." + ) +endif() + +set(BemanExemplar_projectDir "${CMAKE_CURRENT_LIST_DIR}/../..") +message(TRACE "BemanExemplar_projectDir=\"${BemanExemplar_projectDir}\"") + +message(TRACE "BEMAN_EXEMPLAR_LOCKFILE=\"${BEMAN_EXEMPLAR_LOCKFILE}\"") +file( + REAL_PATH + "${BEMAN_EXEMPLAR_LOCKFILE}" + BemanExemplar_lockfile + BASE_DIRECTORY "${BemanExemplar_projectDir}" + EXPAND_TILDE +) +message(DEBUG "Using lockfile: \"${BemanExemplar_lockfile}\"") + +# Force CMake to reconfigure the project if the lockfile changes +set_property( + DIRECTORY "${BemanExemplar_projectDir}" + APPEND + PROPERTY CMAKE_CONFIGURE_DEPENDS "${BemanExemplar_lockfile}" +) + +# For more on the protocol for this function, see: +# https://cmake.org/cmake/help/latest/command/cmake_language.html#provider-commands +function(BemanExemplar_provideDependency method package_name) + # Read the lockfile + file(READ "${BemanExemplar_lockfile}" BemanExemplar_rootObj) + + # Get the "dependencies" field and store it in BemanExemplar_dependenciesObj + string( + JSON + BemanExemplar_dependenciesObj + ERROR_VARIABLE BemanExemplar_error + GET "${BemanExemplar_rootObj}" + "dependencies" + ) + if(BemanExemplar_error) + message(FATAL_ERROR "${BemanExemplar_lockfile}: ${BemanExemplar_error}") + endif() + + # Get the length of the libraries array and store it in BemanExemplar_dependenciesObj + string( + JSON + BemanExemplar_numDependencies + ERROR_VARIABLE BemanExemplar_error + LENGTH "${BemanExemplar_dependenciesObj}" + ) + if(BemanExemplar_error) + message(FATAL_ERROR "${BemanExemplar_lockfile}: ${BemanExemplar_error}") + endif() + + if(BemanExemplar_numDependencies EQUAL 0) + return() + endif() + + # Loop over each dependency object + math(EXPR BemanExemplar_maxIndex "${BemanExemplar_numDependencies} - 1") + foreach(BemanExemplar_index RANGE "${BemanExemplar_maxIndex}") + set(BemanExemplar_errorPrefix + "${BemanExemplar_lockfile}, dependency ${BemanExemplar_index}" + ) + + # Get the dependency object at BemanExemplar_index + # and store it in BemanExemplar_depObj + string( + JSON + BemanExemplar_depObj + ERROR_VARIABLE BemanExemplar_error + GET "${BemanExemplar_dependenciesObj}" + "${BemanExemplar_index}" + ) + if(BemanExemplar_error) + message( + FATAL_ERROR + "${BemanExemplar_errorPrefix}: ${BemanExemplar_error}" + ) + endif() + + # Get the "name" field and store it in BemanExemplar_name + string( + JSON + BemanExemplar_name + ERROR_VARIABLE BemanExemplar_error + GET "${BemanExemplar_depObj}" + "name" + ) + if(BemanExemplar_error) + message( + FATAL_ERROR + "${BemanExemplar_errorPrefix}: ${BemanExemplar_error}" + ) + endif() + + # Get the "package_name" field and store it in BemanExemplar_pkgName + string( + JSON + BemanExemplar_pkgName + ERROR_VARIABLE BemanExemplar_error + GET "${BemanExemplar_depObj}" + "package_name" + ) + if(BemanExemplar_error) + message( + FATAL_ERROR + "${BemanExemplar_errorPrefix}: ${BemanExemplar_error}" + ) + endif() + + # Get the "git_repository" field and store it in BemanExemplar_repo + string( + JSON + BemanExemplar_repo + ERROR_VARIABLE BemanExemplar_error + GET "${BemanExemplar_depObj}" + "git_repository" + ) + if(BemanExemplar_error) + message( + FATAL_ERROR + "${BemanExemplar_errorPrefix}: ${BemanExemplar_error}" + ) + endif() + + # Get the "git_tag" field and store it in BemanExemplar_tag + string( + JSON + BemanExemplar_tag + ERROR_VARIABLE BemanExemplar_error + GET "${BemanExemplar_depObj}" + "git_tag" + ) + if(BemanExemplar_error) + message( + FATAL_ERROR + "${BemanExemplar_errorPrefix}: ${BemanExemplar_error}" + ) + endif() + + if(method STREQUAL "FIND_PACKAGE") + if(package_name STREQUAL BemanExemplar_pkgName) + string( + APPEND + BemanExemplar_debug + "Redirecting find_package calls for ${BemanExemplar_pkgName} " + "to FetchContent logic.\n" + ) + string( + APPEND + BemanExemplar_debug + "Fetching ${BemanExemplar_repo} at " + "${BemanExemplar_tag} according to ${BemanExemplar_lockfile}." + ) + message(DEBUG "${BemanExemplar_debug}") + FetchContent_Declare( + "${BemanExemplar_name}" + GIT_REPOSITORY "${BemanExemplar_repo}" + GIT_TAG "${BemanExemplar_tag}" + EXCLUDE_FROM_ALL + ) + set(INSTALL_GTEST OFF) # Disable GoogleTest installation + FetchContent_MakeAvailable("${BemanExemplar_name}") + + # Important! _FOUND tells CMake that `find_package` is + # not needed for this package anymore + set("${BemanExemplar_pkgName}_FOUND" TRUE PARENT_SCOPE) + endif() + endif() + endforeach() +endfunction() + +cmake_language( + SET_DEPENDENCY_PROVIDER BemanExemplar_provideDependency + SUPPORTED_METHODS FIND_PACKAGE +) + +# Add this dir to the module path so that `find_package(beman-install-library)` works +list(APPEND CMAKE_PREFIX_PATH "${CMAKE_CURRENT_LIST_DIR}") diff --git a/infra/tools/beman-submodule/README.md b/infra/tools/beman-submodule/README.md new file mode 100644 index 0000000..36883ad --- /dev/null +++ b/infra/tools/beman-submodule/README.md @@ -0,0 +1,63 @@ +# beman-submodule + + + +## What is this script? + +`beman-submodule` provides some of the features of `git submodule`, adding child git +repositories to a parent git repository, but unlike with `git submodule`, the entire child +repo is directly checked in, so only maintainers, not users, need to run this script. The +command line interface mimics `git submodule`'s. + +## How do I add a beman submodule to my repository? + +The first beman submodule you should add is this repository, `infra/`, which you can +bootstrap by running: + + +```sh +curl -s https://raw.githubusercontent.com/bemanproject/infra/refs/heads/main/tools/beman-submodule/beman-submodule | python3 - add https://github.com/bemanproject/infra.git +``` + +Once that's added, you can run the script from `infra/tools/beman-submodule/beman-submodule`. + +## How do I update a beman submodule to the latest trunk? + +You can run `beman-submodule update --remote` to update all beman submodule to latest +trunk, or e.g. `beman-submodule update --remote infra` to update only a specific one. + +## How does it work under the hood? + +Along with the files from the child repository, it creates a dotfile called +`.beman_submodule`, which looks like this: + +```ini +[beman_submodule] +remote=https://github.com/bemanproject/infra.git +commit_hash=9b88395a86c4290794e503e94d8213b6c442ae77 +``` + +## How do I update a beman submodule to a specific commit or change the remote URL? + +You can edit the corresponding lines in the `.beman_submodule` file and run +`beman-submodule update` to update the state of the beman submodule to the new +`.beman_submodule` settings. + +## How can I make CI ensure that my beman submodules are in a valid state? + +Add this job to your CI workflow: + +```yaml + beman-submodule-test: + runs-on: ubuntu-latest + name: "Check beman submodules for consistency" + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: beman submodule consistency check + run: | + (set -o pipefail; ./infra/tools/beman-submodule/beman-submodule status | grep -qvF '+') +``` + +This will fail if the contents of any beman submodule don't match what's specified in the +`.beman_submodule` file. diff --git a/infra/tools/beman-submodule/beman-submodule b/infra/tools/beman-submodule/beman-submodule new file mode 100755 index 0000000..66cb96e --- /dev/null +++ b/infra/tools/beman-submodule/beman-submodule @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 + +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +import argparse +import configparser +import filecmp +import glob +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +def directory_compare( + reference: str | Path, actual: str | Path, ignore, allow_untracked_files: bool): + reference, actual = Path(reference), Path(actual) + + compared = filecmp.dircmp(reference, actual, ignore=ignore) + if (compared.left_only + or (compared.right_only and not allow_untracked_files) + or compared.diff_files): + return False + for common_dir in compared.common_dirs: + path1 = reference / common_dir + path2 = actual / common_dir + if not directory_compare(path1, path2, ignore, allow_untracked_files): + return False + return True + +class BemanSubmodule: + def __init__( + self, dirpath: str | Path, remote: str, commit_hash: str, + allow_untracked_files: bool): + self.dirpath = Path(dirpath) + self.remote = remote + self.commit_hash = commit_hash + self.allow_untracked_files = allow_untracked_files + +def parse_beman_submodule_file(path): + config = configparser.ConfigParser() + read_result = config.read(path) + def fail(): + raise Exception(f'Failed to parse {path} as a .beman_submodule file') + if not read_result: + fail() + if not 'beman_submodule' in config: + fail() + if not 'remote' in config['beman_submodule']: + fail() + if not 'commit_hash' in config['beman_submodule']: + fail() + allow_untracked_files = config.getboolean( + 'beman_submodule', 'allow_untracked_files', fallback=False) + return BemanSubmodule( + Path(path).resolve().parent, + config['beman_submodule']['remote'], + config['beman_submodule']['commit_hash'], + allow_untracked_files) + +def get_beman_submodule(path: str | Path): + beman_submodule_filepath = Path(path) / '.beman_submodule' + + if beman_submodule_filepath.is_file(): + return parse_beman_submodule_file(beman_submodule_filepath) + else: + return None + +def find_beman_submodules_in(path): + path = Path(path) + assert path.is_dir() + + result = [] + for dirpath, _, filenames in path.walk(): + if '.beman_submodule' in filenames: + result.append(parse_beman_submodule_file(dirpath / '.beman_submodule')) + return sorted(result, key=lambda module: module.dirpath) + +def cwd_git_repository_path(): + process = subprocess.run( + ['git', 'rev-parse', '--show-toplevel'], capture_output=True, text=True, + check=False) + if process.returncode == 0: + return process.stdout.strip() + elif "fatal: not a git repository" in process.stderr: + return None + else: + raise Exception("git rev-parse --show-toplevel failed") + +def clone_beman_submodule_into_tmpdir(beman_submodule, remote): + tmpdir = tempfile.TemporaryDirectory() + subprocess.run( + ['git', 'clone', beman_submodule.remote, tmpdir.name], capture_output=True, + check=True) + if not remote: + subprocess.run( + ['git', '-C', tmpdir.name, 'reset', '--hard', beman_submodule.commit_hash], + capture_output=True, check=True) + return tmpdir + +def get_paths(beman_submodule): + tmpdir = clone_beman_submodule_into_tmpdir(beman_submodule, False) + paths = set(glob.glob('*', root_dir=Path(tmpdir.name), include_hidden=True)) + paths.remove('.git') + return paths + +def beman_submodule_status(beman_submodule): + tmpdir = clone_beman_submodule_into_tmpdir(beman_submodule, False) + if directory_compare( + tmpdir.name, beman_submodule.dirpath, ['.beman_submodule', '.git'], + beman_submodule.allow_untracked_files): + status_character=' ' + else: + status_character='+' + parent_repo_path = cwd_git_repository_path() + if not parent_repo_path: + raise Exception('this is not a git repository') + relpath = Path(beman_submodule.dirpath).relative_to(Path(parent_repo_path)) + return status_character + ' ' + beman_submodule.commit_hash + ' ' + str(relpath) + +def beman_submodule_update(beman_submodule, remote): + tmpdir = clone_beman_submodule_into_tmpdir(beman_submodule, remote) + tmp_path = Path(tmpdir.name) + sha_process = subprocess.run( + ['git', 'rev-parse', 'HEAD'], capture_output=True, check=True, text=True, + cwd=tmp_path) + + if beman_submodule.allow_untracked_files: + for path in get_paths(beman_submodule): + path2 = Path(beman_submodule.dirpath) / path + if Path(path2).is_dir(): + shutil.rmtree(path2) + elif Path(path2).is_file(): + os.remove(path2) + else: + shutil.rmtree(beman_submodule.dirpath) + + submodule_path = tmp_path / '.beman_submodule' + with open(submodule_path, 'w') as f: + f.write('[beman_submodule]\n') + f.write(f'remote={beman_submodule.remote}\n') + f.write(f'commit_hash={sha_process.stdout.strip()}\n') + if beman_submodule.allow_untracked_files: + f.write(f'allow_untracked_files=True\n') + shutil.rmtree(tmp_path / '.git') + shutil.copytree(tmp_path, beman_submodule.dirpath, dirs_exist_ok=True) + +def update_command(remote, path): + if not path: + parent_repo_path = cwd_git_repository_path() + if not parent_repo_path: + raise Exception('this is not a git repository') + beman_submodules = find_beman_submodules_in(parent_repo_path) + else: + beman_submodule = get_beman_submodule(path) + if not beman_submodule: + raise Exception(f'{path} is not a beman_submodule') + beman_submodules = [beman_submodule] + for beman_submodule in beman_submodules: + beman_submodule_update(beman_submodule, remote) + +def add_command(repository, path, allow_untracked_files): + tmpdir = tempfile.TemporaryDirectory() + subprocess.run( + ['git', 'clone', repository], capture_output=True, check=True, cwd=tmpdir.name) + repository_name = os.listdir(tmpdir.name)[0] + if not path: + path = Path(repository_name) + else: + path = Path(path) + if not allow_untracked_files and path.exists(): + raise Exception(f'{path} exists') + path.mkdir(exist_ok=allow_untracked_files) + tmpdir_repo = Path(tmpdir.name) / repository_name + sha_process = subprocess.run( + ['git', 'rev-parse', 'HEAD'], capture_output=True, check=True, text=True, + cwd=tmpdir_repo) + with open(tmpdir_repo / '.beman_submodule', 'w') as f: + f.write('[beman_submodule]\n') + f.write(f'remote={repository}\n') + f.write(f'commit_hash={sha_process.stdout.strip()}\n') + if allow_untracked_files: + f.write(f'allow_untracked_files=True\n') + shutil.rmtree(tmpdir_repo /'.git') + shutil.copytree(tmpdir_repo, path, dirs_exist_ok=True) + +def status_command(paths): + if not paths: + parent_repo_path = cwd_git_repository_path() + if not parent_repo_path: + raise Exception('this is not a git repository') + beman_submodules = find_beman_submodules_in(parent_repo_path) + else: + beman_submodules = [] + for path in paths: + beman_submodule = get_beman_submodule(path) + if not beman_submodule: + raise Exception(f'{path} is not a beman_submodule') + beman_submodules.append(beman_submodule) + for beman_submodule in beman_submodules: + print(beman_submodule_status(beman_submodule)) + +def get_parser(): + parser = argparse.ArgumentParser(description='Beman pseudo-submodule tool') + subparsers = parser.add_subparsers(dest='command', help='available commands') + parser_update = subparsers.add_parser('update', help='update beman_submodules') + parser_update.add_argument( + '--remote', action='store_true', + help='update a beman_submodule to its latest from upstream') + parser_update.add_argument( + 'beman_submodule_path', nargs='?', + help='relative path to the beman_submodule to update') + parser_add = subparsers.add_parser('add', help='add a new beman_submodule') + parser_add.add_argument('repository', help='git repository to add') + parser_add.add_argument( + 'path', nargs='?', help='path where the repository will be added') + parser_add.add_argument( + '--allow-untracked-files', action='store_true', + help='the beman_submodule will not occupy the subdirectory exclusively') + parser_status = subparsers.add_parser( + 'status', help='show the status of beman_submodules') + parser_status.add_argument('paths', nargs='*') + return parser + +def parse_args(args): + return get_parser().parse_args(args); + +def usage(): + return get_parser().format_help() + +def run_command(args): + if args.command == 'update': + update_command(args.remote, args.beman_submodule_path) + elif args.command == 'add': + add_command(args.repository, args.path, args.allow_untracked_files) + elif args.command == 'status': + status_command(args.paths) + else: + raise Exception(usage()) + +def check_for_git(path): + env = os.environ.copy() + if path is not None: + env["PATH"] = path + return shutil.which("git", path=env.get("PATH")) is not None + +def main(): + try: + if not check_for_git(None): + raise Exception('git not found in PATH') + args = parse_args(sys.argv[1:]) + run_command(args) + except Exception as e: + print("Error:", e, file=sys.stderr) + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/infra/tools/beman-submodule/test/test_beman_submodule.py b/infra/tools/beman-submodule/test/test_beman_submodule.py new file mode 100644 index 0000000..b3dcbd5 --- /dev/null +++ b/infra/tools/beman-submodule/test/test_beman_submodule.py @@ -0,0 +1,763 @@ +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +import glob +import os +import pytest +import shutil +import stat +import subprocess +import tempfile +from pathlib import Path + +# https://stackoverflow.com/a/19011259 +import types +import importlib.machinery + +loader = importlib.machinery.SourceFileLoader( + "beman_submodule", str(Path(__file__).parent.resolve().parent / "beman-submodule") +) +beman_submodule = types.ModuleType(loader.name) +loader.exec_module(beman_submodule) + + +def create_test_git_repository(): + tmpdir = tempfile.TemporaryDirectory() + tmp_path = Path(tmpdir.name) + + subprocess.run(["git", "init"], check=True, cwd=tmpdir.name, capture_output=True) + + def make_commit(a_txt_contents): + with open(tmp_path / "a.txt", "w") as f: + f.write(a_txt_contents) + subprocess.run( + ["git", "add", "a.txt"], check=True, cwd=tmpdir.name, capture_output=True + ) + subprocess.run( + [ + "git", + "-c", + "user.name=test", + "-c", + "user.email=test@example.com", + "commit", + '--author="test "', + "-m", + "test", + ], + check=True, + cwd=tmpdir.name, + capture_output=True, + ) + + make_commit("A") + make_commit("a") + return tmpdir + + +def create_test_git_repository2(): + tmpdir = tempfile.TemporaryDirectory() + tmp_path = Path(tmpdir.name) + + subprocess.run(["git", "init"], check=True, cwd=tmpdir.name, capture_output=True) + with open(tmp_path / "a.txt", "w") as f: + f.write("a") + subprocess.run( + ["git", "add", "a.txt"], check=True, cwd=tmpdir.name, capture_output=True + ) + subprocess.run( + [ + "git", + "-c", + "user.name=test", + "-c", + "user.email=test@example.com", + "commit", + '--author="test "', + "-m", + "test", + ], + check=True, + cwd=tmpdir.name, + capture_output=True, + ) + os.remove(tmp_path / "a.txt") + subprocess.run( + ["git", "rm", "a.txt"], check=True, cwd=tmpdir.name, capture_output=True + ) + with open(tmp_path / "b.txt", "w") as f: + f.write("b") + subprocess.run( + ["git", "add", "b.txt"], check=True, cwd=tmpdir.name, capture_output=True + ) + subprocess.run( + [ + "git", + "-c", + "user.name=test", + "-c", + "user.email=test@example.com", + "commit", + '--author="test "', + "-m", + "test", + ], + check=True, + cwd=tmpdir.name, + capture_output=True, + ) + return tmpdir + + +def test_directory_compare(): + def create_dir_structure(dir_path: Path): + bar_path = dir_path / "bar" + os.makedirs(bar_path) + + with open(dir_path / "foo.txt", "w") as f: + f.write("foo") + with open(bar_path / "baz.txt", "w") as f: + f.write("baz") + + with tempfile.TemporaryDirectory() as dir_a, tempfile.TemporaryDirectory() as dir_b: + path_a = Path(dir_a) + path_b = Path(dir_b) + + create_dir_structure(path_a) + create_dir_structure(path_b) + + assert beman_submodule.directory_compare(dir_a, dir_b, [], False) + + with open(path_a / "bar" / "quux.txt", "w") as f: + f.write("quux") + + assert not beman_submodule.directory_compare(path_a, path_b, [], False) + assert beman_submodule.directory_compare(path_a, path_b, ["quux.txt"], False) + + +def test_directory_compare_untracked_files(): + def create_dir_structure(dir_path: Path): + bar_path = dir_path / "bar" + os.makedirs(bar_path) + + with open(dir_path / "foo.txt", "w") as f: + f.write("foo") + with open(bar_path / "baz.txt", "w") as f: + f.write("baz") + + with tempfile.TemporaryDirectory() as reference, tempfile.TemporaryDirectory() as actual: + path_a = Path(reference) + path_b = Path(actual) + + create_dir_structure(path_a) + create_dir_structure(path_b) + (path_b / "c.txt").touch() + + assert beman_submodule.directory_compare(reference, actual, [], True) + + with open(path_a / "bar" / "quux.txt", "w") as f: + f.write("quux") + + assert not beman_submodule.directory_compare(path_a, path_b, [], True) + assert beman_submodule.directory_compare(path_a, path_b, ["quux.txt"], True) + + +def test_parse_beman_submodule_file(): + def valid_file(): + tmpfile = tempfile.NamedTemporaryFile() + tmpfile.write("[beman_submodule]\n".encode("utf-8")) + tmpfile.write("remote=git@github.com:bemanproject/infra.git\n".encode("utf-8")) + tmpfile.write( + "commit_hash=9b88395a86c4290794e503e94d8213b6c442ae77\n".encode("utf-8") + ) + tmpfile.flush() + module = beman_submodule.parse_beman_submodule_file(tmpfile.name) + assert module.dirpath == Path(tmpfile.name).resolve().parent + assert module.remote == "git@github.com:bemanproject/infra.git" + assert module.commit_hash == "9b88395a86c4290794e503e94d8213b6c442ae77" + + valid_file() + + def invalid_file_missing_remote(): + threw = False + try: + tmpfile = tempfile.NamedTemporaryFile() + tmpfile.write("[beman_submodule]\n".encode("utf-8")) + tmpfile.write( + "commit_hash=9b88395a86c4290794e503e94d8213b6c442ae77\n".encode("utf-8") + ) + tmpfile.flush() + beman_submodule.parse_beman_submodule_file(tmpfile.name) + except: + threw = True + assert threw + + invalid_file_missing_remote() + + def invalid_file_missing_commit_hash(): + threw = False + try: + tmpfile = tempfile.NamedTemporaryFile() + tmpfile.write("[beman_submodule]\n".encode("utf-8")) + tmpfile.write( + "remote=git@github.com:bemanproject/infra.git\n".encode("utf-8") + ) + tmpfile.flush() + beman_submodule.parse_beman_submodule_file(tmpfile.name) + except: + threw = True + assert threw + + invalid_file_missing_commit_hash() + + def invalid_file_wrong_section(): + threw = False + try: + tmpfile = tempfile.NamedTemporaryFile() + tmpfile.write("[invalid]\n".encode("utf-8")) + tmpfile.write( + "remote=git@github.com:bemanproject/infra.git\n".encode("utf-8") + ) + tmpfile.write( + "commit_hash=9b88395a86c4290794e503e94d8213b6c442ae77\n".encode("utf-8") + ) + tmpfile.flush() + beman_submodule.parse_beman_submodule_file(tmpfile.name) + except: + threw = True + assert threw + + invalid_file_wrong_section() + + +def test_get_beman_submodule(): + tmpdir = create_test_git_repository() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + beman_submodule.add_command(tmpdir.name, "foo", False) + assert beman_submodule.get_beman_submodule("foo") + os.remove("foo/.beman_submodule") + assert not beman_submodule.get_beman_submodule("foo") + os.chdir(original_cwd) + + +def test_find_beman_submodules_in(): + tmpdir = create_test_git_repository() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + beman_submodule.add_command(tmpdir.name, "foo", False) + beman_submodule.add_command(tmpdir.name, "bar", False) + beman_submodules = beman_submodule.find_beman_submodules_in(tmpdir2.name) + sha_process = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + sha = sha_process.stdout.strip() + assert beman_submodules[0].dirpath == Path(tmpdir2.name) / "bar" + assert beman_submodules[0].remote == tmpdir.name + assert beman_submodules[0].commit_hash == sha + assert beman_submodules[1].dirpath == Path(tmpdir2.name) / "foo" + assert beman_submodules[1].remote == tmpdir.name + assert beman_submodules[1].commit_hash == sha + os.chdir(original_cwd) + + +def test_cwd_git_repository_path(): + original_cwd = Path.cwd() + tmpdir = tempfile.TemporaryDirectory() + os.chdir(tmpdir.name) + assert not beman_submodule.cwd_git_repository_path() + subprocess.run(["git", "init"]) + assert beman_submodule.cwd_git_repository_path() == tmpdir.name + os.chdir(original_cwd) + + +def test_clone_beman_submodule_into_tmpdir(): + tmpdir = create_test_git_repository() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + sha_process = subprocess.run( + ["git", "rev-parse", "HEAD^"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + sha = sha_process.stdout.strip() + beman_submodule.add_command(tmpdir.name, "foo", False) + module = beman_submodule.get_beman_submodule(Path(tmpdir2.name) / "foo") + module.commit_hash = sha + tmpdir3 = beman_submodule.clone_beman_submodule_into_tmpdir(module, False) + assert not beman_submodule.directory_compare( + tmpdir.name, tmpdir3.name, [".git"], False + ) + tmpdir4 = beman_submodule.clone_beman_submodule_into_tmpdir(module, True) + assert beman_submodule.directory_compare(tmpdir.name, tmpdir4.name, [".git"], False) + subprocess.run( + ["git", "reset", "--hard", sha], + capture_output=True, + check=True, + cwd=tmpdir.name, + ) + assert beman_submodule.directory_compare(tmpdir.name, tmpdir3.name, [".git"], False) + os.chdir(original_cwd) + + +def test_get_paths(): + tmpdir = create_test_git_repository() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + beman_submodule.add_command(tmpdir.name, "foo", False) + module = beman_submodule.get_beman_submodule(Path(tmpdir2.name) / "foo") + assert beman_submodule.get_paths(module) == set(["a.txt"]) + os.chdir(original_cwd) + + +def test_beman_submodule_status(): + tmpdir = create_test_git_repository() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + beman_submodule.add_command(tmpdir.name, "foo", False) + sha_process = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + sha = sha_process.stdout.strip() + assert " " + sha + " foo" == beman_submodule.beman_submodule_status( + beman_submodule.get_beman_submodule(Path(tmpdir2.name) / "foo") + ) + with open(Path(tmpdir2.name) / "foo" / "a.txt", "w") as f: + f.write("b") + assert "+ " + sha + " foo" == beman_submodule.beman_submodule_status( + beman_submodule.get_beman_submodule(Path(tmpdir2.name) / "foo") + ) + os.chdir(original_cwd) + + +def test_update_command_no_paths(): + tmpdir = create_test_git_repository() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + orig_sha_process = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + orig_sha = orig_sha_process.stdout.strip() + parent_sha_process = subprocess.run( + ["git", "rev-parse", "HEAD^"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + parent_sha = parent_sha_process.stdout.strip() + parent_parent_sha_process = subprocess.run( + ["git", "rev-parse", "HEAD^"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + parent_parent_sha = parent_parent_sha_process.stdout.strip() + subprocess.run( + ["git", "reset", "--hard", parent_parent_sha], + capture_output=True, + check=True, + cwd=tmpdir.name, + ) + beman_submodule.add_command(tmpdir.name, "foo", False) + beman_submodule.add_command(tmpdir.name, "bar", False) + subprocess.run( + ["git", "reset", "--hard", orig_sha], + capture_output=True, + check=True, + cwd=tmpdir.name, + ) + with open(Path(tmpdir2.name) / "foo" / ".beman_submodule", "w") as f: + f.write(f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={parent_sha}\n") + with open(Path(tmpdir2.name) / "bar" / ".beman_submodule", "w") as f: + f.write(f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={parent_sha}\n") + beman_submodule.update_command(False, None) + with open(Path(tmpdir2.name) / "foo" / ".beman_submodule", "r") as f: + assert ( + f.read() + == f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={parent_sha}\n" + ) + with open(Path(tmpdir2.name) / "bar" / ".beman_submodule", "r") as f: + assert ( + f.read() + == f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={parent_sha}\n" + ) + subprocess.run( + ["git", "reset", "--hard", parent_sha], + capture_output=True, + check=True, + cwd=tmpdir.name, + ) + assert beman_submodule.directory_compare( + tmpdir.name, Path(tmpdir2.name) / "foo", [".git", ".beman_submodule"], False + ) + assert beman_submodule.directory_compare( + tmpdir.name, Path(tmpdir2.name) / "bar", [".git", ".beman_submodule"], False + ) + subprocess.run( + ["git", "reset", "--hard", orig_sha], + capture_output=True, + check=True, + cwd=tmpdir.name, + ) + beman_submodule.update_command(True, None) + with open(Path(tmpdir2.name) / "foo" / ".beman_submodule", "r") as f: + assert ( + f.read() + == f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={orig_sha}\n" + ) + with open(Path(tmpdir2.name) / "bar" / ".beman_submodule", "r") as f: + assert ( + f.read() + == f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={orig_sha}\n" + ) + assert beman_submodule.directory_compare( + tmpdir.name, Path(tmpdir2.name) / "foo", [".git", ".beman_submodule"], False + ) + assert beman_submodule.directory_compare( + tmpdir.name, Path(tmpdir2.name) / "bar", [".git", ".beman_submodule"], False + ) + os.chdir(original_cwd) + + +def test_update_command_with_path(): + tmpdir = create_test_git_repository() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + orig_sha_process = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + orig_sha = orig_sha_process.stdout.strip() + parent_sha_process = subprocess.run( + ["git", "rev-parse", "HEAD^"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + parent_sha = parent_sha_process.stdout.strip() + parent_parent_sha_process = subprocess.run( + ["git", "rev-parse", "HEAD^"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + parent_parent_sha = parent_parent_sha_process.stdout.strip() + subprocess.run( + ["git", "reset", "--hard", parent_parent_sha], + capture_output=True, + check=True, + cwd=tmpdir.name, + ) + tmpdir_parent_parent_copy = tempfile.TemporaryDirectory() + shutil.copytree(tmpdir.name, tmpdir_parent_parent_copy.name, dirs_exist_ok=True) + beman_submodule.add_command(tmpdir.name, "foo", False) + beman_submodule.add_command(tmpdir.name, "bar", False) + subprocess.run( + ["git", "reset", "--hard", orig_sha], + capture_output=True, + check=True, + cwd=tmpdir.name, + ) + with open(Path(tmpdir2.name) / "foo" / ".beman_submodule", "w") as f: + f.write(f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={parent_sha}\n") + with open(Path(tmpdir2.name) / "bar" / ".beman_submodule", "w") as f: + f.write(f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={parent_sha}\n") + beman_submodule.update_command(False, "foo") + with open(Path(tmpdir2.name) / "foo" / ".beman_submodule", "r") as f: + assert ( + f.read() + == f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={parent_sha}\n" + ) + with open(Path(tmpdir2.name) / "bar" / ".beman_submodule", "r") as f: + assert ( + f.read() + == f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={parent_sha}\n" + ) + subprocess.run( + ["git", "reset", "--hard", parent_sha], + capture_output=True, + check=True, + cwd=tmpdir.name, + ) + assert beman_submodule.directory_compare( + tmpdir.name, Path(tmpdir2.name) / "foo", [".git", ".beman_submodule"], False + ) + assert beman_submodule.directory_compare( + tmpdir_parent_parent_copy.name, + Path(tmpdir2.name) / "bar", + [".git", ".beman_submodule"], + False, + ) + subprocess.run( + ["git", "reset", "--hard", orig_sha], + capture_output=True, + check=True, + cwd=tmpdir.name, + ) + beman_submodule.update_command(True, "foo") + with open(Path(tmpdir2.name) / "foo" / ".beman_submodule", "r") as f: + assert ( + f.read() + == f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={orig_sha}\n" + ) + with open(Path(tmpdir2.name) / "bar" / ".beman_submodule", "r") as f: + assert ( + f.read() + == f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={parent_sha}\n" + ) + assert beman_submodule.directory_compare( + tmpdir.name, Path(tmpdir2.name) / "foo", [".git", ".beman_submodule"], False + ) + assert beman_submodule.directory_compare( + tmpdir_parent_parent_copy.name, + Path(tmpdir2.name) / "bar", + [".git", ".beman_submodule"], + False, + ) + os.chdir(original_cwd) + + +def test_update_command_untracked_files(): + tmpdir = create_test_git_repository2() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + orig_sha_process = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + orig_sha = orig_sha_process.stdout.strip() + parent_sha_process = subprocess.run( + ["git", "rev-parse", "HEAD^"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + parent_sha = parent_sha_process.stdout.strip() + os.makedirs(Path(tmpdir2.name) / "foo") + (Path(tmpdir2.name) / "foo" / "c.txt").touch() + with open(Path(tmpdir2.name) / "foo" / ".beman_submodule", "w") as f: + f.write( + f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={parent_sha}\nallow_untracked_files=True" + ) + beman_submodule.update_command(False, "foo") + assert set(["./foo/a.txt", "./foo/c.txt"]) == set(glob.glob("./foo/*.txt")) + beman_submodule.update_command(True, "foo") + assert set(["./foo/b.txt", "./foo/c.txt"]) == set(glob.glob("./foo/*.txt")) + os.chdir(original_cwd) + + +def test_add_command(): + tmpdir = create_test_git_repository() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + beman_submodule.add_command(tmpdir.name, "foo", False) + sha_process = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + sha = sha_process.stdout.strip() + assert beman_submodule.directory_compare( + tmpdir.name, Path(tmpdir2.name) / "foo", [".git", ".beman_submodule"], False + ) + with open(Path(tmpdir2.name) / "foo" / ".beman_submodule", "r") as f: + assert ( + f.read() == f"[beman_submodule]\nremote={tmpdir.name}\ncommit_hash={sha}\n" + ) + os.chdir(original_cwd) + + +def test_add_command_untracked_files(): + tmpdir = create_test_git_repository() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + os.makedirs(Path(tmpdir2.name) / "foo") + (Path(tmpdir2.name) / "foo" / "c.txt").touch() + beman_submodule.add_command(tmpdir.name, "foo", True) + assert set(["./foo/a.txt", "./foo/c.txt"]) == set(glob.glob("./foo/*.txt")) + os.chdir(original_cwd) + + +def test_status_command_no_paths(capsys): + tmpdir = create_test_git_repository() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + beman_submodule.add_command(tmpdir.name, "foo", False) + beman_submodule.add_command(tmpdir.name, "bar", False) + sha_process = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + with open(Path(tmpdir2.name) / "bar" / "a.txt", "w") as f: + f.write("b") + beman_submodule.status_command([]) + sha = sha_process.stdout.strip() + assert capsys.readouterr().out == "+ " + sha + " bar\n" + " " + sha + " foo\n" + os.chdir(original_cwd) + + +def test_status_command_with_path(capsys): + tmpdir = create_test_git_repository() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + beman_submodule.add_command(tmpdir.name, "foo", False) + beman_submodule.add_command(tmpdir.name, "bar", False) + sha_process = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + with open(Path(tmpdir2.name) / "bar" / "a.txt", "w") as f: + f.write("b") + beman_submodule.status_command(["bar"]) + sha = sha_process.stdout.strip() + assert capsys.readouterr().out == "+ " + sha + " bar\n" + os.chdir(original_cwd) + + +def test_status_command_untracked_files(capsys): + tmpdir = create_test_git_repository() + tmpdir2 = create_test_git_repository() + original_cwd = Path.cwd() + os.chdir(tmpdir2.name) + beman_submodule.add_command(tmpdir.name, "foo", True) + sha_process = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + check=True, + text=True, + cwd=tmpdir.name, + ) + (Path(tmpdir2.name) / "foo" / "c.txt").touch() + beman_submodule.status_command(["foo"]) + sha = sha_process.stdout.strip() + assert capsys.readouterr().out == " " + sha + " foo\n" + os.chdir(original_cwd) + + +def test_check_for_git(): + tmpdir = tempfile.TemporaryDirectory() + assert not beman_submodule.check_for_git(tmpdir.name) + fake_git_path = Path(tmpdir.name) / "git" + with open(fake_git_path, "w"): + pass + os.chmod(fake_git_path, stat.S_IRWXU) + assert beman_submodule.check_for_git(tmpdir.name) + + +def test_parse_args(): + def plain_update(): + args = beman_submodule.parse_args(["update"]) + assert args.command == "update" + assert not args.remote + assert not args.beman_submodule_path + + plain_update() + + def update_remote(): + args = beman_submodule.parse_args(["update", "--remote"]) + assert args.command == "update" + assert args.remote + assert not args.beman_submodule_path + + update_remote() + + def update_path(): + args = beman_submodule.parse_args(["update", "infra/"]) + assert args.command == "update" + assert not args.remote + assert args.beman_submodule_path == "infra/" + + update_path() + + def update_path_remote(): + args = beman_submodule.parse_args(["update", "--remote", "infra/"]) + assert args.command == "update" + assert args.remote + assert args.beman_submodule_path == "infra/" + + update_path_remote() + + def plain_add(): + args = beman_submodule.parse_args( + ["add", "git@github.com:bemanproject/infra.git"] + ) + assert args.command == "add" + assert args.repository == "git@github.com:bemanproject/infra.git" + assert not args.path + + plain_add() + + def add_path(): + args = beman_submodule.parse_args( + ["add", "git@github.com:bemanproject/infra.git", "infra/"] + ) + assert args.command == "add" + assert args.repository == "git@github.com:bemanproject/infra.git" + assert args.path == "infra/" + + add_path() + + def plain_status(): + args = beman_submodule.parse_args(["status"]) + assert args.command == "status" + assert args.paths == [] + + plain_status() + + def status_one_module(): + args = beman_submodule.parse_args(["status", "infra/"]) + assert args.command == "status" + assert args.paths == ["infra/"] + + status_one_module() + + def status_multiple_modules(): + args = beman_submodule.parse_args(["status", "infra/", "foobar/"]) + assert args.command == "status" + assert args.paths == ["infra/", "foobar/"] + + status_multiple_modules() diff --git a/lockfile.json b/lockfile.json new file mode 100644 index 0000000..4208a98 --- /dev/null +++ b/lockfile.json @@ -0,0 +1,3 @@ +{ + "dependencies": [] +} diff --git a/rrcp.txt b/rrcp.txt new file mode 100644 index 0000000..7d24ae4 --- /dev/null +++ b/rrcp.txt @@ -0,0 +1,83 @@ +M:Utility GInitialInfo"v0.8.15","async client",10 + +// with optional Message Number +M:Utility 10002 SString"\rHallo\tWorld\n" +// NOTE: w/o MibName! E:2 10002 // MU error + +// NOTE: M:WF.FF.Main 123456 T Octet 1 +// NOTE: M:WF.FF.Main 123456 t +// NOTE: M:OBit L:1 123456 GGoState // with optional Logical Address + +// M:Audio GAudioVolume // without optionl parts +// NOTE: M:Log SStruct 1,-1,3.14 // multiple parameters + +// NOTE: M:RADIO T FREQUENCY 1 // register trap +// NOTE: M:RADIO t // trap response OK +// NOTE: M:RADIO d FREQUENCY 123456789 // trap data message +M:Utility S Binary #36:AAECAwQFBgcICQoLDA0OD8KAwoHDvsO/Cg== // bas64 encoded binary data + +// +// The more real samples: +// +M:Access GHasControl +M:Access THasControl1 +M:Access GOwnSession +M:Access TOwnSession1 +M:Access SReqSession"Monitoring" +M:Audio GAudioVolume +M:Audio TAudioVolume1 +M:Audio SAudioVolume"Level 0" +M:Audio TAudioVolume0 +M:Control GPresetID +M:Control GCurrWF +M:Control TPresetID1 +M:Control TCurrWF1 +M:Control GCurrMission +M:Control TCurrMission1 +// change preset +M:Control SCurrMission"testString" +M:Control SActPreset0 +M:Control TCurrMission0 +M:Control TCurrWF0 +M:Control TPresetID0 +M:Control GTxInhibit +M:Control TTxInhibit1 +M:Control STxInhibit"Disabled" +M:Control TTxInhibit0 +M:Inventory GInvCountCus +M:Inventory GInventoryCus0 +M:Inventory GInvCount +M:Inventory GInventory0 +M:IP GOwnAdrvIV"Control" +M:IP SOwnAdrvIV"Control","testString","testString" +M:Mission GGlobalAddr +M:Mission SGlobalAddr"testString" +M:Mission GMissions +M:Mission GPresets0,1 +M:OBIT GGOState +M:OBIT TGOState1 +M:OBIT GTestErrors +M:OBIT GTestIDs +M:OBIT TErrorEvent1 +M:OBIT TGOState0 +M:OBIT TErrorEvent0 +M:RxTx GPowerLevel +M:RxTx TPowerLevel1 +M:RxTx SPowerLevel"Off" +M:RxTx TPowerLevel0 +M:RxTx GVswr +M:RxTx TVswr1 +M:RxTx GVswrThres +M:RxTx TVswr0 +M:RxTx TVswrThres1 +M:RxTx GVswrThresLev +M:RxTx SVswrThresLev10 +M:RxTx TVswrThres0 +M:Utility TBattStatus1 +M:Utility GBattStatus +M:Utility TBattStatus0 +M:Utility GErrorText0,"English" +M:Utility GInitialInfo"VersionStr","IdString",0 +M:Utility GPing"message" +M:Maintenance SRestart +M:Maintenance SShutdown diff --git a/rrcp_async_tcp_client.cpp b/rrcp_async_tcp_client.cpp new file mode 100644 index 0000000..b3dd392 --- /dev/null +++ b/rrcp_async_tcp_client.cpp @@ -0,0 +1,110 @@ +/*** + * rrcp_async_tcp_client.cpp + * ~~~~~~~~~~~~~~~~~~~~~~~~~ + * + * Copyright (c) 2003-2024 Christopher M. Kohlhoff (chris at kohlhoff dot com) + * + * Distributed under the Boost Software License, Version 1.0. (See accompanying + * file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) + * + * Moderniced from Claus Klein and ChatGPT + ***/ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// optional #define USE_SIMPLE_RRCP_CLIENT +#ifdef USE_SIMPLE_RRCP_CLIENT +#include "async_rrcp_client.hpp" +#else +#include "async_rrcp_client_threadsafe.hpp" +#endif + +namespace +{ + +void print(std::string msg) { fmt::print("{}\n", msg); } + +} // namespace + +// NOLINTNEXTLINE(bugprone-exception-escape) +auto main(int argc, char* argv[]) -> int +{ + if (argc < 3) + { + fmt::print(stderr, "Usage: {} [input_file]\n", argv[0]); // NOLINT + return EXIT_FAILURE; + } + + std::ifstream file; // persistent file object (if used) + std::istream* input_str = &std::cin; // pointer to chosen input stream + + if (argc == 4) + { + file.open(argv[3]); // NOLINT + if (!file) + { + fmt::print(stderr, "cannot open input file: {}\n", argv[3]); // NOLINT + return 2; + } + input_str = &file; + } + + try + { + using namespace rrcp; + + boost::asio::io_context io_context; + tcp::resolver resolver(io_context); + + auto client = std::make_shared< async_rrcp_client >(io_context); + client->register_trap_handler(&print); + client->start(resolver.resolve(argv[1], argv[2])); + + std::thread io_thread([&io_context]() -> void { io_context.run(); }); + + std::this_thread::sleep_for(TIMEOUT_DURATION); // NOTE: only for gcov results! CK + for (std::string line; client->connected() && std::getline(*input_str, line); fmt::print(stderr, "Enter command: ")) + { + const std::string::size_type sz = line.find("//"); + if ((sz != std::string::npos)) + { + line.resize(sz); // NOTE: w/o c++ comments + } + + boost::trim(line); + if (line.empty()) + { + continue; + } + + if (boost::algorithm::starts_with(line, "E:")) + { + continue; + } + + const auto response = client->write(line); + fmt::print("{}\n", response); + } + std::this_thread::sleep_for(HEARTBEAT_INTERVAL); // NOTE: only for gcov results! CK + + client->stop(); + io_thread.join(); + } + catch (const std::exception& e) + { + fmt::print(stderr, "Exception: {}\n", e.what()); // NOLINT + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} diff --git a/rrcp_client.cpp b/rrcp_client.cpp new file mode 100644 index 0000000..4e4c01e --- /dev/null +++ b/rrcp_client.cpp @@ -0,0 +1,212 @@ +// +// rrcp_client.cpp +// ~~~~~~~~~~~~~~~ +// +// Copyright (c) 2003-2024 Christopher M. Kohlhoff (chris at kohlhoff dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Moderniced from Claus Klein and ChatGPT + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // NOLINT(misc-include-cleaner) +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rrcp_helper.hpp" +#include "rrcp_message.hpp" + +using boost::asio::ip::tcp; + +using rrcp_message_queue = std::deque< rrcp_message >; + +class rrcp_client +{ + public: + rrcp_client(boost::asio::io_context& io_context, const tcp::resolver::results_type& endpoints) + : io_context_(io_context), socket_(io_context) + { + do_connect(endpoints); + } + + void write(const rrcp_message& msg) + { + boost::asio::post(io_context_, + [this, msg]() -> void + { + bool const write_in_progress{!write_msgs_.empty()}; + write_msgs_.push_back(msg); + if (!write_in_progress) + { + do_write(); + } + }); + } + + void close() + { + boost::asio::post(io_context_, [this]() -> void { write_msgs_.clear(); }); + boost::asio::post(io_context_, [this]() -> void { socket_.close(); }); + } + + private: + void do_connect(const tcp::resolver::results_type& endpoints) + { + boost::asio::async_connect(socket_, endpoints, + [this](boost::system::error_code ec, const tcp::endpoint&) -> void + { + if (!ec) + { + do_read_header(); + } + }); + } + + void do_read_header() + { + boost::asio::async_read(socket_, boost::asio::buffer(read_msg_.data(), rrcp_message::HEADER_LENGTH), + [this](boost::system::error_code ec, std::size_t /*length*/) -> void + { + if (!ec && read_msg_.decode_header()) + { + do_read_body(); + } + else + { + socket_.close(); + } + }); + } + + void do_read_body() + { + boost::asio::async_read(socket_, boost::asio::buffer(read_msg_.body(), read_msg_.body_length()), + [this](boost::system::error_code ec, std::size_t /*length*/) -> void + { + if (!ec && read_msg_.decode_body()) + { + // NOLINTNEXTLINE(bugprone-narrowing-conversions) + std::cout.write(read_msg_.body(), read_msg_.body_length()); + std::cout << "\n"; + do_read_header(); + } + else + { + socket_.close(); + } + }); + } + + void do_write() + { + boost::asio::async_write(socket_, boost::asio::buffer(write_msgs_.front().data(), write_msgs_.front().length()), + [this](boost::system::error_code ec, std::size_t /*length*/) -> void + { + if (!ec) + { + write_msgs_.pop_front(); + if (!write_msgs_.empty()) + { + do_write(); + } + } + else + { + socket_.close(); + } + }); + } + + boost::asio::io_context& io_context_; + tcp::socket socket_; + rrcp_message read_msg_; + rrcp_message_queue write_msgs_; +}; + +// NOLINTNEXTLINE(bugprone-exception-escape) +auto main(int argc, char* argv[]) -> int +{ + using namespace std::chrono_literals; + using namespace std::string_literals; + + try + { + if (argc != 3) + { + std::cerr << "Usage: rrcp_client \n"; + return EXIT_FAILURE; + } + + boost::asio::io_context io_context; + + tcp::resolver resolver(io_context); + auto endpoints = resolver.resolve(argv[1], argv[2]); + rrcp_client client(io_context, endpoints); + + std::thread runner([&io_context]() -> void { io_context.run(); }); + + //================================================================ + std::string binary{"\nAB_(\0\001\002\003\004\005\006\a\b\n\r\t\v\x1b\20\'\"\?)-CD\r"s}; + std::cerr << binary.length() << ' ' << std::quoted(binary) << '\n'; + auto quoted = char2esc(binary); + std::cerr << quoted.length() << ' ' << std::quoted(quoted) << '\n'; + + assert(binary == esc2char(quoted)); + assert(binary.length() < quoted.length()); + assert(binary.length() == 28); + assert(quoted.length() == 33); + //================================================================ + + int count{}; + std::this_thread::sleep_for(100ms); // NOLINT(misc-include-cleaner) + for (std::string line; std::getline(std::cin, line);) + { + const std::string::size_type sz = line.find("//"); + if ((sz != std::string::npos)) + { + line.resize(sz); + } + + boost::trim_right(line); + if (line.empty()) + { + continue; + } + + std::cerr << ++count << '\t' << line << '\n'; + rrcp_message msg; + msg.body_length(line.length()); + std::memcpy(msg.body(), line.c_str(), msg.body_length()); + msg.encode_body(); + msg.encode_header(); + client.write(msg); + } + std::this_thread::sleep_for(100ms); // NOLINT(misc-include-cleaner) + + client.close(); + runner.join(); + } + catch (std::exception& e) + { + std::cerr << "Exception: " << e.what() << "\n"; + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} diff --git a/rrcp_helper.cpp b/rrcp_helper.cpp new file mode 100644 index 0000000..1248eb5 --- /dev/null +++ b/rrcp_helper.cpp @@ -0,0 +1,171 @@ +#include "rrcp_helper.hpp" + +#include + +#include // for starts_with +#include // for trim_left, trim_right +#include +#include +#include +#include + +constexpr char ESC = 0x1B; +constexpr char REPLACE_LF = 0x01; +constexpr char REPLACE_CR = 0x02; +constexpr char REPLACE_ESC = 0x03; + +auto rrcp::esc2char(std::string_view data) -> std::string +{ + std::string message; + auto len = data.size(); + for (size_t i = 0; i < len; ++i) + { + char ch = data[i]; + + if (ch == STOP) + { + return message; + } + + if (ch == ESC) + { + if (i == len - 1) + { + throw std::runtime_error("esc2char: Error - message ends with escape character!"); + } + + char const next = data[++i]; + switch (next) + { + case REPLACE_LF: + ch = '\n'; + break; + case REPLACE_CR: + ch = '\r'; + break; + case REPLACE_ESC: + ch = ESC; + break; + default: + throw std::runtime_error("esc2char: Error - unexpected ESC character!"); + } + } + + message.push_back(ch); + } + return message; +} + +auto rrcp::char2esc(std::string_view data) -> std::string +{ + std::string message; + for (char const ch : data) + { + switch (ch) + { + case '\n': + message.push_back(ESC); + message.push_back(REPLACE_LF); + break; + case '\r': + message.push_back(ESC); + message.push_back(REPLACE_CR); + break; + case ESC: + message.push_back(ESC); + message.push_back(REPLACE_ESC); + break; + default: + message.push_back(ch); + break; + } + } + return message; +} + +auto rrcp::insertAfterFirstWord(const std::string& input, const std::string& toInsert) -> std::string +{ + if (toInsert.empty()) + { + return input; // Nothing to do + } + + size_t firstSpace = input.find_first_of(" \t"); // Find first whitespace + if (firstSpace == std::string::npos) + { + return input; // No spaces found, return original string + } + + // NOTE: Only if Set/Get command request, NOT for Trap commands! + size_t nextNonSpace = input.find_first_of("SG", firstSpace); + if (nextNonSpace == std::string::npos) + { + // If there's no second valid command, just return the input! + // XXX return input + " " + toInsert; + return input; + } + + // If there's valid command, just append toInsert after the first word + return input.substr(0, firstSpace + 1) + toInsert + " " + input.substr(nextNonSpace); +} + +auto rrcp::find_response_msg(std::string& response, const std::string& msg_id) -> bool +{ + // DEBUG: fmt::print("RRCP MU received({})\n", response); + // NOTE: different order for error responses like this: "E:2 10001" + auto pos = response.find(msg_id); + if (pos != std::string::npos) + { + // Remove the inserted message number for Set/Get responses. + if (boost::algorithm::starts_with(response, msg_id)) + { + // NOTE: This is an Command response with msg_id! + response = response.substr(pos + msg_id.length()); + boost::trim_left(response); + } + else + { + // NOTE: This is an Error response with msg_id! + response = response.substr(0, pos); + boost::trim_right(response); + } + return true; + } + + // NOTE: This is an Error response with or w/o a valid msg_id! + if (boost::algorithm::starts_with(response, "E:")) + { + return true; // return, this may be a response to an Trap command? + } + + return false; +} + +auto rrcp::create_command_msg(const std::string& message, std::string& msg_id_str, int msg_id) -> std::string +{ + if (msg_id >= INVALID_ID) + { + msg_id = 1; + } + // Insert the next message number for Set/Get request. + // But prevent to insert the msg_id for Trap commands! + auto trap_cmd = message.find(" T"); + if (trap_cmd == std::string::npos) + { + msg_id_str = fmt::format("{}", (msg_id)); + } + else + { + msg_id_str.clear(); + } + std::string msg = insertAfterFirstWord(message, msg_id_str); + + // DEBUG: fmt::print("rrcp MU to send({})\n", msg); + + // Create the RRCP message frame + std::string command = char2esc(msg); + command.insert(0, 1, START); + command += STOP; + + return command; +} diff --git a/rrcp_helper.hpp b/rrcp_helper.hpp new file mode 100644 index 0000000..626f51f --- /dev/null +++ b/rrcp_helper.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +namespace rrcp +{ + +constexpr const char START{0x0A}; // \n +constexpr const char STOP{0x0D}; // \r +constexpr const int INVALID_ID{16777216}; // valid range is 0 to 2**24 - 1 +constexpr const size_t MAX_MU_LENGTH{65432}; + +/** + * @brief Gets the message between message and + * replaces the escape sequences for START and STOP + * + * + * @param data: read from socket + * + * @return message string like 'M:IBIT SStart' + */ +extern auto esc2char(std::string_view data) -> std::string; + +/** + * @brief Replaces START, STOP with Escape sequence + * + * @param data: data to send + * + * @return translated data + */ +extern auto char2esc(std::string_view data) -> std::string; + +// A C++17 function that inserts a given string after the first word in an input string, +// where words are separated by WS +extern auto insertAfterFirstWord(const std::string& input, const std::string& toInsert) -> std::string; + +// helper which returns true if the msg with matching msg_id was found +extern auto find_response_msg(std::string& response, const std::string& msg_id) -> bool; + +// helper which returns the command msg with next valid msg_id inserted if needed +extern auto create_command_msg(const std::string& message, std::string& msg_id_str, int msg_id) -> std::string; + +} // namespace rrcp diff --git a/rrcp_message.hpp b/rrcp_message.hpp new file mode 100644 index 0000000..239050b --- /dev/null +++ b/rrcp_message.hpp @@ -0,0 +1,239 @@ +// +// rrcp_message.hpp +// ~~~~~~~~~~~~~~~~ +// +// Copyright (c) 2003-2024 Christopher M. Kohlhoff +// Moderniced from Claus Klein and ChatGPT +// + +#ifndef RRCP_MESSAGE_HPP +#define RRCP_MESSAGE_HPP + +#include + +#include +#include +#include +#include +#include +#include + +#include "rrcp_helper.hpp" + +using namespace rrcp; + +/** + * @class rrcp_message + * @brief Encapsulates an RRCP message with methods for encoding, decoding, and accessing message content. + * + * Each message consists of a 4-byte header (hex-encoded length) and an escaped string payload. + */ +class rrcp_message +{ + public: + /// Number of bytes used for the fixed-size header. + static constexpr std::size_t HEADER_LENGTH = 4; + + /// Maximum message body length in bytes. + static constexpr std::size_t MAX_MSG_LENGTH = MAX_MU_LENGTH; + + /** + * @brief Default constructor. + */ + rrcp_message() = default; + + /** + * @brief Construct a message from a string view. + * @param msg The message content. + * + * The message is encoded and the header is generated. Validity is tracked. + */ + explicit rrcp_message(std::string_view msg) + { + valid_ = set_msg(msg); + if (!valid_) + { + clear(); + } + } + + /** + * @brief Checks if the message is in a valid state. + * @return True if valid, false otherwise. + */ + [[nodiscard]] auto is_valid() const -> bool { return valid_; } + + /** + * @brief Returns a const pointer to the start of the raw message buffer. + * @return Pointer to buffer (includes header and body). + */ + [[nodiscard]] auto data() const -> const char* { return data_.data(); } + + /** + * @brief Returns a mutable pointer to the start of the raw message buffer. + * @return Pointer to buffer (includes header and body). + */ + [[nodiscard]] auto data() -> char* { return data_.data(); } + + /** + * @brief Returns the total length of the message (header + body). + * @return Total length in bytes. + */ + [[nodiscard]] auto length() const -> std::size_t { return HEADER_LENGTH + msg_length_; } + + /** + * @brief Returns a view over the full message buffer. + * @return Message as a std::string_view. + */ + [[nodiscard]] auto get_data() const -> std::string_view { return {data(), length()}; } + + /** + * @brief Returns a const pointer to the message body. + * @return Pointer to the body (excludes header). + */ + [[nodiscard]] auto body() const -> const char* { return data_.data() + HEADER_LENGTH; } + + /** + * @brief Returns a mutable pointer to the message body. + * @return Pointer to the body (excludes header). + */ + [[nodiscard]] auto body() -> char* { return data_.data() + HEADER_LENGTH; } + + /** + * @brief Returns the length of the message body in bytes. + * @return Length of the body. + */ + [[nodiscard]] auto body_length() const -> std::size_t { return msg_length_; } + + /** + * @brief Returns a view of the message body. + * @return Message body as a std::string_view. + */ + [[nodiscard]] auto get_body() const -> std::string_view { return {body(), body_length()}; } + + /** + * @brief Returns the decoded message string (after escaping is removed). + * @return Decoded message. + */ + [[nodiscard]] auto get_msg() const -> std::string { return esc2char(std::string(body(), body_length())); } + + /** + * @brief Sets the message body using the input string, escaping it as needed. + * @param msg The input message. + * @return True if header encoding is successful, false otherwise. + */ + [[nodiscard]] auto set_msg(std::string_view msg) -> bool + { + std::string data = char2esc(std::string{msg}); + body_length(data.length()); + + if (data.length() > MAX_MSG_LENGTH) + { +#ifdef DEBUG + fmt::print(stderr, "{}: {} to long!\n", __func__, msg_length_); +#endif + clear(); + return false; + } + + std::memcpy(body(), data.c_str(), msg_length_); + encode_header(); + return valid_; + } + + /** + * @brief Sets the body length, clamping it to the maximum allowed length. + * @param new_length Desired length of body. + */ + void body_length(std::size_t new_length) + { + msg_length_ = std::min(new_length, MAX_MSG_LENGTH); +#ifdef DEBUG + fmt::print(stderr, "body_length({})\n", msg_length_); +#endif + } + + /** + * @brief Decodes the escaped content in the body. + * @return True if decoding succeeds, false if result is empty. + */ + auto decode_body() -> bool + { + // TODO(CK): only if (msg_length != 0 && not yet done)! + const std::string result = esc2char(std::string(body(), msg_length_)); + if (result.length() != msg_length_) + { +#ifdef DEBUG + fmt::print(stderr, "{}: {}\n", __func__, result); +#endif + body_length(result.length()); + std::memcpy(body(), result.c_str(), msg_length_); + } + + return !result.empty(); + } + + /** + * @brief Parses the 4-byte header to determine body length. + * @return True if the header is valid, false if the length is out of bounds. + */ + auto decode_header() -> bool + { + // TODO(CK): only if msg_length != 0 + const std::string header(data_.data(), HEADER_LENGTH); + msg_length_ = std::stoul(header, nullptr, 16); + + if (msg_length_ > MAX_MSG_LENGTH) + { +#ifdef DEBUG + fmt::print(stderr, "{}: Invalid msg_length {}!\n", __func__, header); +#endif + msg_length_ = 0; + return false; + } + + return true; + } + + /** + * @brief Encodes the body to escaped format and adjusts body length. + */ + void encode_body() + { + // TODO(CK): only if (msg_length != 0 && not yet done)! + std::string msg = char2esc(std::string(body(), msg_length_)); + if (msg.length() != msg_length_) + { + body_length(msg.length()); + std::memcpy(body(), msg.c_str(), msg_length_); + } + } + + /** + * @brief Encodes the message header from the current body length. + */ + void encode_header() + { + // TODO(CK): only if msg_length != 0 + std::string header = fmt::format("{:04x}", static_cast< uint16_t >(msg_length_)); + std::memcpy(data_.data(), header.data(), HEADER_LENGTH); + valid_ = true; + } + + /** + * @brief Clears the message buffer and resets internal state. + */ + void clear() + { + msg_length_ = 0; + valid_ = false; + data_.fill('\0'); + } + + private: + std::array< char, HEADER_LENGTH + MAX_MSG_LENGTH > data_{}; ///< Internal buffer for message (header + body). + std::size_t msg_length_{0}; ///< Length of message body. + bool valid_{false}; ///< Flag indicating whether the message is valid. +}; + +#endif // RRCP_MESSAGE_HPP diff --git a/run_test.py b/run_test.py new file mode 100755 index 0000000..577289d --- /dev/null +++ b/run_test.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +Simple test harness that runs a server and a client. + +Behavior: + +Start the server first if given. If the server cannot be started (or exits immediately), +terminate and exit non-zero. + +Then start the client. +Let the client run for --timeout seconds. +If the client times out: try to shut it down gracefully (SIGINT), +wait a short grace period, then SIGKILL if necessary. + +After the client has stopped, ask the server to exit (SIGINT) and wait a little. +If the server does not exit, kill it hard. +On any failure to start a subprocess, make sure the other process is terminated. +return the exit result of client only (that is our SW to test)! + +Created from Claus Klein and ChatGPT as reviewer +""" + +import argparse +import signal +import subprocess +import sys +import time + +from typing import List + +# from pathlib import Path +# +# HERE = Path(__file__).resolve().parent +# PROJECT_DIR = HERE.parent.parent.parent + + +def send_and_wait(proc: subprocess.Popen, sig: int, wait: float) -> bool: + """Send signal to proc and wait up to timeout seconds. Return True if exited.""" + if proc is None: + return True + if proc.poll() is not None: + return True + try: + proc.send_signal(sig) + except Exception: + # fallback to terminate if send_signal fails + try: + proc.terminate() + except Exception: + pass + try: + proc.wait(timeout=wait) + return True + except subprocess.TimeoutExpired: + return False + return True + + +def force_kill(proc: subprocess.Popen) -> None: + """Kill proc and wait (best-effort).""" + if proc is None: + return + try: + proc.kill() + except Exception: + pass + try: + proc.wait(timeout=1.0) + except Exception: + # give up + pass + + +def start_process( + cmd: list[str], role: str, check_delay: float = 0.1 +) -> subprocess.Popen: + """ + Start a subprocess (client or server) and ensure it doesn't fail immediately. + + Args: + cmd: Command line list to run (e.g. ['python3', 'server.py', '8000']). + role: A label used for logging ('server', 'client', etc.). + check_delay: Seconds to wait before checking if the process has exited. + + Returns: + subprocess.Popen instance of the started process. + + Raises: + RuntimeError if the process cannot start or exits immediately. + """ + print(f"Starting {role}:", " ".join(cmd)) + try: + proc = subprocess.Popen(cmd) + except Exception as e: + raise RuntimeError(f"Failed to start {role}: {e}") from e + + # Allow short delay to detect immediate failure (resource missing or blocked: e.g. can't open port) + time.sleep(check_delay) + if proc.poll() is not None: + raise RuntimeError( + f"{role.capitalize()} exited immediately with code {proc.returncode}" + ) + + return proc + + +def main(args: List[str]): + parser = argparse.ArgumentParser() + parser.add_argument( + "--client", + help="The client to test", + ) + parser.add_argument( + "--server", + help="The server to run", + ) + parser.add_argument( + "--input", + help="The input file to read", + ) + parser.add_argument( + "--timeout", + "-t", + type=int, + default=29, + help="Number of seconds to run; defaults to %(default)s", + ) + args = parser.parse_args(args) + + if not args.client: + print("Missing path to client!") + return 1 + + port = "8000" + server = None + client = None + + try: + # Start server + if args.server: + try: + server = start_process([args.server, port], role="server") + except Exception as e: + print(e, file=sys.stderr) + return 2 + + # Start client + try: + client_cmd = [args.client, "localhost", port] + if args.input: + client_cmd.append(args.input) + client = start_process(client_cmd, role="client") + except Exception as e: + print(e, file=sys.stderr) + if server and server.poll() is None: + force_kill(server) + return 3 + + # Wait for client to finish or timeout + try: + print(f"Waiting up to {args.timeout} seconds for client to finish...") + client.wait(timeout=args.timeout) + print(f"Client exited (code {client.returncode}).") + except subprocess.TimeoutExpired: + print("Timeout expired. Attempting graceful client shutdown (SIGINT).") + # Try graceful shutdown via SIGINT + graceful = send_and_wait(client, signal.SIGINT, wait=3.0) + if not graceful: + print("Client did not exit after SIGINT; killing it.") + force_kill(client) + else: + print("Client exited gracefully after SIGINT.") + + # Now ask server to exit gracefully + if server and server.poll() is None: + print("Requesting server to exit (SIGINT).") + client_graceful = send_and_wait(server, signal.SIGINT, wait=1.0) + if not client_graceful: + print("Server did not exit after SIGINT; killing server.") + force_kill(server) + else: + print("Server exited gracefully.") + else: + if server: + print(f"Server already exited (code {server.returncode}).") + + # NOTE: We ignore server exit code; We return only client's exit code (or 0) + # NO! if server.returncode not in (None, 0): return server.returncode + if client: + return client.returncode if client.returncode is not None else 0 + return 0 + + finally: + # Cleanup any lingering processes + if client and client.poll() is None: + print("Final cleanup: killing client.") + force_kill(client) + if server and server.poll() is None: + print("Final cleanup: killing server.") + force_kill(server) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/Base64-test.cpp b/tests/Base64-test.cpp new file mode 100644 index 0000000..695aced --- /dev/null +++ b/tests/Base64-test.cpp @@ -0,0 +1,293 @@ +/*** +Additionally, you may want to consider adding more test cases to cover edge cases, such as: + +Null or empty input data +Input data with invalid characters (e.g., non-ASCII characters) +Input data with padding errors (e.g., incorrect number of padding characters) +Input data with encoding errors (e.g., incorrect encoding scheme) + +By covering these edge cases, you can ensure that your base64 class is robust and reliable. +***/ + +#include "Base64.hpp" + +extern "C" +{ + // #include "base64.h" +} + +#include +#include + +#include +// XXX #include // for std::println +#include +#include + +using namespace std::string_literals; + +using rrcp::common::base64; + +#define TEST_RANDOM_VALUES + +namespace +{ + +// Test Vectors from rfc4648 +// see https://datatracker.ietf.org/doc/html/rfc4648#section-10 +struct testpattern_t +{ + const char* bin_; + const char* encoded_; +} testpattern[] = { // + {"", ""}, // + {"f", "Zg=="}, // + {"fo", "Zm8="}, // + {"foo", "Zm9v"}, // + {"foob", "Zm9vYg=="}, // + {"fooba", "Zm9vYmE="}, // + {"foobar", "Zm9vYmFy"}, // + {" ", "IA=="}, // 1 space + {" ", "ICA="}, // 2 spaces + {" ", "ICAg"}, // 3 spaces + {" ", "ICAgIA=="}, // 4 spaces + {" ", "ICAgICA="}, // 5 spaces + {" ", "ICAgICAg"}, // 6 spaces + {" ", "ICAgICAgIA=="}, // 7 spaces + {"U", "VQ=="}, // + {"UU", "VVU="}, // + {"UUU", "VVVV"}, // + {"UUUU", "VVVVVQ=="}, // + {"UUUUU", "VVVVVVU="}, // + {"UUUUUU", "VVVVVVVV"}, // + {"UUUUUUU", "VVVVVVVVVQ=="}, // + {nullptr, nullptr}}; + +} // namespace + +TEST(Base64Test, encoding) +{ + base64 base64; + base64.set_line_break(false); + + std::array< char, 54 > text{}; + size_t i = 0; + while (testpattern[i].bin_ != nullptr) + { + const std::string binary(testpattern[i].bin_); + const std::string encoded{testpattern[i].encoded_}; + fmt::println("'{}':\t{}", binary, encoded); + + const std::string base64_encoded = rrcp::common::base64::encode(binary); + EXPECT_EQ(encoded, base64_encoded); + + ++i; + } +} + +TEST(Base64Test, decoding) +{ + base64 base64; + + std::array< char, 54 > data{}; + size_t i = 0; + while (testpattern[i].bin_ != nullptr) + { + const std::string encoded{testpattern[i].encoded_}; + const std::string binary{testpattern[i].bin_}; + fmt::println("'{}':\t{}", binary, encoded); + + const std::string decoded = rrcp::common::base64::decode(encoded); + EXPECT_EQ(binary, decoded); + + ++i; + } +} + +TEST(Base64Test, ShortString1) +{ + base64 base64; + std::string const original = "A"; + std::string const encoded = rrcp::common::base64::encode(original); + // fmt::println("{}:\t{}", original, encoded); + EXPECT_EQ(encoded, "QQ=="); + + std::string const decoded = rrcp::common::base64::decode(encoded); + EXPECT_EQ(original, decoded); +} + +TEST(Base64Test, ShortString2) +{ + base64 base64; + std::string const original = "AA"; + std::string const encoded = rrcp::common::base64::encode(original); + // fmt::println("{}:\t{}", original, encoded); + EXPECT_EQ(encoded, "QUE="); + + std::string const decoded = rrcp::common::base64::decode(encoded); + EXPECT_EQ(original, decoded); +} + +TEST(Base64Test, ShortString3) +{ + base64 base64; + std::string const original = "AAA"; + std::string const encoded = rrcp::common::base64::encode(original); + // fmt::println("{}:\t{}", original, encoded); + EXPECT_EQ(encoded, "QUFB"); + + std::string const decoded = rrcp::common::base64::decode(encoded); + EXPECT_EQ(original, decoded); +} + +#ifdef TEST_INVALID_VALUES +TEST(Base64Test, DecodeMarker) +{ + base64 base64; + EXPECT_ANY_THROW({ (void)base64.decode("====").empty(); }); + EXPECT_ANY_THROW({ (void)base64.decode("===").empty(); }); + EXPECT_ANY_THROW({ (void)base64.decode("==").empty(); }); + EXPECT_ANY_THROW({ (void)base64.decode("=").empty(); }); + EXPECT_ANY_THROW({ (void)base64.decode("\t").empty(); }); + + EXPECT_NO_THROW({ (void)base64.decode("").empty(); }); +} +#endif + +TEST(Base64Test, MediumString) +{ + base64 base64; + std::string const original = "This is a medium length string."; + std::string const encoded = rrcp::common::base64::encode(original); + std::string const decoded = rrcp::common::base64::decode(encoded); + EXPECT_EQ(original, decoded); +} + +TEST(Base64Test, LongString) +{ + base64 base64; + // XXX base64.setLineBreak(true); + + std::string const original = "This is not a really long string, but also that should be encoded and decoded correctly."; + std::string const encoded = rrcp::common::base64::encode(original); + std::string expected{ + "VGhpcyBpcyBub3QgYSByZWFsbHkgbG9uZyBzdHJpbmcsIGJ1dCBhbHNvIHRoYXQgc2hvdWxkIGJl" + "IGVuY29kZWQgYW5kIGRlY29kZWQgY29ycmVjdGx5Lg=="}; + EXPECT_EQ(expected, encoded); + // fmt::println("{}:\n{}", original, encoded); + + std::string const decoded = rrcp::common::base64::decode(encoded); + EXPECT_EQ(original, decoded); +} + +TEST(Base64Test, FoxString) +{ + base64 base64; + std::string const original = "The quick brown fox jumped over the lazy dogs."; + std::string const encoded = rrcp::common::base64::encode(original); + EXPECT_EQ(encoded, "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUgbGF6eSBkb2dzLg=="); + // fmt::println("{}:\t{}", original, encoded); + + std::string const decoded = rrcp::common::base64::decode(encoded); + EXPECT_EQ(original, decoded); +} + +TEST(Base64Test, BinaryData) +{ + base64 base64; + std::string const original = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F"s; + std::string const encoded = rrcp::common::base64::encode(original); + std::string const decoded = rrcp::common::base64::decode(encoded); + EXPECT_EQ(original, decoded); +} + +TEST(Base64Test, NonAsciiString) +{ + base64 base64; + std::string const original = "\xFC@NOs[\xFEVJ\t@\x80\v\xD0\xAA\xF5"; + std::string const encoded = rrcp::common::base64::encode(original); + // fmt::println("'{}':\t{}", original, encoded); + + std::string const decoded = rrcp::common::base64::decode(encoded); + EXPECT_EQ(original, decoded); +} + +TEST(Base64Test, TestEncoder) +{ + base64 base64; + { + std::string original("\00\01\02\03\04\05", 6); + auto encoded = rrcp::common::base64::encode(original); + EXPECT_EQ(encoded, "AAECAwQF"); + EXPECT_EQ(original, base64.decode(encoded)); + } + { + std::string original("\00\01\02\03", 4); + auto encoded = rrcp::common::base64::encode(original); + EXPECT_EQ(encoded, "AAECAw=="); + EXPECT_EQ(original, base64.decode(encoded)); + } + { + std::string original("ABCDEF"); + auto encoded = rrcp::common::base64::encode(original); + EXPECT_EQ(encoded, "QUJDREVG"); + EXPECT_EQ(original, base64.decode(encoded)); + } + { + std::string original("!@#$%^&*()_~<>"); + std::string expected{"IUAjJCVeJiooKV9+PD4="}; + auto encoded = rrcp::common::base64::encode(original); + fmt::println("'{}':\t{}", original, encoded); + + EXPECT_EQ(encoded, expected); + EXPECT_EQ(original, base64.decode(encoded)); + } +} + +#ifdef TEST_INVALID_VALUES +TEST(Base64Test, TestDecoder) +{ + base64 base64; + { + const std::string istr("QUJ\r\nDRE\r\nVG"); + const std::string decoded = base64.decode(istr); + EXPECT_EQ(decoded, "ABCDEF"); + } + { + const std::string istr("QUJD#REVG"); + EXPECT_ANY_THROW({ (void)base64.decode(istr).empty(); }); + } +} +#endif + +#ifdef TEST_RANDOM_VALUES +TEST(Base64Test, RandomBinaryData) +{ + std::random_device rd; // a seed source for the random number engine + std::mt19937 gen(rd()); // mersenne_twister_engine seeded with rd() + std::uniform_int_distribution<> distrib(0, 255); + + base64 base64; + // XXX base64.setLineBreak(true); + + for (size_t i = 0; i < 5; ++i) + { + const std::string::size_type new_cap{64U + i}; + std::string original; + original.reserve(new_cap); + for (size_t j = 0; j < new_cap; ++j) + { + original += static_cast< char >(distrib(gen) % 256); + } + std::string const encoded = rrcp::common::base64::encode(original); + std::string const decoded = rrcp::common::base64::decode(encoded); + EXPECT_EQ(original, decoded); + } +} +#endif + +auto main(int argc, char** argv) -> int +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..321a6a2 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,51 @@ +include(FetchContent) + +# we use boost::ut +FetchContent_Declare( + ut + GIT_TAG v2.3.1 + GIT_REPOSITORY https://github.com/boost-ext/ut.git + EXCLUDE_FROM_ALL + SYSTEM + FIND_PACKAGE_ARGS 2.3.1 NAMES ut +) + +# TODO(CK): We still use googletest too! But will be changed to Boost::ut +FetchContent_Declare( + googletest + GIT_TAG v1.16.0 + GIT_REPOSITORY https://github.com/google/googletest.git + EXCLUDE_FROM_ALL + SYSTEM + FIND_PACKAGE_ARGS 1.16.0 NAMES GTest COMPONENTS gmain_main +) + +# For Windows: Prevent overriding the parent project's compiler/linker settings +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(fmt googletest ut) + +# add_library(base64c STATIC) +# NO! target_sources(base64c PRIVATE base64.c +# PUBLIC FILE_SET HEADERS BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR} FILES base64.h) + +target_sources( + rrcp_helper + PRIVATE ${CMAKE_SOURCE_DIR}/Base64.cpp + PUBLIC + FILE_SET HEADERS + BASE_DIRS ${CMAKE_SOURCE_DIR} + FILES ${CMAKE_SOURCE_DIR}/Base64.hpp +) +# target_link_libraries( +# rrcp_helper +# PUBLIC +# Boost::headers # Not needed: PUBLIC Boost::beast +# ) + +add_executable(RRCP-test RRCP-test.cpp) +target_link_libraries(RRCP-test PRIVATE rrcp_helper Boost::ut) +add_test(NAME RRCP-test COMMAND RRCP-test) + +add_executable(Base64-test Base64-test.cpp) +target_link_libraries(Base64-test PRIVATE rrcp_helper GTest::gtest_main) +add_test(NAME Base64-test COMMAND Base64-test) diff --git a/tests/RRCP-test.cpp b/tests/RRCP-test.cpp new file mode 100644 index 0000000..4adc431 --- /dev/null +++ b/tests/RRCP-test.cpp @@ -0,0 +1,254 @@ +#include // import boost.ut; +#include // use std::quoted +#include +#include +#include + +#define DEBUG + +#include "rrcp_helper.hpp" +#include "rrcp_message.hpp" + +namespace ut = boost::ut; + +ut::suite errors = [] -> void +{ + using namespace ut; + using namespace std::literals; + + "find_response_msg"_test = [] -> void + { + constexpr std::string_view EXPECTED{"gGoState"sv}; + const std::string message{"123456 gGoState"}; + std::string result{message}; + auto found = rrcp::find_response_msg(result, "123456"); + expect(found); + expect(EXPECTED == result); +#ifdef BOOST_UT_HAS_FORMAT + ut::log("{} == {}\n", result, EXPECTED); +#endif + }; + + "doNotfind_error_response_msg"_test = [] -> void + { + constexpr std::string_view EXPECTED{"E:1"sv}; + const std::string message{"E:1"}; + std::string result{message}; + auto found = rrcp::find_response_msg(result, "0815"); + expect(found); + expect(EXPECTED == result); +#ifdef BOOST_UT_HAS_FORMAT + ut::log("{} == {}\n", result, EXPECTED); +#endif + }; + + "find_error_response_msg"_test = [] -> void + { + constexpr std::string_view EXPECTED{"E:10"sv}; + const std::string message{"E:10 123456"}; + std::string result{message}; + auto found = rrcp::find_response_msg(result, "123456"); + expect(found); + expect(EXPECTED == result); +#ifdef BOOST_UT_HAS_FORMAT + ut::log("{} == {}\n", result, EXPECTED); +#endif + }; + + "doNotfind_response_msg"_test = [] -> void + { + constexpr std::string_view EXPECTED{"d NoGo"sv}; + const std::string message{EXPECTED}; + std::string result{message}; + auto found = rrcp::find_response_msg(result, "123456"); + expect(!found); + expect(EXPECTED == result); +#ifdef BOOST_UT_HAS_FORMAT + ut::log("{} == {}\n", result, EXPECTED); +#endif + }; + + // ============================================================ + + "insertAfterFirstWord"_test = [] -> void + { + constexpr std::string_view EXPECTED{"M:test 123456 GGoState"sv}; + const std::string command{"M:test GGoState"}; + auto result = rrcp::insertAfterFirstWord(command, "123456"); + expect(EXPECTED == result); +#ifdef BOOST_UT_HAS_FORMAT + ut::log("{} == {}\n", result, EXPECTED); +#endif + }; + + "doNotInsertAnEmptyString"_test = [] -> void + { + constexpr std::string_view EXPECTED{"M:test GGoState"sv}; + const std::string command{EXPECTED}; + auto result = rrcp::insertAfterFirstWord(command, ""); + expect(EXPECTED == result); +#ifdef BOOST_UT_HAS_FORMAT + ut::log("{} == {}\n", result, EXPECTED); +#endif + }; + + "doNotInsertBeforeTrapCmd"_test = [] -> void + { + constexpr std::string_view EXPECTED{"M:test TGoState1"sv}; + const std::string command{EXPECTED}; + auto result = rrcp::insertAfterFirstWord(command, ""); + expect(EXPECTED == result); +#ifdef BOOST_UT_HAS_FORMAT + ut::log("{} == {}\n", result, EXPECTED); +#endif + }; + + "doNotInsertAfterSingleWord"_test = [] -> void + { + constexpr std::string_view EXPECTED{"E:10"sv}; + const std::string message{EXPECTED}; + auto result = rrcp::insertAfterFirstWord(message, "123456"); + expect(EXPECTED == result); +#ifdef BOOST_UT_HAS_FORMAT + ut::log("{} == {}\n", result, EXPECTED); +#endif + }; + + // ============================================================ + + "create_command_msg"_test = [] -> void + { + constexpr std::string_view EXPECTED{"\nM:RxTx 1 SPowerLevel\"Off\"\r"sv}; + const std::string command{R"(M:RxTx SPowerLevel"Off")"}; + std::string msg_id_str; + int counter{rrcp::INVALID_ID}; + auto result = rrcp::create_command_msg(command, msg_id_str, counter); + expect("1" == msg_id_str); + expect(EXPECTED == result); + + std::ostringstream quoted; + quoted << std::quoted(result.substr(1, result.length() - 1)); // NOTE: w/o START STOP +#ifdef BOOST_UT_HAS_FORMAT + ut::log("{} == {}\n", "RRCP MU", quoted.str()); +#endif + }; + + // ============================================================ + + "wrong_quoted"_test = [] -> void + { + expect(throws( + [] -> void + { + constexpr std::string_view WRONG_QUOTED{"\n\x1b\004\r"sv}; + auto result = rrcp::esc2char(WRONG_QUOTED); + })); + }; + + "empty_str"_test = [] -> void + { + expect(nothrow( + [] -> void + { + auto result = rrcp::esc2char(""); + expect(result.empty()); + })); + }; + + "single_esc_msg"_test = [] -> void { expect(throws([] -> void { auto result = rrcp::esc2char("\x1b\rSINGLE_ESC"); })); }; + + "esc_as_last_msg"_test = [] -> void { expect(throws([] -> void { auto result = rrcp::esc2char("ESC_AS_LAST\x1b"); })); }; + + "to_short_msg"_test = [] -> void { expect(throws([] -> void { auto result = rrcp::esc2char("\x1b\0"s); })); }; + + "basic_quoteing"_test = [] -> void + { + constexpr std::string_view BINARY{"\nAB_(\0\001\002\003\004\005\006\a\b\n\r\t\v\x1b\20\'\"\?)-CD\r"sv}; + auto quoted = rrcp::char2esc(BINARY); + + // NOTE: std::quoted works only with std::stringstream +#if defined(BOOST_UT_HAS_FORMAT) && defined(FIXME) // FIXME! + std::ostringstream binary_bin; + binary_bin << std::quoted(binary); + ut::log("{} {}\n", binary.length(), binary_bin.str()); + + std::ostringstream quoted_bin; + quoted_bin << std::quoted(quoted); + ut::log("{} {}\n", quoted.length(), quoted_bin.str()); +#endif + + expect(BINARY == rrcp::esc2char(quoted)); + expect(BINARY.length() < quoted.length()); + expect(BINARY.length() == 28); + expect(quoted.length() == 33); + }; + + // ============================================================ + + "rrcp_message"_test = [] -> void + { + rrcp_message msg; + msg.body_length(MAX_MU_LENGTH); + expect(msg.length() == MAX_MU_LENGTH + 4); + msg.encode_body(); + expect(msg.body_length() == MAX_MU_LENGTH); + // XXX expect(msg.is_valid()); + expect(msg.decode_body()); + + msg.encode_header(); + expect(msg.length() == MAX_MU_LENGTH + 4); + expect(msg.decode_header()); + + msg.clear(); + expect(!msg.is_valid()); + expect(msg.get_body().empty()); + expect(msg.get_data().length() == 4); + }; + + "rrcp_message_empty"_test = [] -> void + { + rrcp_message msg; + msg.body_length(0); + expect(msg.length() == 4); + msg.encode_body(); + expect(msg.body_length() == 0); + expect(!msg.is_valid()); + expect(!msg.decode_body()); + + // FIXME: expect(nothrow([&] {msg.decode_header();} )); + }; + + "rrcp_message_to_long"_test = [] -> void + { + const std::string invalid(MAX_MU_LENGTH, '\n'); + rrcp_message msg(invalid); + expect(!msg.is_valid()); + expect(!msg.set_msg(invalid)); + }; + + "rrcp_message_text"_test = [] -> void + { + constexpr std::string_view COMMAND{"Hallo Server"}; + rrcp_message msg; + expect(msg.set_msg(COMMAND)); + expect(msg.is_valid()); + expect(msg.body_length() == COMMAND.length()); + expect(msg.body() == COMMAND); + auto result = msg.get_msg(); + expect(COMMAND == result); + }; + + "rrcp_message_binary"_test = [] -> void + { + constexpr std::string_view BINARY{"\nAB_(\0\001\002\003\004\005\006\a\b\n\r\t\v\x1b\20\'\"\?)-CD\r"sv}; + rrcp_message msg(BINARY); + expect(msg.is_valid()); + expect(msg.body_length() == 33); + auto result = msg.get_msg(); + expect(BINARY == result); + }; + + // ============================================================ +}; + +auto main() -> int {} diff --git a/tests/base64.c b/tests/base64.c new file mode 100644 index 0000000..ce6f17e --- /dev/null +++ b/tests/base64.c @@ -0,0 +1,334 @@ +/* base64.c -- Encode binary data using printable characters. + Copyright (C) 1999, 2000, 2001, 2004, 2005, 2006 Free Software + Foundation, Inc. + + 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, 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, write to the Free Software Foundation, + Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + +/* Written by Simon Josefsson. Partially adapted from GNU MailUtils + * (mailbox/filter_trans.c, as of 2004-11-28). Improved by review + * from Paul Eggert, Bruno Haible, and Stepan Kasal. + * + * See also RFC 3548 . + * + * Be careful with error checking. Here is how you would typically + * use these functions: + * + * bool ok = base64_decode_alloc (in, inlen, &out, &outlen); + * if (!ok) + * FAIL: input was not valid base64 + * if (out == NULL) + * FAIL: memory allocation error + * OK: data in OUT/OUTLEN + * + * size_t outlen = base64_encode_alloc (in, inlen, &out); + * if (out == NULL && outlen == 0 && inlen != 0) + * FAIL: input too long + * if (out == NULL) + * FAIL: memory allocation error + * OK: data in OUT/OUTLEN. + * + */ + +// XXX #include + +/* Get prototype. */ +#include "base64.h" + +/* Get malloc. */ +#include + +/* Get UCHAR_MAX. */ +#include + +/* C89 compliant way to cast 'char' to 'unsigned char'. */ +static inline unsigned char to_uchar(char ch) { return ch; } + +/* Base64 encode IN array of size INLEN into OUT array of size OUTLEN. + If OUTLEN is less than BASE64_LENGTH(INLEN), write as many bytes as + possible. If OUTLEN is larger than BASE64_LENGTH(INLEN), also zero + terminate the output buffer. */ +void base64_encode(const char* restrict in, size_t inlen, char* restrict out, size_t outlen) +{ + static const char b64str[64] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + while (inlen && outlen) + { + *out++ = b64str[(to_uchar(in[0]) >> 2) & 0x3f]; + if (!--outlen) break; + *out++ = b64str[((to_uchar(in[0]) << 4) + (--inlen ? to_uchar(in[1]) >> 4 : 0)) & 0x3f]; + if (!--outlen) break; + *out++ = (inlen ? b64str[((to_uchar(in[1]) << 2) + (--inlen ? to_uchar(in[2]) >> 6 : 0)) & 0x3f] : '='); + if (!--outlen) break; + *out++ = inlen ? b64str[to_uchar(in[2]) & 0x3f] : '='; + if (!--outlen) break; + if (inlen) inlen--; + if (inlen) in += 3; + } + + if (outlen) *out = '\0'; +} + +/* Allocate a buffer and store zero terminated base64 encoded data + from array IN of size INLEN, returning BASE64_LENGTH(INLEN), i.e., + the length of the encoded data, excluding the terminating zero. On + return, the OUT variable will hold a pointer to newly allocated + memory that must be deallocated by the caller. If output string + length would overflow, 0 is returned and OUT is set to NULL. If + memory allocation failed, OUT is set to NULL, and the return value + indicates length of the requested memory block, i.e., + BASE64_LENGTH(inlen) + 1. */ +size_t base64_encode_alloc(const char* in, size_t inlen, char** out) +{ + size_t outlen = 1 + BASE64_LENGTH(inlen); + + /* Check for overflow in outlen computation. + * + * If there is no overflow, outlen >= inlen. + * + * If the operation (inlen + 2) overflows then it yields at most +1, so + * outlen is 0. + * + * If the multiplication overflows, we lose at least half of the + * correct value, so the result is < ((inlen + 2) / 3) * 2, which is + * less than (inlen + 2) * 0.66667, which is less than inlen as soon as + * (inlen > 4). + */ + if (inlen > outlen) + { + *out = NULL; + return 0; + } + + *out = malloc(outlen); + if (!*out) return outlen; + + base64_encode(in, inlen, *out, outlen); + + return outlen - 1; +} + +/* With this approach this file works independent of the charset used + (think EBCDIC). However, it does assume that the characters in the + Base64 alphabet (A-Za-z0-9+/) are encoded in 0..255. POSIX + 1003.1-2001 require that char and unsigned char are 8-bit + quantities, though, taking care of that problem. But this may be a + potential problem on non-POSIX C99 platforms. + + IBM C V6 for AIX mishandles "#define B64(x) ...'x'...", so use "_" + as the formal parameter rather than "x". */ +#define B64(_) \ + ((_) == 'A' ? 0 \ + : (_) == 'B' ? 1 \ + : (_) == 'C' ? 2 \ + : (_) == 'D' ? 3 \ + : (_) == 'E' ? 4 \ + : (_) == 'F' ? 5 \ + : (_) == 'G' ? 6 \ + : (_) == 'H' ? 7 \ + : (_) == 'I' ? 8 \ + : (_) == 'J' ? 9 \ + : (_) == 'K' ? 10 \ + : (_) == 'L' ? 11 \ + : (_) == 'M' ? 12 \ + : (_) == 'N' ? 13 \ + : (_) == 'O' ? 14 \ + : (_) == 'P' ? 15 \ + : (_) == 'Q' ? 16 \ + : (_) == 'R' ? 17 \ + : (_) == 'S' ? 18 \ + : (_) == 'T' ? 19 \ + : (_) == 'U' ? 20 \ + : (_) == 'V' ? 21 \ + : (_) == 'W' ? 22 \ + : (_) == 'X' ? 23 \ + : (_) == 'Y' ? 24 \ + : (_) == 'Z' ? 25 \ + : (_) == 'a' ? 26 \ + : (_) == 'b' ? 27 \ + : (_) == 'c' ? 28 \ + : (_) == 'd' ? 29 \ + : (_) == 'e' ? 30 \ + : (_) == 'f' ? 31 \ + : (_) == 'g' ? 32 \ + : (_) == 'h' ? 33 \ + : (_) == 'i' ? 34 \ + : (_) == 'j' ? 35 \ + : (_) == 'k' ? 36 \ + : (_) == 'l' ? 37 \ + : (_) == 'm' ? 38 \ + : (_) == 'n' ? 39 \ + : (_) == 'o' ? 40 \ + : (_) == 'p' ? 41 \ + : (_) == 'q' ? 42 \ + : (_) == 'r' ? 43 \ + : (_) == 's' ? 44 \ + : (_) == 't' ? 45 \ + : (_) == 'u' ? 46 \ + : (_) == 'v' ? 47 \ + : (_) == 'w' ? 48 \ + : (_) == 'x' ? 49 \ + : (_) == 'y' ? 50 \ + : (_) == 'z' ? 51 \ + : (_) == '0' ? 52 \ + : (_) == '1' ? 53 \ + : (_) == '2' ? 54 \ + : (_) == '3' ? 55 \ + : (_) == '4' ? 56 \ + : (_) == '5' ? 57 \ + : (_) == '6' ? 58 \ + : (_) == '7' ? 59 \ + : (_) == '8' ? 60 \ + : (_) == '9' ? 61 \ + : (_) == '+' ? 62 \ + : (_) == '/' ? 63 \ + : -1) + +static const signed char b64[0x100] = {B64(0), B64(1), B64(2), B64(3), B64(4), B64(5), B64(6), B64(7), B64(8), B64(9), + B64(10), B64(11), B64(12), B64(13), B64(14), B64(15), B64(16), B64(17), B64(18), B64(19), B64(20), B64(21), B64(22), + B64(23), B64(24), B64(25), B64(26), B64(27), B64(28), B64(29), B64(30), B64(31), B64(32), B64(33), B64(34), B64(35), + B64(36), B64(37), B64(38), B64(39), B64(40), B64(41), B64(42), B64(43), B64(44), B64(45), B64(46), B64(47), B64(48), + B64(49), B64(50), B64(51), B64(52), B64(53), B64(54), B64(55), B64(56), B64(57), B64(58), B64(59), B64(60), B64(61), + B64(62), B64(63), B64(64), B64(65), B64(66), B64(67), B64(68), B64(69), B64(70), B64(71), B64(72), B64(73), B64(74), + B64(75), B64(76), B64(77), B64(78), B64(79), B64(80), B64(81), B64(82), B64(83), B64(84), B64(85), B64(86), B64(87), + B64(88), B64(89), B64(90), B64(91), B64(92), B64(93), B64(94), B64(95), B64(96), B64(97), B64(98), B64(99), B64(100), + B64(101), B64(102), B64(103), B64(104), B64(105), B64(106), B64(107), B64(108), B64(109), B64(110), B64(111), B64(112), + B64(113), B64(114), B64(115), B64(116), B64(117), B64(118), B64(119), B64(120), B64(121), B64(122), B64(123), B64(124), + B64(125), B64(126), B64(127), B64(128), B64(129), B64(130), B64(131), B64(132), B64(133), B64(134), B64(135), B64(136), + B64(137), B64(138), B64(139), B64(140), B64(141), B64(142), B64(143), B64(144), B64(145), B64(146), B64(147), B64(148), + B64(149), B64(150), B64(151), B64(152), B64(153), B64(154), B64(155), B64(156), B64(157), B64(158), B64(159), B64(160), + B64(161), B64(162), B64(163), B64(164), B64(165), B64(166), B64(167), B64(168), B64(169), B64(170), B64(171), B64(172), + B64(173), B64(174), B64(175), B64(176), B64(177), B64(178), B64(179), B64(180), B64(181), B64(182), B64(183), B64(184), + B64(185), B64(186), B64(187), B64(188), B64(189), B64(190), B64(191), B64(192), B64(193), B64(194), B64(195), B64(196), + B64(197), B64(198), B64(199), B64(200), B64(201), B64(202), B64(203), B64(204), B64(205), B64(206), B64(207), B64(208), + B64(209), B64(210), B64(211), B64(212), B64(213), B64(214), B64(215), B64(216), B64(217), B64(218), B64(219), B64(220), + B64(221), B64(222), B64(223), B64(224), B64(225), B64(226), B64(227), B64(228), B64(229), B64(230), B64(231), B64(232), + B64(233), B64(234), B64(235), B64(236), B64(237), B64(238), B64(239), B64(240), B64(241), B64(242), B64(243), B64(244), + B64(245), B64(246), B64(247), B64(248), B64(249), B64(250), B64(251), B64(252), B64(253), B64(254), B64(255)}; + +#if UCHAR_MAX == 255 +#define uchar_in_range(c) true +#else +#define uchar_in_range(c) ((c) <= 255) +#endif + +/* Return true if CH is a character from the Base64 alphabet, and + false otherwise. Note that '=' is padding and not considered to be + part of the alphabet. */ +bool isbase64(char ch) { return uchar_in_range(to_uchar(ch)) && 0 <= b64[to_uchar(ch)]; } + +/* Decode base64 encoded input array IN of length INLEN to output + array OUT that can hold *OUTLEN bytes. Return true if decoding was + successful, i.e. if the input was valid base64 data, false + otherwise. If *OUTLEN is too small, as many bytes as possible will + be written to OUT. On return, *OUTLEN holds the length of decoded + bytes in OUT. Note that as soon as any non-alphabet characters are + encountered, decoding is stopped and false is returned. This means + that, when applicable, you must remove any line terminators that is + part of the data stream before calling this function. */ +bool base64_decode(const char* restrict in, size_t inlen, char* restrict out, size_t* outlen) +{ + size_t outleft = *outlen; + + while (inlen >= 2) + { + if (!isbase64(in[0]) || !isbase64(in[1])) break; + + if (outleft) + { + *out++ = ((b64[to_uchar(in[0])] << 2) | (b64[to_uchar(in[1])] >> 4)); + outleft--; + } + + if (inlen == 2) break; + + if (in[2] == '=') + { + if (inlen != 4) break; + + if (in[3] != '=') break; + } + else + { + if (!isbase64(in[2])) break; + + if (outleft) + { + *out++ = (((b64[to_uchar(in[1])] << 4) & 0xf0) | (b64[to_uchar(in[2])] >> 2)); + outleft--; + } + + if (inlen == 3) break; + + if (in[3] == '=') + { + if (inlen != 4) break; + } + else + { + if (!isbase64(in[3])) break; + + if (outleft) + { + *out++ = (((b64[to_uchar(in[2])] << 6) & 0xc0) | b64[to_uchar(in[3])]); + outleft--; + } + } + } + + in += 4; + inlen -= 4; + } + + *outlen -= outleft; + + if (inlen != 0) return false; + + return true; +} + +/* Allocate an output buffer in *OUT, and decode the base64 encoded + data stored in IN of size INLEN to the *OUT buffer. On return, the + size of the decoded data is stored in *OUTLEN. OUTLEN may be NULL, + if the caller is not interested in the decoded length. *OUT may be + NULL to indicate an out of memory error, in which case *OUTLEN + contains the size of the memory block needed. The function returns + true on successful decoding and memory allocation errors. (Use the + *OUT and *OUTLEN parameters to differentiate between successful + decoding and memory error.) The function returns false if the + input was invalid, in which case *OUT is NULL and *OUTLEN is + undefined. */ +bool base64_decode_alloc(const char* in, size_t inlen, char** out, size_t* outlen) +{ + /* This may allocate a few bytes too much, depending on input, + but it's not worth the extra CPU time to compute the exact amount. + The exact amount is 3 * inlen / 4, minus 1 if the input ends + with "=" and minus another 1 if the input ends with "==". + Dividing before multiplying avoids the possibility of overflow. */ + size_t needlen = 3 * (inlen / 4) + 2; + + *out = malloc(needlen); + if (!*out) return true; + + if (!base64_decode(in, inlen, *out, &needlen)) + { + free(*out); + *out = NULL; + return false; + } + + if (outlen) *outlen = needlen; + + return true; +} diff --git a/tests/base64.h b/tests/base64.h new file mode 100644 index 0000000..1ec85aa --- /dev/null +++ b/tests/base64.h @@ -0,0 +1,42 @@ +/* base64.h -- Encode binary data using printable characters. + Copyright (C) 2004, 2005, 2006 Free Software Foundation, Inc. + Written by Simon Josefsson. + + 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, 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, write to the Free Software Foundation, + Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + +#ifndef BASE64_H +#define BASE64_H + +/* Get size_t. */ +#include + +/* Get bool. */ +#include + +/* This uses that the expression (n+(k-1))/k means the smallest + integer >= n/k, i.e., the ceiling of n/k. */ +#define BASE64_LENGTH(inlen) ((((inlen) + 2) / 3) * 4) + +extern bool isbase64(char ch); + +extern void base64_encode(const char* in, size_t inlen, char* out, size_t outlen); + +extern size_t base64_encode_alloc(const char* in, size_t inlen, char** out); + +extern bool base64_decode(const char* in, size_t inlen, char* out, size_t* outlen); + +extern bool base64_decode_alloc(const char* in, size_t inlen, char** out, size_t* outlen); + +#endif /* BASE64_H */ diff --git a/tests/base64.md b/tests/base64.md new file mode 100644 index 0000000..a9f0cf5 --- /dev/null +++ b/tests/base64.md @@ -0,0 +1,79 @@ +## Parts of RFC 2045 Internet Message Bodies, November 1996 + +### Table 1: The Base64 Alphabet + + Value Encoding Value Encoding Value Encoding Value Encoding + 0 A 17 R 34 i 51 z + 1 B 18 S 35 j 52 0 + 2 C 19 T 36 k 53 1 + 3 D 20 U 37 l 54 2 + 4 E 21 V 38 m 55 3 + 5 F 22 W 39 n 56 4 + 6 G 23 X 40 o 57 5 + 7 H 24 Y 41 p 58 6 + 8 I 25 Z 42 q 59 7 + 9 J 26 a 43 r 60 8 + 10 K 27 b 44 s 61 9 + 11 L 28 c 45 t 62 + + 12 M 29 d 46 u 63 / + 13 N 30 e 47 v + 14 O 31 f 48 w (pad) = + 15 P 32 g 49 x + 16 Q 33 h 50 y + +The encoded output stream must be represented in lines of no more +than 76 characters each. All line breaks or other characters not +found in Table 1 must be ignored by decoding software. In base64 +data, characters other than those in Table 1, line breaks, and other +white space probably indicate a transmission error, about which a +warning message or even a message rejection might be appropriate +under some circumstances. + +Special processing is performed if fewer than 24 bits are available +at the end of the data being encoded. A full encoding quantum is +always completed at the end of a body. When fewer than 24 input bits +are available in an input group, zero bits are added (on the right) +to form an integral number of 6-bit groups. Padding at the end of +the data is performed using the "=" character. Since all base64 +input is an integral number of octets, only the following cases can +arise: + +(1) the final quantum of encoding input is an integral multiple of + 24 bits; here, the final unit of encoded output will be an + integral multiple of 4 characters with no "=" padding. + +(2) the final quantum of encoding input is exactly 8 bits; here, + the final unit of encoded output will be two characters + followed by two "=" padding characters. + +(3) the final quantum of encoding input is exactly 16 bits; here, + the final unit of encoded output will be three characters + followed by one "=" padding character. + +Because it is used only for padding at the end of the data, the +occurrence of any "=" characters may be taken as evidence that the +end of the data has been reached (without truncation in transit). No +such assurance is possible, however, when the number of octets +transmitted was a multiple of three and no "=" characters are +present. + +Any characters outside of the base64 alphabet are to be ignored in +base64-encoded data. + +Care must be taken to use the proper octets for line breaks if base64 +encoding is applied directly to text material that has not been +converted to canonical form. In particular, text line breaks must be +converted into CRLF sequences prior to base64 encoding. The +important thing to note is that this may be done directly by the +encoder rather than in a prior canonicalization step in some +implementations. + +#### NOTE: + +There is no need to worry about quoting potential boundary +delimiters within base64-encoded bodies within multipart entities +because no hyphen characters are used in the base64 encoding. + +## see too rfc4648 + +https://datatracker.ietf.org/doc/html/rfc4648#section-4 diff --git a/timer.cpp b/timer.cpp new file mode 100644 index 0000000..75be5c9 --- /dev/null +++ b/timer.cpp @@ -0,0 +1,65 @@ +// +// timer4/timer.cpp +// ~~~~~~~~~~~~~~~~ +// +// Copyright (c) 2003-2024 Christopher M. Kohlhoff (chris at kohlhoff dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Moderniced from Claus Klein and ChatGPT + +#include + +#include +#include +#include +#include + +class printer +{ + static constexpr int kMaxCount{5}; + + public: + explicit printer(boost::asio::io_context& io) : timer_(io, boost::asio::chrono::seconds(1)) + { + // cpp11: timer_.async_wait(std::bind(&printer::print, this)); + timer_.async_wait([this](const boost::system::error_code& /*ec*/) { print(); }); + } + + ~printer() { std::cout << "Final count is " << count_ << '\n'; } + + void print() + { + if (count_ < kMaxCount) + { + std::cout << count_ << '\n'; + ++count_; + + timer_.expires_at(timer_.expiry() + boost::asio::chrono::milliseconds(100)); + // cpp11: timer_.async_wait(std::bind(&printer::print, this)); + timer_.async_wait([this](const boost::system::error_code& /*ec*/) { print(); }); + } + } + + private: + boost::asio::steady_timer timer_; + int count_{}; +}; + +auto main() -> int +{ + try + { + boost::asio::io_context io; + auto p = std::make_unique< printer >(io); + io.run(); + } + catch (const std::exception& e) + { + fmt::print("Error: {}\n", e.what()); + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +}