From d7fc15b0bb23be3349cb0d66dd6b96e4b009db7e Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Mon, 15 Dec 2025 20:42:11 +0100 Subject: [PATCH 01/26] Fix compile issues with clang 22 --- CMakeLists.txt | 24 +++++++++++++----------- src/bencode.hpp | 2 +- src/peer.cpp | 3 +-- src/peer.hpp | 1 - src/retry.hpp | 2 +- src/torrent.cpp | 4 ++-- tests/test_utils.hpp | 2 +- vcpkg | 2 +- vcpkg.json | 4 ++-- 9 files changed, 22 insertions(+), 22 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a4f069f..2c34ab4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,17 +71,19 @@ if(UNIX) # Be strict and just disable unwanted or noisy warnings add_compile_options( -Weverything - -Wno-c++98-compat # Don't care about c++98 - -Wno-c++98-compat-pedantic # Don't care about c++98 - -Wno-switch-default # Conflicts with -Wcovered-switch-default - -Wno-ctad-maybe-unsupported # Triggers on use of scoped_lock - -Wno-padded # - -Wno-weak-vtables # - -Wno-global-constructors # performance startup-time - -Wno-exit-time-destructors # similar as ^ for destructors - -Wno-undefined-func-template # does not seem very important - -Wno-zero-as-null-pointer-constant # fails clang-10 on travis but not locally? - -Wno-disabled-macro-expansion # Does not work when interfacing with csignal + -Wno-c++98-compat # Don't care about c++98 + -Wno-c++98-compat-pedantic # Don't care about c++98 + -Wno-switch-default # Conflicts with -Wcovered-switch-default + -Wno-ctad-maybe-unsupported # Triggers on use of scoped_lock + -Wno-padded # + -Wno-weak-vtables # + -Wno-global-constructors # performance startup-time + -Wno-exit-time-destructors # similar as ^ for destructors + -Wno-undefined-func-template # does not seem very important + -Wno-zero-as-null-pointer-constant # fails clang-10 on travis but not locally? + -Wno-disabled-macro-expansion # Does not work when interfacing with csignal + -Wno-unsafe-buffer-usage-in-libc-call # Complains about fgets but would like finer grain control like https://github.com/llvm/llvm-project/issues/127849 + -Wno-unsafe-buffer-usage-in-container # We sametimes use spans to shorten the bound ) elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") add_compile_options( diff --git a/src/bencode.hpp b/src/bencode.hpp index bc6efc7..b7d9e7c 100644 --- a/src/bencode.hpp +++ b/src/bencode.hpp @@ -128,7 +128,7 @@ class Element : public std::enable_shared_from_this { if (!ptr) { throw BencodeConversionError("Could not convert to type"); } - return ptr; + return std::move(ptr); } template diff --git a/src/peer.cpp b/src/peer.cpp index 16ab500..40b104a 100644 --- a/src/peer.cpp +++ b/src/peer.cpp @@ -70,8 +70,7 @@ PeerConnection::PeerConnection(IConnectionUrlProvider& peer, resolver_(io_service), socket_(socket ? std::move(socket) : make_unique(io_service, tcp::v4())), - m_connection_port(connection_port), - m_io_service(io_service) { + m_connection_port(connection_port) { // Note use of socket constructor that does not bind such // that we can set the options before that. } diff --git a/src/peer.hpp b/src/peer.hpp index ee79f60..f39d8b6 100644 --- a/src/peer.hpp +++ b/src/peer.hpp @@ -80,7 +80,6 @@ class PeerConnection { bool m_connected = false; bool m_sending = false; ConnectionPort m_connection_port; - asio::io_service& m_io_service; }; class PeerAcceptor { diff --git a/src/retry.hpp b/src/retry.hpp index 4441bd0..ac0bee3 100644 --- a/src/retry.hpp +++ b/src/retry.hpp @@ -38,7 +38,7 @@ inline auto retry_call( for (unsigned i = 0; i < retries; ++i) { last_call = Clock::now(); if (auto ret = callable(); ret || i == retries - 1) { - return ret; + return std::move(ret); } const auto time_spent = Clock::now() - last_call; if (i < (retries - 1)) { diff --git a/src/torrent.cpp b/src/torrent.cpp index 7ec4ece..3111bf6 100644 --- a/src/torrent.cpp +++ b/src/torrent.cpp @@ -210,7 +210,7 @@ Torrent::Torrent(asio::io_context& io_context, tier_output_list, [](const auto& elm) { return elm->template to>()->val(); }); - return tier_output_list; + return std::move(tier_output_list); }); } @@ -870,7 +870,7 @@ std::vector> Torrent::tracker_request( resolved.emplace_back("localhost"); static auto docker_ip{"172.17.0.1"}; resolved.emplace_back(docker_ip); - return resolved; + return std::move(resolved); }(); return url && ranges::contains(local_ips, url->host()) && url->port().value_or(0) == m_listening_port.get(); diff --git a/tests/test_utils.hpp b/tests/test_utils.hpp index 9657bf5..367d840 100644 --- a/tests/test_utils.hpp +++ b/tests/test_utils.hpp @@ -72,7 +72,7 @@ inline std::string exec(const std::string& cmd) { std::array buffer; std::string result; while (!feof(pipe)) { - if (fgets(buffer.data(), 128, pipe) != nullptr) { + if (fgets(buffer.data(), buffer.size(), pipe) != nullptr) { result += buffer.data(); } } diff --git a/vcpkg b/vcpkg index 8ae59b5..74e6536 160000 --- a/vcpkg +++ b/vcpkg @@ -1 +1 @@ -Subproject commit 8ae59b5b1329a51875abc71d528da93d9c3e8972 +Subproject commit 74e6536215718009aae747d86d84b78376bf9e09 diff --git a/vcpkg.json b/vcpkg.json index 5f9bee4..3ea64dc 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -25,8 +25,8 @@ }, { "name": "spdlog", - "version": "1.14.1" + "version": "1.16.0" } ], - "builtin-baseline": "8ae59b5b1329a51875abc71d528da93d9c3e8972" + "builtin-baseline": "74e6536215718009aae747d86d84b78376bf9e09" } \ No newline at end of file From a54abd0b14832791a233983ec25833a207405494 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Tue, 16 Dec 2025 23:45:49 +0100 Subject: [PATCH 02/26] Fix clang-tidy-22 issues --- .clang-tidy | 5 ++- src/CMakeLists.txt | 15 ++++--- src/arg_parser.cpp | 4 +- src/arg_parser.hpp | 18 ++++---- src/bencode.hpp | 10 +++-- src/bitfield.cpp | 20 ++++++++- src/bitfield.hpp | 12 +++++- src/file_utils.hpp | 2 +- src/file_writer.cpp | 4 +- src/file_writer.hpp | 12 +++--- src/global_config.cpp | 11 +++-- src/global_config.hpp | 8 ++-- src/messages.cpp | 29 +++++++------ src/net.cpp | 44 ++++++++++++------- src/net.hpp | 10 ++--- src/peer.cpp | 18 +++++--- src/peer.hpp | 20 ++++----- src/piece.cpp | 26 ++++++------ src/piece.hpp | 8 ++-- src/scope_guard.hpp | 2 +- src/sha1.cpp | 4 +- src/sha1.hpp | 2 +- src/socket_test/client.cpp | 7 +-- src/socket_test/server.cpp | 10 +++-- src/string_utils.hpp | 19 ++++++--- src/torrent.cpp | 87 ++++++++++++++++++++------------------ src/torrent.hpp | 51 ++++++++++++---------- src/types.hpp | 36 +++++++++------- 28 files changed, 288 insertions(+), 206 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index d07160e..292350d 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -2,6 +2,7 @@ # Magic number check is a bit noisy, so off by default # -altera-struct-pack-align (too specific performance issue) +# -boost-* (not using boost) # -cert-err58-cpp statics might throw. Allowing this for now. # -clang-analyzer-cplusplus.NewDelete (temporary due to https://github.com/boostorg/asio/issues/341) # -cppcoreguidelines-avoid-const-or-ref-data-members (TODO: Recheck later) @@ -20,6 +21,7 @@ # -misc-non-private-member-variables-in-classes (optimally I would like to allow protected but not private) # -modernize-avoid-c-arrays (covered by cppcore) # -modernize-use-trailing-return-type (prefer old style for now) +# -portability-avoid-pragma-once (pragma once is nicer) # -readability-braces-around-statements (can't handle constexpr if) # -readability-function-cognitive-complexity (no hard limit on this) # -readability-inconsistent-declaration-parameter-name (prefer different names on templates) @@ -30,6 +32,7 @@ Checks: '*, -abseil-*, -altera-*, -altera-struct-pack-align, + -boost-*, -cert-err58-cpp, -clang-analyzer-cplusplus.NewDelete, -cppcoreguidelines-avoid-const-or-ref-data-members, @@ -55,6 +58,7 @@ Checks: '*, -modernize-avoid-c-arrays, -modernize-use-trailing-return-type, -objc-*, + -portability-avoid-pragma-once, -readability-braces-around-statements, -readability-function-cognitive-complexity, -readability-identifier-length, @@ -66,7 +70,6 @@ Checks: '*, ' WarningsAsErrors: '*' HeaderFilterRegex: '(src|tests)/*.h$' -AnalyzeTemporaryDtors: false FormatStyle: none User: db CheckOptions: diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 824cde3..8ea2f1e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,4 +1,5 @@ option(USE_CLANG_TIDY "Use clang-tidy if available" ON) +option(USE_CLANG_TIDY_CACHE "Use clang-tidy-cache if available" ON) option(ASIO_DEBUG "Add asio debug output" OFF) option(DISABLE_LTO "Turn off lto" OFF) @@ -10,16 +11,18 @@ option(DISABLE_LTO "Turn off lto" OFF) # the compile time went from 16s to 48s. # if(USE_CLANG_TIDY) - find_program(CLANG_TIDY NAMES clang-tidy-18) + find_program(CLANG_TIDY NAMES clang-tidy-22) if(CLANG_TIDY) find_program(CLANG_TIDY_CACHE NAMES "clang-tidy-cache") - if(CLANG_TIDY_CACHE) - message(STATUS "Found clang-tidy-cache: ${CLANG_TIDY_CACHE}") - set(CLANG_TIDY_CACHE_PREFIX "${CLANG_TIDY_CACHE};") - else() - message(STATUS "No clang-tidy-cache found") + if(USE_CLANG_TIDY_CACHE) + if(CLANG_TIDY_CACHE) + message(STATUS "Found clang-tidy-cache: ${CLANG_TIDY_CACHE}") + set(CLANG_TIDY_CACHE_PREFIX "${CLANG_TIDY_CACHE};") + else() + message(STATUS "No clang-tidy-cache found") + endif() endif() message(STATUS "Enabling clang-tidy: ${CLANG_TIDY}") diff --git a/src/arg_parser.cpp b/src/arg_parser.cpp index a334a6a..5cd2cd6 100644 --- a/src/arg_parser.cpp +++ b/src/arg_parser.cpp @@ -85,7 +85,7 @@ void ArgParser::parse(const std::vector& argv) { unsigned positional{0}; for (size_t i = 1; i < argv.size(); i++) { // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) - const std::string& name = argv[i]; + const std::string& name = argv.at(i); auto m = find(name); if (m == m_options.end()) { // No named argument - check if we have positional @@ -113,7 +113,7 @@ void ArgParser::parse(const std::vector& argv) { throw runtime_error(fmt::format("Missing value for {}", name)); } // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) - val = argv[i++ + 1]; + val = argv.at(i++ + 1); } switch (arg->get_type()) { diff --git a/src/arg_parser.hpp b/src/arg_parser.hpp index eb0beb5..b56f36c 100644 --- a/src/arg_parser.hpp +++ b/src/arg_parser.hpp @@ -19,7 +19,7 @@ namespace zit { */ class ArgParser { public: - enum class Type { INT, UINT, FLOAT, STRING, BOOL }; + enum class Type : std::uint8_t { INT, UINT, FLOAT, STRING, BOOL }; private: struct BaseArg { @@ -27,7 +27,6 @@ class ArgParser { : m_option(std::move(option)), m_type(type) {} virtual ~BaseArg() = default; - virtual void* dst() = 0; [[nodiscard]] auto get_option() const { return m_option; } [[nodiscard]] const auto& get_aliases() const { return m_aliases; } @@ -45,15 +44,15 @@ class ArgParser { protected: std::string m_option; - std::string m_help{}; - std::set m_aliases{}; + std::string m_help; + std::set m_aliases; Type m_type; bool m_provided = false; bool m_required = false; bool m_help_arg = false; bool m_is_multi = false; bool m_is_collecting = false; - std::optional m_position{}; + std::optional m_position; }; template @@ -86,15 +85,14 @@ class ArgParser { } } - void* dst() override { return &m_dst; } - [[nodiscard]] auto dst() const { return m_dst.empty() ? m_default : m_dst; } + void set_dst(T t) { if (!m_is_multi && !m_dst.empty()) { throw std::runtime_error( "Multiple values provided for single value option: " + m_option); } - m_dst.emplace_back(t); + m_dst.emplace_back(std::move(t)); } /** Help text for option */ @@ -111,7 +109,7 @@ class ArgParser { /** Default value for option */ auto& default_value(T t) { - m_default = {t}; + m_default = {std::move(t)}; return *this; } @@ -184,7 +182,7 @@ class ArgParser { void verify_no_duplicate_collecting() const; std::string m_desc; - std::vector> m_options{}; + std::vector> m_options; bool m_parsed = false; template diff --git a/src/bencode.hpp b/src/bencode.hpp index b7d9e7c..26d7465 100644 --- a/src/bencode.hpp +++ b/src/bencode.hpp @@ -83,7 +83,7 @@ template template [[nodiscard]] std::string encode(const T& in) { - using src = typename ArrayToPointerDecay::type; + using src = ArrayToPointerDecay::type; return encode_internal(in); } @@ -122,7 +122,7 @@ class Element : public std::enable_shared_from_this { template [[nodiscard]] auto to() const { - static_assert(std::is_base_of::value, + static_assert(std::is_base_of_v, "Can only return sublasses of Element"); auto ptr = std::dynamic_pointer_cast(shared_from_this()); if (!ptr) { @@ -133,7 +133,7 @@ class Element : public std::enable_shared_from_this { template [[nodiscard]] auto is() const { - static_assert(std::is_base_of::value, + static_assert(std::is_base_of_v, "Can only return sublasses of Element"); return std::dynamic_pointer_cast(shared_from_this()) != nullptr; } @@ -169,7 +169,9 @@ class TypedElement : public Element { explicit TypedElement(std::add_rvalue_reference_t data) : m_data(std::forward(data)) {} - [[nodiscard]] std::string encode() const override { return bencode::encode(m_data); } + [[nodiscard]] std::string encode() const override { + return bencode::encode(m_data); + } [[nodiscard]] auto val() const { return m_data; } diff --git a/src/bitfield.cpp b/src/bitfield.cpp index 495e66b..ffa73f0 100644 --- a/src/bitfield.cpp +++ b/src/bitfield.cpp @@ -2,6 +2,8 @@ #include "bitfield.hpp" #include "types.hpp" +#include + #include #include #include @@ -11,8 +13,12 @@ #include #include #include +#include #include +// Using operator[] on purpose +// NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) + namespace zit { namespace { @@ -59,7 +65,7 @@ Bitfield::Proxy::operator bool() const { Bitfield::Bitfield(bytes::size_type count) { // Number of bytes that will fit given number of bits // i.e. ceil(count/8) - m_bytes.resize(count / 8 + (count % 8 != 0)); + m_bytes.resize((count / 8) + (count % 8 != 0)); } bool Bitfield::get(bytes::size_type i) const { @@ -75,6 +81,14 @@ Bitfield::Proxy Bitfield::operator[](bytes::size_type i) { return {*this, i}; } +bool Bitfield::at(bytes::size_type i) const { + return get(i); +} + +Bitfield::Proxy Bitfield::at(bytes::size_type i) { + return {*this, i}; +} + void Bitfield::fill(bytes::size_type count, bool val, bytes::size_type start) { std::cerr << fmt::format("Fill: {} {} {} {}", count, val, start, m_bytes.size()) @@ -184,7 +198,7 @@ std::optional Bitfield::next(bool val, // Check first partial byte if we have a bit offset if (bit_offset) { for (unsigned short i = bit_offset; i < 8; ++i) { - const auto pos = numeric_cast(byte_offset * 8 + i); + const auto pos = numeric_cast((byte_offset * 8) + i); if (operator[](pos) == val) { return pos; } @@ -239,3 +253,5 @@ std::string format_as(const Bitfield& bf) { } } // namespace zit + +// NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) diff --git a/src/bitfield.hpp b/src/bitfield.hpp index 8c8835f..e459b70 100644 --- a/src/bitfield.hpp +++ b/src/bitfield.hpp @@ -82,6 +82,16 @@ class Bitfield { */ Proxy operator[](bytes::size_type i); + /** + * Get bit value. Alias for operator[] - both throws. + */ + [[nodiscard]] bool at(bytes::size_type i) const; + + /** + * Get bit value. Alias for operator[] - both throws. + */ + Proxy at(bytes::size_type i); + /** * Fill the first n bits with 0 or 1. * @@ -129,7 +139,7 @@ class Bitfield { [[nodiscard]] const bytes& data() const { return m_bytes; } private: - bytes m_bytes{}; + bytes m_bytes; }; std::ostream& operator<<(std::ostream& os, const Bitfield& bf); diff --git a/src/file_utils.hpp b/src/file_utils.hpp index f775b2f..bd12387 100644 --- a/src/file_utils.hpp +++ b/src/file_utils.hpp @@ -51,7 +51,7 @@ inline void write_file(const std::filesystem::path& file_name, } // namespace zit -// NOLINTNEXTLINE(cert-dcl58-cpp) +// NOLINTNEXTLINE(cert-dcl58-cpp,bugprone-std-namespace-modification) namespace std::filesystem { inline auto format_as(const path& p) { return p.string(); diff --git a/src/file_writer.cpp b/src/file_writer.cpp index a897d58..e69c64c 100644 --- a/src/file_writer.cpp +++ b/src/file_writer.cpp @@ -30,7 +30,7 @@ using namespace std; namespace fs = filesystem; void FileWriter::add(Torrent* torrent, const shared_ptr& piece) { - const lock_guard lock(m_queue_mutex); + const scoped_lock lock(m_queue_mutex); logger()->debug("Piece {} added to queue", piece->id()); m_queue.emplace(torrent, piece); m_condition.notify_one(); @@ -270,7 +270,7 @@ void FileWriter::write_next_piece() { auto [torrent, piece] = t_piece; try { auto sha = Sha1::calculateData(piece->data()); - if (sha != torrent->pieces()[piece->id()]) { + if (sha != torrent->pieces().at(piece->id())) { throw runtime_error("Piece data does not match expected Sha1"); } diff --git a/src/file_writer.hpp b/src/file_writer.hpp index ef22bc1..19dbad2 100644 --- a/src/file_writer.hpp +++ b/src/file_writer.hpp @@ -93,12 +93,12 @@ class FileWriter { private: void write_next_piece(); - std::queue m_queue{}; - std::mutex m_queue_mutex{}; - std::mutex m_file_mutex{}; - std::condition_variable m_condition{}; + std::queue m_queue; + std::mutex m_queue_mutex; + std::mutex m_file_mutex; + std::condition_variable m_condition; std::atomic_bool m_stop = false; - TorrentWrittenCallback m_torrent_written_callback{}; + TorrentWrittenCallback m_torrent_written_callback; }; /** @@ -131,7 +131,7 @@ class FileWriterThread { } private: - std::shared_ptr m_logger{}; + std::shared_ptr m_logger; FileWriter& m_file_writer; std::thread m_file_writer_thread; }; diff --git a/src/global_config.cpp b/src/global_config.cpp index 755458c..31dec89 100644 --- a/src/global_config.cpp +++ b/src/global_config.cpp @@ -119,6 +119,9 @@ std::optional parse_bool(const std::string& _str) { template const std::map settings_map; +// Should be fine - possibly try maxing them constexpr +// NOLINTBEGIN(bugprone-throwing-static-initialization) + template <> const std::map settings_map{ {"initiate_peer_connections", BoolSetting::INITIATE_PEER_CONNECTIONS}, @@ -137,6 +140,8 @@ template <> const std::map settings_map{ {"bind_address", StringSetting::BIND_ADDRESS}}; +// NOLINTEND(bugprone-throwing-static-initialization) + } // namespace std::ostream& operator<<(std::ostream& os, const Config& config) { @@ -170,9 +175,9 @@ bool FileConfig::try_file(const std::filesystem::path& config_file) { if (kv.size() != 2) { logger()->warn("Ignoring invalid config line: {}", line); } else { - trim(kv[0]); - trim(kv[1]); - update_value(kv[0], kv[1]); + trim(kv.at(0)); + trim(kv.at(1)); + update_value(kv.at(0), kv.at(1)); } } return true; diff --git a/src/global_config.hpp b/src/global_config.hpp index d10192c..fd85f36 100644 --- a/src/global_config.hpp +++ b/src/global_config.hpp @@ -8,7 +8,7 @@ #include namespace spdlog { -class logger; +class logger; // NOLINT(readability-identifier-naming) } namespace zit { @@ -16,7 +16,7 @@ namespace zit { /** * Boolean settings. In the config file they can be read as true/false or 1/0 */ -enum class BoolSetting { +enum class BoolSetting : std::uint8_t { /** * If this is true, Zit will initiate connections to peers from the tracker * even if we are just seeding. This works around a problem where some @@ -38,7 +38,7 @@ enum class BoolSetting { * Settings taking an integer. An invalid integer for the setting is a runtime * error. */ -enum class IntSetting { +enum class IntSetting : std::uint8_t { /** * Port on which the Zit client is listening to incoming peer connections. */ @@ -60,7 +60,7 @@ enum class IntSetting { RETRY_PEERS_INTERVAL_SECONDS }; -enum class StringSetting { +enum class StringSetting : std::uint8_t { /** * IPv4 address to accept incoming connections on, by default 127.0.0.1 */ diff --git a/src/messages.cpp b/src/messages.cpp index 5deaaaa..ea01a4b 100644 --- a/src/messages.cpp +++ b/src/messages.cpp @@ -114,7 +114,7 @@ string debugMsg(span msg) { ss.fill('0'); ss << hex; for (const auto b : msg) { - ss << setw(2) << int(b) << " "; + ss << setw(2) << static_cast(b) << " "; } return ss.str(); } @@ -131,21 +131,22 @@ optional HandshakeMsg::parse(const bytes& msg) { 'r'_b, 'e'_b, 'n'_b, 't'_b, ' '_b, 'p'_b, 'r'_b, 'o'_b, 't'_b, 'o'_b, 'c'_b, 'o'_b, 'l'_b}; - const auto it = - std::search(msg.begin(), msg.end(), BT_START.begin(), BT_START.end()); - if (it == msg.end()) { - logger()->trace("No handshake match:\nGot: {}\nExp: {}", - debugMsg(span(msg.begin(), std::min(msg.size(), BT_START.size()))), - debugMsg(BT_START)); + const auto sr = std::ranges::search(msg, BT_START); + if (sr.empty()) { + logger()->trace( + "No handshake match:\nGot: {}\nExp: {}", + debugMsg(span(msg.begin(), std::min(msg.size(), BT_START.size()))), + debugMsg(BT_START)); return {}; } - if (it != msg.begin()) { - logger()->debug("Found BT start at {}", std::distance(msg.begin(), it)); - return HandshakeMsg::parse(bytes{it, msg.end()}); + if (sr.begin() != msg.begin()) { + logger()->debug("Found BT start at {}", + std::distance(msg.begin(), sr.begin())); + return HandshakeMsg::parse(bytes{sr.begin(), msg.end()}); } - const bytes reserved(&msg[20], &msg[28]); + const bytes reserved(&msg.at(20), &msg.at(28)); const Sha1 info_hash = Sha1::fromBuffer(msg, 28); const string peer_id = from_bytes(msg, 48, 68); @@ -155,10 +156,10 @@ optional HandshakeMsg::parse(const bytes& msg) { logger()->error("Invalid handshake length: {}", msg.size()); return {}; } - if (to_peer_wire_id(msg[72]) != peer_wire_id::BITFIELD) { + if (to_peer_wire_id(msg.at(72)) != peer_wire_id::BITFIELD) { logger()->error("Expected bitfield id ({}) but got: {}", static_cast(peer_wire_id::BITFIELD), - static_cast(msg[72])); + static_cast(msg.at(72))); return {}; } // 4-byte big endian @@ -222,7 +223,7 @@ size_t Message::parse(PeerConnection& connection) { return 4; } if (m_msg.size() >= 5) { - auto id = to_peer_wire_id(m_msg[4]); + auto id = to_peer_wire_id(m_msg.at(4)); logger()->debug("{}: Received: {}", peer.str(), id); switch (id) { case peer_wire_id::CHOKE: diff --git a/src/net.cpp b/src/net.cpp index 00bfdc3..42a039b 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -5,10 +5,11 @@ #include #include #include +#include #include #include #include -#include +#include // NOLINT(misc-include-cleaner) #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -73,11 +75,11 @@ vector get_host_ip_addresses() { vector ip_addresses; asio::io_context io_context; asio::ip::tcp::resolver resolver(io_context); - asio::ip::tcp::resolver::query query(asio::ip::host_name(), ""); + const asio::ip::tcp::resolver::query query(asio::ip::host_name(), ""); asio::ip::tcp::resolver::iterator it = resolver.resolve(query); while (it != asio::ip::tcp::resolver::iterator()) { - asio::ip::tcp::endpoint endpoint = *it++; + const asio::ip::tcp::endpoint endpoint = *it++; ip_addresses.push_back(endpoint.address().to_string()); } @@ -167,7 +169,7 @@ std::tuple request(Sock& sock, const Url& url) { response_stream >> status_code; std::string status_message; std::getline(response_stream, status_message); - if (!response_stream || http_version.substr(0, 5) != "HTTP/") { + if (!response_stream || !http_version.starts_with("HTTP/")) { throw runtime_error("invalid response"); } @@ -321,13 +323,16 @@ void addWindowsCertificates(const SSL_CTX* ctx) { // Based on example at // https://www.boost.org/doc/libs/1_73_0/doc/html/boost_asio/overview/ssl.html -std::tuple httpsGet(const Url& url, - const Net::BindAddress& bind_address) { +std::tuple httpsGet( + const Url& url, + const Net::BindAddress& bind_address) { using ssl_socket = ssl::stream; // Create a context that uses the default paths for // finding CA certificates. ssl::context ctx(ssl::context::tlsv12); + // Both critical section and call is inside asio - so nothing we can control + // NOLINTNEXTLINE(clang-analyzer-unix.BlockInCriticalSection) if (!bind_address.get().empty() && bind_address.get() != "127.0.0.1") { throw std::runtime_error("bind for ssl not yet supported"); } @@ -339,15 +344,20 @@ std::tuple httpsGet(const Url& url, ctx.set_default_verify_paths(); // In my case the default path is not aligned with the systems on Ubuntu // (/usr/local/ssl/certs). Thus adding the standard linux directory in - // addition. This is likely due OpenSSL being built by Conan with that path. - // Note: With a newer conan openssl build this was no longer necessary + // addition. This is likely due to OpenSSL being built by Conan with that + // path. Note: With a newer conan openssl build this was no longer necessary // ctx.add_verify_path("/etc/ssl/certs"); // Open a socket and connect it to the remote host. asio::io_context io_context; + + // Both critical section and call is inside asio - so nothing we can control + // NOLINTNEXTLINE(clang-analyzer-unix.BlockInCriticalSection) ssl_socket sock(io_context, ctx); + tcp::resolver resolver(io_context); const tcp::resolver::query query(url.host(), url.service()); + // NOLINTNEXTLINE(misc-include-cleaner) asio::connect(sock.lowest_layer(), resolver.resolve(query)); sock.lowest_layer().set_option(tcp::no_delay(true)); @@ -419,8 +429,8 @@ std::tuple Net::httpGet( try { if (!bind_address.get().empty()) { socket.open(asio::ip::tcp::v4()); - socket.bind( - asio::ip::tcp::endpoint(asio::ip::make_address(bind_address.get()), 0)); + socket.bind(asio::ip::tcp::endpoint( + asio::ip::make_address(bind_address.get()), 0)); logger()->debug("Http request from {}:{}", socket.local_endpoint().address().to_string(), socket.local_endpoint().port()); @@ -430,6 +440,7 @@ std::tuple Net::httpGet( fmt::format("Could not bind to address: '{}'", bind_address.get()))); } asio::error_code error = asio::error::host_not_found; + // NOLINTNEXTLINE(misc-include-cleaner) asio::connect(socket, endpoints, error); if (error) { throw asio::system_error(error, url.str()); @@ -517,7 +528,7 @@ string Net::urlEncode(const string& value) { // Any other characters are percent-encoded escaped << uppercase; - escaped << '%' << setw(2) << int(static_cast(C)); + escaped << '%' << setw(2) << static_cast(static_cast(C)); escaped << nouppercase; } @@ -548,11 +559,14 @@ Url::Url(const string& url, Binary binary, Resolve resolve) { to_string(url.length())); } stringstream ss; - ss << to_string(uint8_t(url[0])) << "." << to_string(uint8_t(url[1])) << "." - << to_string(uint8_t(url[2])) << "." << to_string(uint8_t(url[3])); + ss << to_string(static_cast(url.at(0))) << "." + << to_string(static_cast(url.at(1))) << "." + << to_string(static_cast(url.at(2))) << "." + << to_string(static_cast(url.at(3))); m_host = ss.str(); - m_port = host_to_network_short(static_cast( - static_cast(url[4]) << 0 | static_cast(url[5]) << 8)); + m_port = host_to_network_short( + static_cast(static_cast(url.at(4)) << 0 | + static_cast(url.at(5)) << 8)); m_scheme = "http"; } diff --git a/src/net.hpp b/src/net.hpp index b5f1a92..8c2edc1 100644 --- a/src/net.hpp +++ b/src/net.hpp @@ -127,11 +127,11 @@ class Url { } private: - std::string m_scheme{}; - std::string m_host{}; - std::string m_path{}; - string_list m_params{}; - std::optional m_port{}; + std::string m_scheme; + std::string m_host; + std::string m_path; + string_list m_params; + std::optional m_port; }; inline std::ostream& operator<<(std::ostream& os, const zit::Url& url) { diff --git a/src/peer.cpp b/src/peer.cpp index 40b104a..ae4ec74 100644 --- a/src/peer.cpp +++ b/src/peer.cpp @@ -1,9 +1,10 @@ // -*- mode:c++; c-basic-offset : 2; -*- #include "peer.hpp" +#include #include #include -#include +#include // NOLINT(misc-include-cleaner) #include #include #include @@ -11,11 +12,11 @@ #include #include #include -#include +#include // NOLINT(misc-include-cleaner) #include #include #include -#include +#include // NOLINT(misc-include-cleaner) #ifndef _MSC_VER #include @@ -145,6 +146,7 @@ void PeerConnection::send(bool start_read) { m_sending = true; const string msg(m_msg); m_msg.clear(); + // NOLINTNEXTLINE(misc-include-cleaner) asio::async_write( *socket_, asio::buffer(msg.c_str(), msg.size()), [this, start_read](auto err, auto len) { @@ -222,6 +224,7 @@ void PeerConnection::handle_response(const asio::error_code& err, std::size_t) { // TODO: https://sourceforge.net/p/asio/mailman/message/23968189/ // mentions that maybe socket.async_read_some is better to read > 512 // bytes at a time + // NOLINTNEXTLINE(misc-include-cleaner) asio::async_read( *socket_, response_, asio::transfer_at_least(1), [this](const auto& ec, auto s) { handle_response(ec, s); }); @@ -249,7 +252,9 @@ PeerAcceptor::PeerAcceptor(ListeningPort port, m_bind_address(std::move(bind_address)) { logger()->trace(PRETTY_FUNCTION); // For now rethrowing - can consider asio::detached later? - co_spawn(m_io_context, listen(), [](auto e) { std::rethrow_exception(e); }); + // NOLINTNEXTLINE(misc-include-cleaner) + co_spawn(m_io_context, listen(), + [](auto e) { std::rethrow_exception(std::move(e)); }); } asio::awaitable PeerAcceptor::listen() { @@ -278,6 +283,7 @@ asio::awaitable PeerAcceptor::listen() { // Read data from socket bytes buffer; + // NOLINTNEXTLINE(misc-include-cleaner) co_await asio::async_read(socket, asio::dynamic_buffer(buffer), asio::transfer_at_least(MIN_BT_MSG_LENGTH), asio::use_awaitable); @@ -409,7 +415,7 @@ std::size_t Peer::request_next_block(unsigned short count) { return requests; } bytes req; - for (int i = 0; i < count; i++) { + for (unsigned short i = 0; i < count; i++) { // We can now start requesting pieces auto has_piece = next_piece(true); if (!has_piece) { @@ -503,7 +509,7 @@ void Peer::have(uint32_t id) { m_remote_pieces = Bitfield(number_of_pieces); m_remote_pieces.fill(number_of_pieces, true); } else { - m_remote_pieces[id] = true; + m_remote_pieces.at(id) = true; } request_next_block(); } diff --git a/src/peer.hpp b/src/peer.hpp index f39d8b6..0032c00 100644 --- a/src/peer.hpp +++ b/src/peer.hpp @@ -70,13 +70,13 @@ class PeerConnection { void send(bool start_read = false); - std::string m_msg{}; + std::string m_msg; IConnectionUrlProvider& peer_; asio::ip::tcp::resolver resolver_; - asio::streambuf response_{}; + asio::streambuf response_; socket_ptr socket_; - asio::ip::tcp::resolver::iterator endpoint_{}; - std::deque m_send_queue{}; + asio::ip::tcp::resolver::iterator endpoint_; + std::deque m_send_queue; bool m_connected = false; bool m_sending = false; ConnectionPort m_connection_port; @@ -240,7 +240,7 @@ class Peer : public IConnectionUrlProvider { [[nodiscard]] bool is_inactive() const; private: - std::optional m_url{}; + std::optional m_url; bool m_am_choking = true; bool m_am_interested = false; bool m_choking = true; @@ -249,15 +249,15 @@ class Peer : public IConnectionUrlProvider { void init_io_service(socket_ptr socket = nullptr); - Bitfield m_remote_pieces{}; + Bitfield m_remote_pieces; // order important - connection need to be destroyed first - std::unique_ptr m_io_service{}; - std::unique_ptr m_connection{}; + std::unique_ptr m_io_service; + std::unique_ptr m_connection; Torrent& m_torrent; - std::unique_ptr m_work{}; - std::chrono::system_clock::time_point m_last_activity{}; + std::unique_ptr m_work; + std::chrono::system_clock::time_point m_last_activity; }; inline std::ostream& operator<<(std::ostream& os, const zit::Peer& peer) { diff --git a/src/piece.cpp b/src/piece.cpp index 2056b2e..2a38aef 100644 --- a/src/piece.cpp +++ b/src/piece.cpp @@ -26,7 +26,7 @@ using namespace std; namespace zit { optional Piece::next_offset(bool mark) { - const lock_guard lock(m_mutex); + const scoped_lock lock(m_mutex); const auto req_or_done = m_blocks_requested + m_blocks_done; auto next = req_or_done.next(false); if (!next) { @@ -40,14 +40,14 @@ optional Piece::next_offset(bool mark) { } // Mark block as requested if (mark) { - m_blocks_requested[*next] = true; + m_blocks_requested.at(*next) = true; m_last_request = std::chrono::system_clock::now(); } return numeric_cast(*next) * m_block_size; } bytes Piece::data() const { - const lock_guard lock(m_mutex); + const scoped_lock lock(m_mutex); if (m_data.empty()) { logger()->warn("Retrieved empty data from piece {}", m_id); } @@ -66,15 +66,15 @@ bool Piece::set_block(uint32_t offset, bytes_span data) { } auto block_id = offset / m_block_size; - const lock_guard lock(m_mutex); - if (m_blocks_done[block_id]) { + const scoped_lock lock(m_mutex); + if (m_blocks_done.at(block_id)) { logger()->warn("Already got block {} for piece {}", block_id, m_id); } else { - if (!m_blocks_requested[block_id]) { + if (!m_blocks_requested.at(block_id)) { logger()->warn("Got data for non requested block?"); } ranges::copy(data, m_data.begin() + offset); - m_blocks_done[block_id] = true; + m_blocks_done.at(block_id) = true; logger()->debug("Block {}/{} of size {} stored for piece {}", block_id + 1, block_count(), data.size(), m_id); } @@ -94,8 +94,8 @@ bytes Piece::get_block(uint32_t offset, } auto block_id = offset / m_block_size; - const lock_guard lock(m_mutex); - if (!m_blocks_done[block_id]) { + const scoped_lock lock(m_mutex); + if (!m_blocks_done.at(block_id)) { logger()->warn("Block {} in piece {} not done", block_id, m_id); return {}; } @@ -111,15 +111,15 @@ bytes Piece::get_block(uint32_t offset, logger()->debug("Returning block {} in piece {} from disk", block_id, m_id); // Important to use the piece_length from the torrent, since the last // piece is often shorter and can't thus be used to get the offset. - const auto file_offset = torrent.piece_length() * m_id + offset; + const auto file_offset = (torrent.piece_length() * m_id) + offset; return FileWriter::getInstance().read_block(file_offset, length, torrent); } void Piece::set_piece_written(bool written) { m_piece_written = written; - const lock_guard lock(m_mutex); + const scoped_lock lock(m_mutex); for (uint32_t i = 0; i < block_count(); ++i) { - m_blocks_done[i] = true; + m_blocks_done.at(i) = true; } m_data.clear(); m_data.shrink_to_fit(); @@ -142,7 +142,7 @@ std::size_t Piece::retry_blocks() { logger()->warn( "Piece {} inactive for {} seconds. Marking for retry.", m_id, std::chrono::duration_cast(inactive).count()); - const lock_guard lock(m_mutex); + const scoped_lock lock(m_mutex); const auto retry = (m_blocks_requested - m_blocks_done).count(); // TODO: Should we really request the whole piece again? diff --git a/src/piece.hpp b/src/piece.hpp index 6d9b730..5019dd0 100644 --- a/src/piece.hpp +++ b/src/piece.hpp @@ -90,11 +90,11 @@ class Piece { Bitfield m_blocks_requested; Bitfield m_blocks_done; const uint32_t m_id; - mutable std::mutex m_mutex{}; - bytes m_data{}; + mutable std::mutex m_mutex; + bytes m_data; std::atomic_bool m_piece_written = false; - std::chrono::system_clock::time_point m_last_request{}; - std::chrono::system_clock::time_point m_last_block{}; + std::chrono::system_clock::time_point m_last_request; + std::chrono::system_clock::time_point m_last_block; }; } // namespace zit diff --git a/src/scope_guard.hpp b/src/scope_guard.hpp index bc9631c..15e2968 100644 --- a/src/scope_guard.hpp +++ b/src/scope_guard.hpp @@ -23,7 +23,7 @@ class ScopeGuard { } catch (...) { try { logger()->warn("Exception thrown from ScopeGuard"); - } catch (...) { + } catch (...) { // NOLINT(bugprone-empty-catch) } } } diff --git a/src/sha1.cpp b/src/sha1.cpp index 17c40a3..c956101 100644 --- a/src/sha1.cpp +++ b/src/sha1.cpp @@ -142,12 +142,12 @@ Sha1 Sha1::calculateFile(const std::filesystem::path& file) { } template -Sha1 Sha1::fromBuffer(const T& buffer, typename T::size_type offset) { +Sha1 Sha1::fromBuffer(const T& buffer, T::size_type offset) { if (offset + SHA_LENGTH > buffer.size()) { throw invalid_argument("Buffer too small for extracting sha1"); } Sha1 ret; - copy_n(reinterpret_cast(&buffer[offset]), SHA_LENGTH, + copy_n(reinterpret_cast(&buffer.at(offset)), SHA_LENGTH, ret.data()); return ret; } diff --git a/src/sha1.hpp b/src/sha1.hpp index 03532b6..69a34f8 100644 --- a/src/sha1.hpp +++ b/src/sha1.hpp @@ -45,7 +45,7 @@ class Sha1 : public std::array { * Implemented for zit::bytes and std::strings. */ template - static Sha1 fromBuffer(const T& buffer, typename T::size_type offset); + static Sha1 fromBuffer(const T& buffer, T::size_type offset); private: [[nodiscard]] static Sha1 calculate(const unsigned char* src, size_t count); diff --git a/src/socket_test/client.cpp b/src/socket_test/client.cpp index 9a8c137..edc7837 100644 --- a/src/socket_test/client.cpp +++ b/src/socket_test/client.cpp @@ -4,7 +4,7 @@ #include #include #include -#include +#include // NOLINT(misc-include-cleaner) #include #include #include @@ -16,8 +16,8 @@ #include #include #include -#include "asio_helpers.hpp" -#include "common.hpp" // NOLINT(misc-include-cleaner) +#include "asio_helpers.hpp" // NOLINT(misc-include-cleaner) +#include "common.hpp" // NOLINT(misc-include-cleaner) #include "logger.hpp" class Connection : public socket_test::ID { @@ -79,6 +79,7 @@ int main(int argc, char* argv[]) { // Note: asio::detached is often used in examples but it will ignore any // exceptions thrown by the coroutine. Using my own completion // handler here instead that just rethrows the exception instead. + // NOLINTNEXTLINE(misc-include-cleaner) co_spawn(io_context, connections.back()->connect(), socket_test::rethrow); } io_context.run(); diff --git a/src/socket_test/server.cpp b/src/socket_test/server.cpp index 7a64f1c..ba9ec90 100644 --- a/src/socket_test/server.cpp +++ b/src/socket_test/server.cpp @@ -2,10 +2,10 @@ // #define ASIO_ENABLE_HANDLER_TRACKING #include -#include +#include // NOLINT(misc-include-cleaner) #include #include -#include +#include // NOLINT(misc-include-cleaner) #include #include #include @@ -17,8 +17,8 @@ #include #include #include -#include "asio_helpers.hpp" -#include "common.hpp" // NOLINT(misc-include-cleaner) +#include "asio_helpers.hpp" // NOLINT(misc-include-cleaner) +#include "common.hpp" // NOLINT(misc-include-cleaner) #include "logger.hpp" class Connection : public socket_test::ID { @@ -44,6 +44,7 @@ class Connection : public socket_test::ID { // Read incoming data asio::streambuf buffer; + // NOLINTNEXTLINE(misc-include-cleaner) co_await asio::async_read_until(m_socket, buffer, '\n', asio::use_awaitable); @@ -93,6 +94,7 @@ int main(int argc, char* argv[]) { // Note: asio::detached is often used in examples but it will ignore any // exceptions thrown by the coroutine. Using my own completion // handler here instead that just rethrows the exception instead. + // NOLINTNEXTLINE(misc-include-cleaner) co_spawn(io_context, connections.back()->listen(), socket_test::rethrow); } io_context.run(); diff --git a/src/string_utils.hpp b/src/string_utils.hpp index 4c5438b..afc8df0 100644 --- a/src/string_utils.hpp +++ b/src/string_utils.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -39,9 +40,12 @@ inline std::string from_bytes(const bytes& buffer, } return ss.str(); #else + // Bounds checked above + // NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) return { reinterpret_cast(&buffer[start]), reinterpret_cast(&buffer[end == 0 ? buffer.size() : end])}; + // NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) #endif // WIN32 } @@ -77,17 +81,18 @@ inline std::string to_hex(const bytes& data) { // trim from start (in place) inline void ltrim(std::string& s) { - s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch) { + s.erase(s.begin(), std::ranges::find_if(s, [](unsigned char ch) { return !std::isspace(ch); })); } // trim from end (in place) inline void rtrim(std::string& s) { - s.erase(std::find_if(s.rbegin(), s.rend(), - [](unsigned char ch) { return !std::isspace(ch); }) - .base(), - s.end()); + s.erase( + std::ranges::find_if(std::ranges::reverse_view(s), + [](unsigned char ch) { return !std::isspace(ch); }) + .base(), + s.end()); } // trim from both ends (in place) @@ -150,8 +155,8 @@ inline std::string bytesToHumanReadable(int64_t bytes) { if (abytes >= limit) { if (limit) { const auto w = abytes / limit; - const auto f = - static_cast(abytes - w * limit) / static_cast(limit); + const auto f = static_cast(abytes - (w * limit)) / + static_cast(limit); return fmt::format("{}{:.2f} {}", sign, static_cast(w) + f, unit); } diff --git a/src/torrent.cpp b/src/torrent.cpp index 3111bf6..d04ccda 100644 --- a/src/torrent.cpp +++ b/src/torrent.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -35,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -61,7 +63,8 @@ using namespace std; namespace std { template -// NOLINTNEXTLINE(cert-dcl58-cpp) +// Various issues related to std extensions +// NOLINTNEXTLINE std::string format_as(const atomic& t) { return std::to_string(t.load()); } @@ -86,7 +89,7 @@ auto transform_all(const In& in, Out& out, Op func) { auto beDictToFileInfo(const Element& element) { const auto& dict = element.template to>()->val(); string md5; - if (dict.find("md5sum") != dict.end()) { + if (dict.contains("md5sum")) { md5 = dict.at("md5sum")->template to>()->val(); } const auto path_list = @@ -164,10 +167,10 @@ Torrent::Torrent(asio::io_context& io_context, info.at("piece length")->to>()->val()); // Either 'length' or 'files' is needed depending on mode - if (info.find("length") != info.end()) { + if (info.contains("length")) { m_length = info.at("length")->to>()->val(); } - if (info.find("files") != info.end()) { + if (info.contains("files")) { if (m_length != 0) { throw runtime_error("Invalid torrent: dual mode"); } @@ -220,7 +223,7 @@ Torrent::Torrent(asio::io_context& io_context, verify_existing_file(); { - scoped_lock lock(m_torrents_mutex); + const scoped_lock lock(m_torrents_mutex); if (m_torrents.contains(m_info_hash)) { throw runtime_error("Torrent already exists"); } @@ -230,7 +233,7 @@ Torrent::Torrent(asio::io_context& io_context, } Torrent::~Torrent() { - scoped_lock lock(m_torrents_mutex); + const scoped_lock lock(m_torrents_mutex); m_torrents.erase(m_info_hash); } @@ -260,8 +263,8 @@ void Torrent::verify_piece_single_file(uintmax_t file_length, if (sha1 == fsha1) { // Lock when updating num_pieces, inserting into // std::map is likely not thread safe. - const std::lock_guard lock(mutex); - m_client_pieces[id] = true; + const std::scoped_lock lock(mutex); + m_client_pieces.at(id) = true; m_active_pieces.emplace( id, make_shared(PieceId(id), PieceSize(piece_length(id)))); m_active_pieces[id]->set_piece_written(true); @@ -305,8 +308,8 @@ void Torrent::verify_piece_multi_file(std::atomic_uint32_t& num_pieces, if (sha1 == fsha1) { // Lock when updating num_pieces, inserting into std::map is // likely not thread safe. - const std::lock_guard lock(mutex); - m_client_pieces[id] = true; + const std::scoped_lock lock(mutex); + m_client_pieces.at(id) = true; m_active_pieces.emplace( id, make_shared(PieceId(id), PieceSize(piece_length(id)))); m_active_pieces[id]->set_piece_written(true); @@ -471,8 +474,8 @@ Torrent::http_tracker_request(const Url& announce_url, TrackerEvent event) { logger()->info("HTTP Tracker request ({}): {}", event, url.str()); } - auto [headers, body] = - m_http_get(url, Net::BindAddress(m_config.get(StringSetting::BIND_ADDRESS))); + auto [headers, body] = m_http_get( + url, Net::BindAddress(m_config.get(StringSetting::BIND_ADDRESS))); std::vector> peers; @@ -493,13 +496,13 @@ Torrent::http_tracker_request(const Url& announce_url, TrackerEvent event) { logger()->debug("=====HEADER=====\n{}\n=====BODY=====\n{}", headers, reply); auto reply_dict = reply->to>()->val(); - if (reply_dict.find("failure reason") != reply_dict.end()) { + if (reply_dict.contains("failure reason")) { throw runtime_error( "Tracker request failed: " + reply_dict["failure reason"]->to>()->val()); } - if (reply_dict.find("peers") == reply_dict.end()) { + if (!reply_dict.contains("peers")) { throw runtime_error("Invalid tracker reply, no peer list"); } auto peers_dict = reply_dict["peers"]; @@ -663,10 +666,10 @@ class UDPTrackerRequest { logger()->debug("Parsing {} peers", peers_in_list); for (size_t i = 0; i < peers_in_list; ++i) { const auto ip = from_big_endian( - announce_response, peer_offset + i * size_of_peer); + announce_response, peer_offset + (i * size_of_peer)); const auto port = from_big_endian( announce_response, - peer_offset + i * size_of_peer + sizeof(int32_t)); + peer_offset + (i * size_of_peer) + sizeof(int32_t)); const auto ip_str = fmt::format("{}.{}.{}.{}", static_cast((ip >> 24) & 0xFF), @@ -688,7 +691,12 @@ class UDPTrackerRequest { } private: - enum class UdpAction { CONNECT = 0, ANNOUNCE = 1, SCRAPE = 2, ERR = 3 }; + enum class UdpAction : std::uint8_t { + CONNECT = 0, + ANNOUNCE = 1, + SCRAPE = 2, + ERR = 3 + }; /** Connect to UDP tracker */ bool connect() { @@ -785,7 +793,7 @@ class UDPTrackerRequest { Url m_announce_url; Torrent& m_torrent; - std::optional m_connection_id{}; + std::optional m_connection_id; TimePoint m_last_connection_id{ClockType::time_point::min()}; }; @@ -868,7 +876,8 @@ std::vector> Torrent::tracker_request( static const auto local_ips = [] { auto resolved = get_host_ip_addresses(); resolved.emplace_back("localhost"); - static auto docker_ip{"172.17.0.1"}; + // FIXME: This should not be hardcoded + constexpr auto* docker_ip{"172.17.0.1"}; resolved.emplace_back(docker_ip); return std::move(resolved); }(); @@ -884,7 +893,7 @@ std::vector> Torrent::tracker_request( } void Torrent::start() { - scoped_lock lock(m_peers_mutex); + const scoped_lock lock(m_peers_mutex); if (!m_peers.empty()) { throw runtime_error("Local peer vector not empty"); } @@ -941,7 +950,7 @@ void Torrent::run() { while (!m_stopped && (!done() || !ranges::all_of(m_peers, is_stopped))) { std::size_t ran = 0; { - scoped_lock lock(m_peers_mutex); + const scoped_lock lock(m_peers_mutex); for (auto& p : m_peers) { ran += p->io_service().poll_one(); } @@ -962,7 +971,7 @@ void Torrent::run() { } void Torrent::stop() { - scoped_lock lock(m_peers_mutex); + const scoped_lock lock(m_peers_mutex); for (auto& peer : m_peers) { peer->stop(); } @@ -971,7 +980,7 @@ void Torrent::stop() { } Torrent* Torrent::get(const Sha1& info_hash) { - scoped_lock lock(m_torrents_mutex); + const scoped_lock lock(m_torrents_mutex); if (auto match = m_torrents.find(info_hash); match != m_torrents.end()) { return match->second; } @@ -979,7 +988,7 @@ Torrent* Torrent::get(const Sha1& info_hash) { } size_t Torrent::count() { - scoped_lock lock(m_torrents_mutex); + const scoped_lock lock(m_torrents_mutex); return m_torrents.size(); } @@ -1050,7 +1059,7 @@ void Torrent::retry_pieces() { // To hit different peers for each invocation - shuffle the list std::random_device rd; std::mt19937 g(rd()); - scoped_lock lock(m_peers_mutex); + const scoped_lock lock(m_peers_mutex); shuffle(m_peers.begin(), m_peers.end(), g); auto it = m_peers.begin(); @@ -1077,14 +1086,12 @@ bool Torrent::add_peer( shared_ptr peer, optional>>> peers) { const bool in_use = - std::find_if(m_peers.begin(), m_peers.end(), - [&peer](auto& existing_peer) { - const auto& lurl = peer->url(); - const auto& rurl = existing_peer->url(); - return lurl.has_value() && rurl.has_value() && - peer->url().value().str() == - existing_peer->url().value().str(); - }) != m_peers.end(); + std::ranges::find_if(m_peers, [&peer](auto& existing_peer) { + const auto& lurl = peer->url(); + const auto& rurl = existing_peer->url(); + return lurl.has_value() && rurl.has_value() && + peer->url().value().str() == existing_peer->url().value().str(); + }) != m_peers.end(); logger()->debug("Candidate {} in use: {}", peer->str(), in_use); if (!in_use) { peer->handshake(); @@ -1103,7 +1110,7 @@ void Torrent::retry_peers() { return; } - scoped_lock lock(m_peers_mutex); + const scoped_lock lock(m_peers_mutex); const ScopeGuard scope_guard([this]() { schedule_retry_peers(); }); logger()->debug("Checking peers for retry"); @@ -1142,9 +1149,9 @@ void Torrent::retry_peers() { // TODO: Yes, should group these in one type // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) bool Torrent::set_block(uint32_t piece_id, uint32_t offset, bytes_span data) { - const std::lock_guard lock(m_mutex); + const std::scoped_lock lock(m_mutex); // Look up relevant piece object among active pieces - if (m_active_pieces.find(piece_id) != m_active_pieces.end()) { + if (m_active_pieces.contains(piece_id)) { auto piece = m_active_pieces[piece_id]; if (piece->set_block(offset, data)) { logger()->debug("Piece {} done!", piece_id); @@ -1157,7 +1164,7 @@ bool Torrent::set_block(uint32_t piece_id, uint32_t offset, bytes_span data) { } std::shared_ptr Torrent::active_piece(uint32_t id, bool create) { - const std::lock_guard lock(m_mutex); + const std::scoped_lock lock(m_mutex); auto piece = m_active_pieces.find(id); if (piece == m_active_pieces.end()) { if (!create) { @@ -1172,7 +1179,7 @@ std::shared_ptr Torrent::active_piece(uint32_t id, bool create) { } bool Torrent::done() const { - const std::lock_guard lock(m_mutex); + const std::scoped_lock lock(m_mutex); // If we haven't started on all pieces we are not done if (m_active_pieces.size() != m_pieces.size()) { @@ -1204,7 +1211,7 @@ void Torrent::last_piece_written() { logger()->info("{} completed. Notifying peers and tracker.", m_name); { - scoped_lock lock(m_peers_mutex); + const scoped_lock lock(m_peers_mutex); for (auto& peer : m_peers) { if (!peer->is_listening()) { peer->set_am_interested(false); @@ -1216,7 +1223,7 @@ void Torrent::last_piece_written() { } void Torrent::piece_done(std::shared_ptr& piece) { - m_client_pieces[piece->id()] = true; + m_client_pieces.at(piece->id()) = true; ranges::for_each(m_piece_callbacks, [&](const auto& cb) { cb(this, piece); }); } diff --git a/src/torrent.hpp b/src/torrent.hpp index 34a2768..934c0ef 100644 --- a/src/torrent.hpp +++ b/src/torrent.hpp @@ -279,7 +279,7 @@ class Torrent { * m_client_pieces is also accessed by the file_writer, thus we need to lock. */ void init_client_pieces(bytes::size_type count) { - const std::lock_guard lock(m_mutex); + const std::scoped_lock lock(m_mutex); if (!m_client_pieces.size()) { m_client_pieces = Bitfield(count); } @@ -289,7 +289,7 @@ class Torrent { * The pieces that we have (on disk). */ [[nodiscard]] Bitfield client_pieces() const { - const std::lock_guard lock(m_mutex); + const std::scoped_lock lock(m_mutex); return m_client_pieces; } @@ -297,7 +297,7 @@ class Torrent { * Number of pieces on disk and in total. */ [[nodiscard]] auto piece_status() const { - const std::lock_guard lock(m_mutex); + const std::scoped_lock lock(m_mutex); return std::make_pair(m_client_pieces.count(), m_pieces.size()); } @@ -305,7 +305,7 @@ class Torrent { * Information about what pieces we don't have that a remote has. */ [[nodiscard]] Bitfield relevant_pieces(const Bitfield& remote_pieces) const { - const std::lock_guard lock(m_mutex); + const std::scoped_lock lock(m_mutex); return remote_pieces - m_client_pieces; } @@ -363,7 +363,12 @@ class Torrent { [[nodiscard]] std::tuple file_at_pos( int64_t pos) const; - enum class TrackerEvent { STARTED, STOPPED, COMPLETED, UNSPECIFIED }; + enum class TrackerEvent : std::uint8_t { + STARTED, + STOPPED, + COMPLETED, + UNSPECIFIED + }; /** * Find a torrent with a specific info hash among all existing torrents. @@ -456,40 +461,40 @@ class Torrent { asio::io_context& m_io_context; const Config& m_config; - std::string m_announce{}; - std::vector> m_announce_list{}; + std::string m_announce; + std::vector> m_announce_list; int64_t m_creation_date = 0; - std::string m_comment{}; - std::string m_created_by{}; - std::string m_encoding{}; + std::string m_comment; + std::string m_created_by; + std::string m_encoding; uint32_t m_piece_length = 0; - std::vector m_pieces{}; + std::vector m_pieces; bool m_private = false; - std::string m_name{}; + std::string m_name; int64_t m_length = 0; - std::string m_md5sum{}; - std::vector m_files{}; - Sha1 m_info_hash{}; + std::string m_md5sum; + std::vector m_files; + Sha1 m_info_hash; std::filesystem::path m_tmpfile{}; std::filesystem::path m_data_dir{}; std::filesystem::path m_torrent_file{}; - std::vector m_piece_callbacks{}; - DisconnectCallback m_disconnect_callback{}; - std::string m_peer_id{}; + std::vector m_piece_callbacks; + DisconnectCallback m_disconnect_callback; + std::string m_peer_id; ListeningPort m_listening_port; ConnectionPort m_connection_port; - std::mutex m_peers_mutex{}; - std::vector> m_peers{}; + std::mutex m_peers_mutex; + std::vector> m_peers; HttpGet m_http_get; asio::steady_timer m_retry_pieces_timer; asio::steady_timer m_retry_peers_timer; bool m_stopped = false; // Piece housekeeping - mutable std::mutex m_mutex{}; - Bitfield m_client_pieces{}; + mutable std::mutex m_mutex; + Bitfield m_client_pieces; /** Piece id -> Piece object */ - std::map> m_active_pieces{}; + std::map> m_active_pieces; // This is ok and a bug in clang-tidy // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/src/types.hpp b/src/types.hpp index 56595c4..ddf02a0 100644 --- a/src/types.hpp +++ b/src/types.hpp @@ -24,14 +24,14 @@ using ListeningPort = StrongType; using ConnectionPort = StrongType; // numeric_cast ( Copied from https://codereview.stackexchange.com/a/26496/39248 -// ) by user Matt Whitlock - functions renamed. +// ) by user Matt Whitlock - functions renamed and modified. // FIXME: These casts could fall back to no checking at all if I == J template -static constexpr - typename std::enable_if_t && std::is_signed_v, I> - numeric_cast(J value, const char* msg = nullptr) { +static constexpr I numeric_cast(J value, const char* msg = nullptr) + requires(std::is_signed_v && std::is_signed_v) +{ if (value < static_cast(std::numeric_limits::min()) || value > static_cast(std::numeric_limits::max())) { throw std::out_of_range(msg ? msg : "out of range"); @@ -40,21 +40,21 @@ static constexpr } template -static constexpr - typename std::enable_if_t && std::is_unsigned_v, I> - numeric_cast(J value, const char* msg = nullptr) { - if (value > static_cast>( - std::numeric_limits::max())) { +static constexpr I numeric_cast(J value, const char* msg = nullptr) + requires(std::is_signed_v && std::is_unsigned_v) +{ + if (value > + static_cast>(std::numeric_limits::max())) { throw std::out_of_range(msg ? msg : "out of range"); } return static_cast(value); } template -static constexpr - typename std::enable_if_t && std::is_signed_v, I> - numeric_cast(J value, const char* msg = nullptr) { - if (value < 0 || static_cast>(value) > +static constexpr I numeric_cast(J value, const char* msg = nullptr) + requires(std::is_unsigned_v && std::is_signed_v) +{ + if (value < 0 || static_cast>(value) > std::numeric_limits::max()) { throw std::out_of_range(msg ? msg : "out of range"); } @@ -62,9 +62,9 @@ static constexpr } template -static constexpr - typename std::enable_if_t && std::is_unsigned_v, I> - numeric_cast(J value, const char* msg = nullptr) { +static constexpr I numeric_cast(J value, const char* msg = nullptr) + requires(std::is_unsigned_v && std::is_unsigned_v) +{ if (value > std::numeric_limits::max()) { throw std::out_of_range(msg ? msg : "out of range"); } @@ -115,6 +115,8 @@ static inline T from_big_endian(const bytes& buf, bytes::size_type offset = 0) { "Target range outside of buffer: ({},{})", offset, buf.size())); } + // NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) + if constexpr (size == 2) { return static_cast(static_cast(buf[offset + 1]) << 0 | static_cast(buf[offset + 0]) << 8); @@ -135,6 +137,8 @@ static inline T from_big_endian(const bytes& buf, bytes::size_type offset = 0) { } else { static_assert(!sizeof(T*), "Unhandled size"); } + + // NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) } /** From 0e3863709047b860425013ae48d4d6b072528c6d Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Thu, 18 Dec 2025 12:37:13 +0100 Subject: [PATCH 03/26] Disable -Weffc++ better covered by clang-tidy and newer compilers now --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2c34ab4..bc3b81c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -66,7 +66,7 @@ endif() include_directories(SYSTEM "external") if(UNIX) - add_compile_options(-Wall -Wextra -Werror -Weffc++ -Wconversion -Wsign-conversion) + add_compile_options(-Wall -Wextra -Werror -Wconversion -Wsign-conversion) if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang") # Be strict and just disable unwanted or noisy warnings add_compile_options( From e03d146c324887a03e6c9d36734d5bd833059912 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Thu, 18 Dec 2025 12:37:53 +0100 Subject: [PATCH 04/26] Make both g++-14 and clang++-22 happy with nrvo --- src/torrent.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/torrent.cpp b/src/torrent.cpp index d04ccda..a2c086d 100644 --- a/src/torrent.cpp +++ b/src/torrent.cpp @@ -207,13 +207,13 @@ Torrent::Torrent(asio::io_context& io_context, const auto& announce_list = root_dict.at("announce-list")->to>()->val(); - transform_all(announce_list, m_announce_list, [](const auto& tier) { + transform_all(announce_list, m_announce_list, [](const auto& tier) -> vector { vector tier_output_list; transform_all(tier->template to>()->val(), tier_output_list, [](const auto& elm) { return elm->template to>()->val(); }); - return std::move(tier_output_list); + return tier_output_list; }); } @@ -873,13 +873,13 @@ std::vector> Torrent::tracker_request( // To avoid connecting to our own listening peer const auto is_local_peer = [&](const auto& peer) { const auto& url = peer->url(); - static const auto local_ips = [] { + static const auto local_ips = [] -> string_list { auto resolved = get_host_ip_addresses(); resolved.emplace_back("localhost"); // FIXME: This should not be hardcoded constexpr auto* docker_ip{"172.17.0.1"}; resolved.emplace_back(docker_ip); - return std::move(resolved); + return resolved; }(); return url && ranges::contains(local_ips, url->host()) && url->port().value_or(0) == m_listening_port.get(); From b643d982240f67a71346c31d925ad70d135fbbce Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Thu, 18 Dec 2025 12:39:14 +0100 Subject: [PATCH 05/26] Update ci to clang 22 and gcc 14 --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c53801c..3f53acf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,8 +5,8 @@ jobs: strategy: matrix: compiler: - - { os: ubuntu-24.04, bin: clang++-18 } - - { os: ubuntu-24.04, bin: g++-13 } + - { os: ubuntu-24.04, bin: clang++-22 } + - { os: ubuntu-24.04, bin: g++-14 } - { os: windows-2022, bin: cl.exe } runs-on: ${{ matrix.compiler.os }} @@ -30,7 +30,7 @@ jobs: - name: Apt install if: runner.os == 'Linux' - run: sudo apt install -y ninja-build clang-18 g++-12 + run: sudo apt install -y ninja-build clang-22 g++-14 - name: Configure CMake run: cmake -B ${{github.workspace}}/build -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} -GNinja -DDISABLE_LTO=ON -DUSE_CLANG_TIDY=OFF -DCMAKE_TOOLCHAIN_FILE=${{github.workspace}}/vcpkg/scripts/buildsystems/vcpkg.cmake From a331a96fc4a690576687111424788787e31dd9be Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Thu, 18 Dec 2025 12:42:08 +0100 Subject: [PATCH 06/26] Update build instructions Removed nix, it has not been maintained in a while. --- README.md | 8 +++---- default.nix | 68 ----------------------------------------------------- 2 files changed, 4 insertions(+), 72 deletions(-) delete mode 100644 default.nix diff --git a/README.md b/README.md index 5157393..ebb97be 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ # Build instructions +## Compilers + +Note that at the moment the tested compilers are clang 22 and gcc 14 on Linux. + ## CMake + vcpkg * `git submodule update --init --recursive` @@ -14,7 +18,3 @@ Or with --preset zit-gcc. Note that on Linux `pkg-config` and `clang` or `gcc` must be installed. - -## [Nix](https://nixos.org) - -* `nix-build` diff --git a/default.nix b/default.nix deleted file mode 100644 index 769da76..0000000 --- a/default.nix +++ /dev/null @@ -1,68 +0,0 @@ -{ pkgs ? import (fetchTarball { - url = - "https://github.com/NixOS/nixpkgs/archive/f02597fb8e07c57c969df77f39a63bda64b09848.tar.gz"; - sha256 = "sha256:1a76da1j10m60jhfn5xdi8fmy7c2a8lhijrimn62ysd990m795wf"; -}) { } }: - -let - - # Override asio to a custom version - asio = pkgs.asio.overrideAttrs (finalAttrs: previousAttrs: rec { - version = "1.24.0"; - src = pkgs.fetchurl { - url = "mirror://sourceforge/asio/asio-${version}.tar.bz2"; - sha256 = "iXaBLCShGGAPb88HGiBgZjCmmv5MCr7jsN6lKOaCxYU="; - }; - enableParallelBuilding = true; - }); - - openssl = pkgs.openssl_1_1; - -in pkgs.stdenv.mkDerivation rec { - pname = "zit"; - version = "0.2"; - - src = ./.; - - # Not strictly in sync with vcpkg.json, if that turns out to be a problem it can be synced then - buildInputs = - [ asio pkgs.cmake pkgs.gtest pkgs.ninja openssl.dev openssl.bin pkgs.spdlog pkgs.tbb pkgs.pkg-config ]; - - # Adding FindXXX.cmake for packages not providing CMake support - configurePhase = '' - echo ' - find_path(ASIO_INCLUDE_DIR NAMES asio.hpp PATHS ${asio}/include REQUIRED NO_DEFAULT_PATH) - message(STATUS "Found asio include: ''${ASIO_INCLUDE_DIR}") - set(ASIO_FOUND TRUE) - set(ASIO_INCLUDE_DIRS ''${ASIO_INCLUDE_DIR}) - add_library(asio::asio INTERFACE IMPORTED GLOBAL) - mark_as_advanced(ASIO_INCLUDE_DIR)' > Findasio.cmake - - echo ' - find_path(TBB_INCLUDE_DIR NAMES tbb PATHS ${pkgs.tbb}/include REQUIRED NO_DEFAULT_PATH) - message(STATUS "Found tbb include: ''${TBB_INCLUDE_DIR}") - find_library(TBB_LIBRARY NAMES tbb PATHS ${pkgs.tbb}/lib REQUIRED NO_DEFAULT_PATH) - message(STATUS "Found tbb lib: ''${TBB_LIBRARY}") - set(TBB_FOUND TRUE) - set(TBB_INCLUDE_DIRS ''${TBB_INCLUDE_DIR}) - set(TBB_LIBRARIES TBB::tbb) - add_library(TBB::tbb SHARED IMPORTED GLOBAL) - set_target_properties(TBB::tbb PROPERTIES - IMPORTED_LOCATION "''${TBB_LIBRARY}" - INTERFACE_INCLUDE_DIRECTORIES "''${TBB_INCLUDE_DIR}" - ) - mark_as_advanced(TBB_INCLUDE_DIR) - mark_as_advanced(TBB_LIBRARIES)' > FindTBB.cmake - - rm -vf CMakeCache.txt - cmake . -GNinja -DCMAKE_BUILD_TYPE=Release -DUSE_CLANG_TIDY=NO -DCMAKE_MODULE_PATH="$PWD" -DCMAKE_INSTALL_PREFIX="$out" - ''; - - buildPhase = '' - ninja - ''; - - installPhase = '' - ninja install - ''; -} From c2d1e1662fee3805702c6a29d09d8966d9ac4a93 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Thu, 18 Dec 2025 13:10:53 +0100 Subject: [PATCH 07/26] Fix windows compile errors --- tests/test_utils.hpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_utils.hpp b/tests/test_utils.hpp index 367d840..e701a05 100644 --- a/tests/test_utils.hpp +++ b/tests/test_utils.hpp @@ -72,7 +72,8 @@ inline std::string exec(const std::string& cmd) { std::array buffer; std::string result; while (!feof(pipe)) { - if (fgets(buffer.data(), buffer.size(), pipe) != nullptr) { + if (fgets(buffer.data(), zit::numeric_cast(buffer.size()), pipe) != + nullptr) { result += buffer.data(); } } From 25773d65606a1071b96ec9eaefcfa9b63809dd7b Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Thu, 18 Dec 2025 13:14:08 +0100 Subject: [PATCH 08/26] Update dependencies --- vcpkg.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vcpkg.json b/vcpkg.json index 3ea64dc..7f69152 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -9,19 +9,19 @@ "overrides": [ { "name": "openssl", - "version-string": "3.3.1" + "version-string": "3.6.0" }, { "name": "asio", - "version": "1.30.2" + "version": "1.32.0" }, { "name": "gtest", - "version": "1.15.0" + "version": "1.17.0" }, { "name": "tbb", - "version": "2021.13.0" + "version": "2022.2.0" }, { "name": "spdlog", From fdadb825bbdc7d6c3a60e305cde6fe8fd120fae6 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Fri, 19 Dec 2025 12:22:11 +0100 Subject: [PATCH 09/26] Add torrent writer support So we don't have to rely on external tools. For now only single file torrents but should be quite easy to add multi support later. --- src/CMakeLists.txt | 3 +- src/torrent_writer/CMakeLists.txt | 6 + src/torrent_writer/torrent_writer.cpp | 154 ++++++++++++++++++++++++++ tests/CMakeLists.txt | 5 + 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/torrent_writer/CMakeLists.txt create mode 100644 src/torrent_writer/torrent_writer.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8ea2f1e..3e53e27 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -123,4 +123,5 @@ if(NOT WIN32) endif() endif() -add_subdirectory(socket_test) \ No newline at end of file +add_subdirectory(socket_test) +add_subdirectory(torrent_writer) diff --git a/src/torrent_writer/CMakeLists.txt b/src/torrent_writer/CMakeLists.txt new file mode 100644 index 0000000..8884eb4 --- /dev/null +++ b/src/torrent_writer/CMakeLists.txt @@ -0,0 +1,6 @@ +add_executable(zit_torrent_writer torrent_writer.cpp) +target_link_libraries(zit_torrent_writer + spdlog::spdlog + zitlib +) +target_include_directories(zit_torrent_writer PRIVATE ${CMAKE_CURRENT_LIST_DIR}/..) \ No newline at end of file diff --git a/src/torrent_writer/torrent_writer.cpp b/src/torrent_writer/torrent_writer.cpp new file mode 100644 index 0000000..8b5e03b --- /dev/null +++ b/src/torrent_writer/torrent_writer.cpp @@ -0,0 +1,154 @@ +#include "arg_parser.hpp" + +#include "bencode.hpp" +#include "sha1.hpp" +#include "string_utils.hpp" +#include "strong_type.hpp" +#include "version.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using TorrentFile = StrongType; +using DataPath = StrongType; +using Comment = StrongType; +using AnnounceURL = StrongType; + +void WriteTorrent(const TorrentFile& torrent_file, + const DataPath& data_path, + const Comment& comment, + const AnnounceURL& announce_url, + const uint32_t piece_length) { + auto root = bencode::BeDict(); + root["announce"] = bencode::Element::build(announce_url.get()); + root["comment"] = bencode::Element::build(comment.get()); + root["creation date"] = bencode::Element::build(time(nullptr)); + root["created by"] = bencode::Element::build( + fmt::format("Zit v{}.{}.{}", zit::MAJOR_VERSION, zit::MINOR_VERSION, + zit::PATCH_VERSION)); + root["encoding"] = bencode::Element::build("UTF-8"); + + // info dict + auto info = bencode::BeDict(); + // single file + if (std::filesystem::is_regular_file(data_path.get())) { + const auto file_size = std::filesystem::file_size(data_path.get()); + const auto name_str = + std::filesystem::path(data_path.get()).filename().string(); + // Some clients include a UTF-8 variant of the filename in the info + // dictionary (commonly `name.utf-8`). This is not required by the + // core BitTorrent spec but is widely used by several clients to + // preserve UTF-8 metadata. Include it here to improve compatibility + // and to ensure generated torrents match existing ones that include + // the same field. + info["name"] = bencode::Element::build(name_str); + info["name.utf-8"] = bencode::Element::build(name_str); + info["length"] = bencode::Element::build(static_cast(file_size)); + // md5sum is optional - we skip it for now + } else if (std::filesystem::is_directory(data_path.get())) { + // Directory / multi-file mode not implemented yet. When/if implemented + // the file entries in the `files` list should include a `path.utf-8` + // variant mirroring the `path` list to maintain compatibility with + // torrents that include UTF-8 path variants. + throw std::runtime_error("Directory mode not yet implemented"); + } else { + throw std::runtime_error( + "Data path is neither an accessible file nor directory"); + } + + // common info fields + info["piece length"] = bencode::Element::build(piece_length); + // the "private" field is optional - we skip it for now + + // Read file piecewise and add sha1 per piece + std::ifstream file_stream(data_path.get(), std::ios::in | std::ios::binary); + if (!file_stream) { + throw std::runtime_error("Could not open data file for reading"); + } + auto file_range = + std::ranges::subrange(std::istreambuf_iterator(file_stream), + std::istreambuf_iterator()); + auto chunked_view = file_range | std::ranges::views::chunk(piece_length); + + std::stringstream pieces_ss; + for (auto&& chunk : chunked_view) { + auto data = std::ranges::to(chunk); + pieces_ss << zit::Sha1::calculateData(data).str(); + } + info["pieces"] = bencode::Element::build(pieces_ss.str()); + root["info"] = bencode::Element::build(info); + + const auto encoded = bencode::encode(root); + std::ofstream torrent_stream(torrent_file.get(), + std::ios::out | std::ios::binary); + if (!torrent_stream) { + throw std::runtime_error("Could not open torrent file for writing"); + } + torrent_stream << encoded; +} + +} // namespace + +int main(int argc, const char* argv[]) noexcept { + try { + zit::ArgParser parser("Zit - torrent client"); + parser.add_option("--help") + .aliases({"-h"}) + .help("Print help") + .help_arg(); + parser.add_option("--torrent") + .help("Torrent file to download or write") + .required(); + parser.add_option("--comment") + .help("Torrent file comment") + .default_value(""); + parser.add_option("--data") + .help("Directory or file with torrent content") + .required(); + parser.add_option("--announce") + .help( + "Announce URL for the torrent. Currently only single URL is " + "supported") + .required(); + constexpr uint32_t DEFAULT_PIECE_LENGTH{256 * 1024}; + parser.add_option("--piece-length") + .help("Piece length in bytes (default is " + + zit::bytesToHumanReadable(DEFAULT_PIECE_LENGTH) + ")") + .default_value(DEFAULT_PIECE_LENGTH); + const auto args = std::vector{argv, std::next(argv, argc)}; + parser.parse(args); + if (parser.get("--help")) { + std::cout << parser.usage(); + return 0; + } + + const auto torrent_file = parser.get("--torrent"); + const auto data_path = parser.get("--data"); + const auto comment = parser.get("--comment"); + const auto announce_url = parser.get("--announce"); + const auto piece_length = parser.get("--piece-length"); + + WriteTorrent(TorrentFile(torrent_file), DataPath(data_path), + Comment(comment), AnnounceURL(announce_url), piece_length); + + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } + + return 0; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 16b77e1..da4a7ca 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -32,6 +32,7 @@ add_executable(zitest test_global_config.cpp test_integrate.cpp test_torrent.cpp + test_torrent_writer.cpp test_main.cpp test_net.cpp test_peer.cpp @@ -82,6 +83,10 @@ endif() target_compile_definitions(zitest PUBLIC DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data") +# Path to the torrent writer executable for tests +add_dependencies(zitest zit_torrent_writer) +target_compile_definitions(zitest PUBLIC TORRENT_WRITER_EXE="$") + # For getting backtraces use: # sudo ln -s /usr/bin/llvm-symbolizer-3.8 /usr/bin/llvm-symbolizer # export ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer From e9769cd93f3ce705b8578d46369d69a094d338e1 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sat, 20 Dec 2025 15:01:22 +0100 Subject: [PATCH 10/26] Minor cleanup --- src/bencode.hpp | 2 +- src/file_writer.cpp | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bencode.hpp b/src/bencode.hpp index 26d7465..ac921e2 100644 --- a/src/bencode.hpp +++ b/src/bencode.hpp @@ -141,7 +141,7 @@ class Element : public std::enable_shared_from_this { virtual std::ostream& print(std::ostream& os) = 0; /** - * First try used T&& val, but it caoused storage of int references when we + * First try used T&& val, but it caused storage of int references when we * want such values copied. See https://stackoverflow.com/q/17316386/11722 and * https://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers * for details. diff --git a/src/file_writer.cpp b/src/file_writer.cpp index e69c64c..045ea45 100644 --- a/src/file_writer.cpp +++ b/src/file_writer.cpp @@ -45,7 +45,6 @@ void FileWriter::run() { logger()->info("FileWriter done"); } -// FIXME: Support for multi torrents bytes FileWriter::read_block(uint32_t offset, uint32_t length, const Torrent& torrent) { From 99f1a59cac2c3eb1bbc99ddd128f52e3d6ff330b Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sat, 20 Dec 2025 15:02:53 +0100 Subject: [PATCH 11/26] Add the torrent writer tests and multi support --- src/torrent_writer/torrent_writer.cpp | 154 +++++++++++++++++++----- tests/test_torrent_writer.cpp | 164 ++++++++++++++++++++++++++ 2 files changed, 290 insertions(+), 28 deletions(-) create mode 100644 tests/test_torrent_writer.cpp diff --git a/src/torrent_writer/torrent_writer.cpp b/src/torrent_writer/torrent_writer.cpp index 8b5e03b..957301c 100644 --- a/src/torrent_writer/torrent_writer.cpp +++ b/src/torrent_writer/torrent_writer.cpp @@ -1,6 +1,7 @@ #include "arg_parser.hpp" #include "bencode.hpp" +#include "logger.hpp" #include "sha1.hpp" #include "string_utils.hpp" #include "strong_type.hpp" @@ -13,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -28,11 +30,46 @@ using DataPath = StrongType; using Comment = StrongType; using AnnounceURL = StrongType; -void WriteTorrent(const TorrentFile& torrent_file, - const DataPath& data_path, - const Comment& comment, - const AnnounceURL& announce_url, - const uint32_t piece_length) { +namespace rv = std::ranges::views; + +/** + * Get chunks for the pieces. + * + * @param is Input stream + * @param n First chunk size + * @param m Subsequent chunk size + * @return std::generator> + */ +std::generator get_chunks(std::istream& is, size_t n, size_t m) { + const auto read_chunk = [&](size_t size) { + zit::bytes buf; + char c; + for (size_t i = 0; i < size && is.get(c); ++i) { + buf.push_back(static_cast(c)); + } + return buf; + }; + + // 1. Read the first chunk (size n) + if (auto first = read_chunk(n); !first.empty()) { + co_yield first; + } + + // 2. Read subsequent chunks (size m) + while (is) { + if (auto next = read_chunk(m); !next.empty()) { + co_yield next; + } else { + break; + } + } +} + +void write_torrent(const TorrentFile& torrent_file, + const DataPath& data_path, + const Comment& comment, + const AnnounceURL& announce_url, + const uint32_t piece_length) { auto root = bencode::BeDict(); root["announce"] = bencode::Element::build(announce_url.get()); root["comment"] = bencode::Element::build(comment.get()); @@ -44,9 +81,12 @@ void WriteTorrent(const TorrentFile& torrent_file, // info dict auto info = bencode::BeDict(); + std::stringstream pieces_ss; // single file if (std::filesystem::is_regular_file(data_path.get())) { const auto file_size = std::filesystem::file_size(data_path.get()); + zit::logger()->debug("Adding file: {} ({} bytes)", data_path.get(), + file_size); const auto name_str = std::filesystem::path(data_path.get()).filename().string(); // Some clients include a UTF-8 variant of the filename in the info @@ -59,12 +99,86 @@ void WriteTorrent(const TorrentFile& torrent_file, info["name.utf-8"] = bencode::Element::build(name_str); info["length"] = bencode::Element::build(static_cast(file_size)); // md5sum is optional - we skip it for now + + // Read file piecewise and add sha1 per piece + std::ifstream file_stream(data_path.get(), std::ios::in | std::ios::binary); + if (!file_stream) { + throw std::runtime_error("Could not open data file for reading"); + } + auto file_range = + std::ranges::subrange(std::istreambuf_iterator(file_stream), + std::istreambuf_iterator()); + auto chunked_view = file_range | rv::chunk(piece_length); + + for (auto&& chunk : chunked_view) { + auto data = std::ranges::to(chunk); + pieces_ss << zit::Sha1::calculateData(data).str(); + } + } else if (std::filesystem::is_directory(data_path.get())) { - // Directory / multi-file mode not implemented yet. When/if implemented - // the file entries in the `files` list should include a `path.utf-8` - // variant mirroring the `path` list to maintain compatibility with - // torrents that include UTF-8 path variants. - throw std::runtime_error("Directory mode not yet implemented"); + // multi file + const auto base_name = + std::filesystem::path(data_path.get()).filename().string(); + info["name"] = bencode::Element::build(base_name); + info["name.utf-8"] = bencode::Element::build(base_name); + + auto files = std::vector{}; + zit::bytes piece_data; + for (const auto& entry : + std::filesystem::recursive_directory_iterator(data_path.get())) { + if (std::filesystem::is_regular_file(entry.path())) { + const auto file_size = std::filesystem::file_size(entry.path()); + zit::logger()->debug("Adding file: {} ({} bytes)", + entry.path().string(), file_size); + + // build path list relative to data_path + std::vector path_list; + for (const auto& part : + std::filesystem::relative(entry.path(), data_path.get())) { + path_list.emplace_back(bencode::Element::build(part.string())); + } + + auto file_dict = bencode::BeDict(); + file_dict["length"] = + bencode::Element::build(static_cast(file_size)); + file_dict["path"] = bencode::Element::build(path_list); + file_dict["path.utf-8"] = bencode::Element::build(path_list); + // md5sum is optional - we skip it for now + + files.emplace_back(bencode::Element::build(file_dict)); + + // Now read data for sha1 pieces + + std::ifstream file_stream(entry.path(), + std::ios::in | std::ios::binary); + if (!file_stream) { + throw std::runtime_error("Could not open data file for reading"); + } + + auto file_range = + std::ranges::subrange(std::istreambuf_iterator(file_stream), + std::istreambuf_iterator()); + + auto chunk_generator = get_chunks( + file_stream, piece_length - piece_data.size(), piece_length); + for (auto&& chunk : chunk_generator) { + piece_data.insert(piece_data.end(), chunk.begin(), chunk.end()); + if (piece_data.size() == piece_length) { + pieces_ss << zit::Sha1::calculateData(piece_data).str(); + piece_data.clear(); + } + } + } + } + + // Handle any remaining piece data + if (!piece_data.empty()) { + pieces_ss << zit::Sha1::calculateData(piece_data).str(); + piece_data.clear(); + } + + info["files"] = bencode::Element::build(files); + } else { throw std::runtime_error( "Data path is neither an accessible file nor directory"); @@ -73,22 +187,6 @@ void WriteTorrent(const TorrentFile& torrent_file, // common info fields info["piece length"] = bencode::Element::build(piece_length); // the "private" field is optional - we skip it for now - - // Read file piecewise and add sha1 per piece - std::ifstream file_stream(data_path.get(), std::ios::in | std::ios::binary); - if (!file_stream) { - throw std::runtime_error("Could not open data file for reading"); - } - auto file_range = - std::ranges::subrange(std::istreambuf_iterator(file_stream), - std::istreambuf_iterator()); - auto chunked_view = file_range | std::ranges::views::chunk(piece_length); - - std::stringstream pieces_ss; - for (auto&& chunk : chunked_view) { - auto data = std::ranges::to(chunk); - pieces_ss << zit::Sha1::calculateData(data).str(); - } info["pieces"] = bencode::Element::build(pieces_ss.str()); root["info"] = bencode::Element::build(info); @@ -142,8 +240,8 @@ int main(int argc, const char* argv[]) noexcept { const auto announce_url = parser.get("--announce"); const auto piece_length = parser.get("--piece-length"); - WriteTorrent(TorrentFile(torrent_file), DataPath(data_path), - Comment(comment), AnnounceURL(announce_url), piece_length); + write_torrent(TorrentFile(torrent_file), DataPath(data_path), + Comment(comment), AnnounceURL(announce_url), piece_length); } catch (const std::exception& e) { std::cerr << "Error: " << e.what() << "\n"; diff --git a/tests/test_torrent_writer.cpp b/tests/test_torrent_writer.cpp new file mode 100644 index 0000000..42763e5 --- /dev/null +++ b/tests/test_torrent_writer.cpp @@ -0,0 +1,164 @@ +#include + +#include +#include +#include + +#include "test_utils.hpp" +#include "torrent.hpp" + +namespace fs = std::filesystem; + +// Path to the writer executable provided by CMake as a compile definition. +// Example: "C:/path/to/build/src/torrent_writer/zit_torrent_writer" +#ifndef TORRENT_WRITER_EXE +#error "TORRENT_WRITER_EXE not defined" +#endif + +using TorrentWriter = TestWithTmpDir; + +TEST_F(TorrentWriter, GeneratedInfoHashEqualsReferenceSingle) { + const auto data_dir = fs::path(DATA_DIR); + const auto data_file = data_dir / "1MiB.dat"; + const auto out_torrent = tmp_dir() / "test_1MiB.torrent"; + + // Run the writer executable to create a torrent for 1MiB.dat + const std::string cmd = + std::string(TORRENT_WRITER_EXE) + " --torrent " + + out_torrent.generic_string() + " --data " + data_file.generic_string() + + " --comment '' --announce http://example/announce --piece-length 32768"; + + const int rc = std::system(cmd.c_str()); + ASSERT_EQ(rc, 0) << "torrent writer failed: " << cmd; + + // Read generated torrent and reference torrent using existing Torrent class. + // Construct each Torrent in its own scope so they are not live at the same + // time (the Torrent constructor registers the torrent by info_hash). + + zit::Sha1 gen_hash; + std::vector gen_pieces; + uint32_t gen_piece_length = 0; + int64_t gen_length = 0; + std::string gen_name; + + { + asio::io_context io1; + zit::Torrent generated(io1, out_torrent); + gen_hash = generated.info_hash(); + gen_pieces = generated.pieces(); + gen_piece_length = generated.piece_length(); + gen_length = generated.length(); + gen_name = generated.name(); + } + + zit::Sha1 ref_hash; + std::vector ref_pieces; + uint32_t ref_piece_length = 0; + int64_t ref_length = 0; + std::string ref_name; + + { + asio::io_context io2; + zit::Torrent reference(io2, data_dir / "1MiB.torrent"); + ref_hash = reference.info_hash(); + ref_pieces = reference.pieces(); + ref_piece_length = reference.piece_length(); + ref_length = reference.length(); + ref_name = reference.name(); + } + + EXPECT_EQ(gen_name, ref_name); + EXPECT_EQ(gen_length, ref_length); + EXPECT_EQ(gen_pieces.size(), ref_pieces.size()); + EXPECT_EQ(gen_piece_length, ref_piece_length); + EXPECT_EQ(gen_hash.hex(), ref_hash.hex()); + + // Compare individual pieces + for (size_t i = 0; i < gen_pieces.size(); ++i) { + EXPECT_EQ(gen_pieces[i].hex(), ref_pieces[i].hex()) << "Piece index " << i; + } +} + +TEST_F(TorrentWriter, GeneratedInfoHashEqualsReferenceMulti) { + const auto data_dir = fs::path(DATA_DIR); + const auto multi_dir = data_dir / "multi"; + const auto out_torrent = tmp_dir() / "test_multi.torrent"; + + // Run the writer executable to create a torrent for 1MiB.dat + const std::string cmd = + std::string(TORRENT_WRITER_EXE) + " --torrent " + + out_torrent.generic_string() + " --data " + multi_dir.generic_string() + + " --comment '' --announce http://example/announce --piece-length 262144"; + + const int rc = std::system(cmd.c_str()); + ASSERT_EQ(rc, 0) << "torrent writer failed: " << cmd; + + // Read generated torrent and reference torrent using existing Torrent class. + // Construct each Torrent in its own scope so they are not live at the same + // time (the Torrent constructor registers the torrent by info_hash). + + zit::Sha1 gen_hash; + std::vector gen_pieces; + uint32_t gen_piece_length = 0; + int64_t gen_length = 0; + std::string gen_name; + std::vector gen_files; + + { + asio::io_context io1; + zit::Torrent generated(io1, out_torrent); + gen_hash = generated.info_hash(); + gen_pieces = generated.pieces(); + gen_piece_length = generated.piece_length(); + gen_length = generated.length(); + gen_name = generated.name(); + gen_files = generated.files(); + } + + zit::Sha1 ref_hash; + std::vector ref_pieces; + uint32_t ref_piece_length = 0; + int64_t ref_length = 0; + std::string ref_name; + std::vector ref_files; + + { + asio::io_context io2; + zit::Torrent reference(io2, data_dir / "multi.torrent"); + ref_hash = reference.info_hash(); + ref_pieces = reference.pieces(); + ref_piece_length = reference.piece_length(); + ref_length = reference.length(); + ref_name = reference.name(); + ref_files = reference.files(); + } + + EXPECT_EQ(gen_name, ref_name); + EXPECT_EQ(gen_length, ref_length); + EXPECT_EQ(gen_pieces.size(), ref_pieces.size()); + EXPECT_EQ(gen_piece_length, ref_piece_length); + + // Compare file lists for both torrents + const auto path_order = [](const zit::FileInfo& a, const zit::FileInfo& b) { + return a.path() < b.path(); + }; + std::sort(gen_files.begin(), gen_files.end(), path_order); + std::sort(ref_files.begin(), ref_files.end(), path_order); + EXPECT_EQ(gen_files.size(), ref_files.size()); + for (size_t i = 0; i < gen_files.size(); ++i) { + EXPECT_EQ(gen_files[i].path(), ref_files[i].path()); + EXPECT_EQ(gen_files[i].length(), ref_files[i].length()); + } + + // The current reference was generated with a different file order and is thus + // not hash comparable. + // TODO: Generate a reference with a fixed order and + // enusure the writer uses the same order. + /* + EXPECT_EQ(gen_hash.hex(), ref_hash.hex()); + // Compare individual pieces + for (size_t i = 0; i < gen_pieces.size(); ++i) { + EXPECT_EQ(gen_pieces[i].hex(), ref_pieces[i].hex()) << "Piece index " << i; + } + */ +} From 6ff6c478691579d4400f6a30f1dc9ad7958e0e4f Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sat, 20 Dec 2025 22:30:21 +0100 Subject: [PATCH 12/26] Fix use after free Shouldn't start listen() in the constructor. --- src/peer.cpp | 6 ++++++ src/peer.hpp | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/peer.cpp b/src/peer.cpp index ae4ec74..0279028 100644 --- a/src/peer.cpp +++ b/src/peer.cpp @@ -251,6 +251,12 @@ PeerAcceptor::PeerAcceptor(ListeningPort port, m_io_context(io_context), m_bind_address(std::move(bind_address)) { logger()->trace(PRETTY_FUNCTION); + // Do not start the coroutine here: it must be started after the object is + // fully emplaced into the container that will own its lifetime. Call + // `start()` to begin the accept loop once the object is stored. +} + +void PeerAcceptor::start() { // For now rethrowing - can consider asio::detached later? // NOLINTNEXTLINE(misc-include-cleaner) co_spawn(m_io_context, listen(), diff --git a/src/peer.hpp b/src/peer.hpp index 0032c00..27fbbcc 100644 --- a/src/peer.hpp +++ b/src/peer.hpp @@ -88,13 +88,19 @@ class PeerAcceptor { asio::io_context& io_context, std::string bind_address); + void start(); + static void acceptOnPort(asio::io_context& io_context, ListeningPort port, const std::string& bind_address) { // FIXME: Add check that already existing port use the same io_context/bind // FIXME: Since we are using the io_context of the torrent, it should also // be removed/replaced when the torrent is stopped. - m_acceptors.try_emplace(port, port, io_context, bind_address); + auto [it, inserted] = + m_acceptors.try_emplace(port, port, io_context, bind_address); + if (inserted) { + it->second.start(); + } } private: From 22424f8c299bb562553e754aff1eeb48cb69d351 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sun, 21 Dec 2025 17:18:01 +0100 Subject: [PATCH 13/26] Fix various issues with the integration tests * No hardcoded IP * Upload tests fixed * Docker containers fixed * Fixed various related bugs --- src/peer.cpp | 24 ++- src/peer.hpp | 16 +- src/torrent.cpp | 31 ++- src/torrent.hpp | 7 + tests/data/opentracker.whitelist | 1 + tests/docker/opentracker.dockerfile | 2 +- tests/docker/opentracker_entrypoint.sh | 14 +- tests/docker/transmission.dockerfile | 15 +- tests/docker/webtorrent.dockerfile | 9 +- tests/docker/webtorrent_entrypoint.sh | 14 +- tests/test_integrate.cpp | 274 +++++++++++++++++++------ 11 files changed, 311 insertions(+), 96 deletions(-) diff --git a/src/peer.cpp b/src/peer.cpp index 0279028..5b0ed19 100644 --- a/src/peer.cpp +++ b/src/peer.cpp @@ -96,6 +96,20 @@ void PeerConnection::write(const optional& url, const std::string& msg) { } m_msg = msg; + // If socket is already open and connected (accepted incoming connection), + // use it directly without attempting to resolve/connect + if (socket_ && socket_->is_open() && !m_connected) { + // Check if socket is actually connected (has remote endpoint) + asio::error_code ec; + socket_->remote_endpoint(ec); + if (!ec) { + // Socket is connected - this is an accepted incoming connection + m_connected = true; + send(true); + return; + } + } + if (m_connected || endpoint_ != tcp::resolver::iterator()) { // We have already resolved and connected handle_connect(asio::error_code(), endpoint_); @@ -314,8 +328,14 @@ asio::awaitable PeerAcceptor::listen() { return port; //} }(); - torrent->add_peer(make_shared( - Url{fmt::format("http://{}:{}", ip, accept_port)}, *torrent)); + auto peer = make_shared( + std::make_unique(std::move(socket)), + Url{fmt::format("http://{}:{}", ip, accept_port)}, *torrent); + if (torrent->add_peer(peer)) { + // Successfully added - send our bitfield + // Remote will send INTERESTED if they want our pieces + peer->report_bitfield(); + } } catch (const std::system_error& error) { logger()->error("Listen errored: {}", error.what()); } diff --git a/src/peer.hpp b/src/peer.hpp index 27fbbcc..ac1510a 100644 --- a/src/peer.hpp +++ b/src/peer.hpp @@ -120,11 +120,25 @@ class PeerAcceptor { */ class Peer : public IConnectionUrlProvider { public: - explicit Peer(Url url, Torrent& torrent) + /** + * Construct a new Peer object + */ + Peer(Url url, Torrent& torrent) : m_url(std::move(url)), m_torrent(torrent), m_last_activity(std::chrono::system_clock::now()) {} + /** + * Constructor for incoming connections with an already-accepted socket + */ + Peer(socket_ptr socket, Url url, Torrent& torrent) + : m_url(std::move(url)), + m_listening(true), + m_torrent(torrent), + m_last_activity(std::chrono::system_clock::now()) { + init_io_service(std::move(socket)); + } + /** * Return the url of this peer. */ diff --git a/src/torrent.cpp b/src/torrent.cpp index a2c086d..683f937 100644 --- a/src/torrent.cpp +++ b/src/torrent.cpp @@ -207,14 +207,16 @@ Torrent::Torrent(asio::io_context& io_context, const auto& announce_list = root_dict.at("announce-list")->to>()->val(); - transform_all(announce_list, m_announce_list, [](const auto& tier) -> vector { - vector tier_output_list; - transform_all(tier->template to>()->val(), - tier_output_list, [](const auto& elm) { - return elm->template to>()->val(); - }); - return tier_output_list; - }); + transform_all( + announce_list, m_announce_list, [](const auto& tier) -> vector { + vector tier_output_list; + transform_all( + tier->template to>()->val(), + tier_output_list, [](const auto& elm) { + return elm->template to>()->val(); + }); + return tier_output_list; + }); } m_info_hash = Sha1::calculateData(encode(info)); @@ -416,6 +418,12 @@ void Torrent::disconnected(Peer* peer) { } } +void Torrent::not_interested(Peer* peer) { + if (m_not_interested_callback) { + m_not_interested_callback(peer); + } +} + std::ostream& operator<<(std::ostream& os, const Torrent::TrackerEvent& te) { switch (te) { case Torrent::TrackerEvent::STARTED: @@ -947,7 +955,12 @@ void Torrent::schedule_retry_peers() { void Torrent::run() { logger()->debug("Run loop start"); const auto is_stopped = [](auto& p) { return p->io_service().stopped(); }; - while (!m_stopped && (!done() || !ranges::all_of(m_peers, is_stopped))) { + // Keep running while not explicitly stopped AND either: + // - we're still downloading (!done()), OR + // - we have active peers, OR + // - the io_context has pending work (timers, acceptor, etc.) + while (!m_stopped && (!done() || !ranges::all_of(m_peers, is_stopped) || + !m_io_context.stopped())) { std::size_t ran = 0; { const scoped_lock lock(m_peers_mutex); diff --git a/src/torrent.hpp b/src/torrent.hpp index 934c0ef..3f2be7b 100644 --- a/src/torrent.hpp +++ b/src/torrent.hpp @@ -21,6 +21,7 @@ using PieceCallback = std::function&)>; using DisconnectCallback = std::function; +using NotInterestedCallback = std::function; /** * Function taking an Url + bind address and returning the resulting @@ -254,7 +255,12 @@ class Torrent { m_disconnect_callback = std::move(disconnect_callback); } + void set_not_interested_callback(NotInterestedCallback not_interested_callback) { + m_not_interested_callback = std::move(not_interested_callback); + } + void disconnected(Peer* peer); + void not_interested(Peer* peer); /** * The first request to the tracker. After calling this the peer list @@ -480,6 +486,7 @@ class Torrent { std::filesystem::path m_torrent_file{}; std::vector m_piece_callbacks; DisconnectCallback m_disconnect_callback; + NotInterestedCallback m_not_interested_callback; std::string m_peer_id; ListeningPort m_listening_port; ConnectionPort m_connection_port; diff --git a/tests/data/opentracker.whitelist b/tests/data/opentracker.whitelist index fddd134..922a6ef 100644 --- a/tests/data/opentracker.whitelist +++ b/tests/data/opentracker.whitelist @@ -1,2 +1,3 @@ F23537B079181D186B71487DF7A9677D0DCF3CC8 274EF6264F05A63BAAAF8532AA33BFE74F799B69 +FB0DD799BAC111630421AC87E074563D4704F18C diff --git a/tests/docker/opentracker.dockerfile b/tests/docker/opentracker.dockerfile index 99e3c4d..0b3b9ca 100644 --- a/tests/docker/opentracker.dockerfile +++ b/tests/docker/opentracker.dockerfile @@ -1,5 +1,5 @@ # Use fixed ubuntu image for reproducibility -FROM ubuntu:24.10 +FROM ubuntu:24.04 # Install necessary packages RUN apt-get update && apt-get install -y \ diff --git a/tests/docker/opentracker_entrypoint.sh b/tests/docker/opentracker_entrypoint.sh index a48d483..e53a150 100644 --- a/tests/docker/opentracker_entrypoint.sh +++ b/tests/docker/opentracker_entrypoint.sh @@ -1,14 +1,18 @@ #!/bin/sh set -e -# Resolve the host IP address -HOST_IP=$(getent ahostsv4 host.docker.internal | grep 'STREAM' | awk '{ print $1 }' | head -n 1) -#HOST_IP=192.168.0.18 +HOST_IP="${HOST_IP:-}" + +if [ -z "$HOST_IP" ]; then + echo "ERROR: HOST_IP not set." >&2 + echo "You can set HOST_IP env or run container with --add-host=host.docker.internal:host-gateway" >&2 + exit 1 +fi # Set up iptables rule to route traffic to host.docker.internal -# This is necessary because opentracker uses will use the Docker gateway IP +# This is necessary because opentracker will use the Docker gateway IP # for the peer list which will not work from within the container -iptables -t nat -A OUTPUT -d 172.17.0.1 -j DNAT --to-destination "$HOST_IP" +/usr/sbin/iptables -t nat -A OUTPUT -d 172.17.0.1 -j DNAT --to-destination "$HOST_IP" # Run opentracker with the provided arguments exec opentracker "$@" diff --git a/tests/docker/transmission.dockerfile b/tests/docker/transmission.dockerfile index 4e6bfe5..3cd1301 100644 --- a/tests/docker/transmission.dockerfile +++ b/tests/docker/transmission.dockerfile @@ -1,5 +1,5 @@ # Use fixed ubuntu image for reproducibility -FROM ubuntu:24.10 +FROM ubuntu:24.04 # Install necessary packages RUN apt-get update && apt-get install -y \ @@ -7,11 +7,10 @@ RUN apt-get update && apt-get install -y \ # https://github.com/transmission/transmission/blob/main/docs/Building-Transmission.md#on-unix build-essential cmake git libcurl4-openssl-dev libssl-dev \ # Additional packages for the build process added by me: - ninja-build \ - && rm -rf /var/lib/apt/lists/* - -# Clone the Transmission repository -RUN git clone --recurse-submodules --branch 4.0.6 https://github.com/transmission/transmission.git /opt/transmission + ninja-build python-is-python3 \ + && rm -rf /var/lib/apt/lists/* && \ + # Clone the Transmission repository + git clone --recurse-submodules --branch 4.0.6 https://github.com/transmission/transmission.git /opt/transmission # Set the working directory WORKDIR /opt/transmission @@ -24,8 +23,8 @@ RUN cmake -B build -G Ninja \ -DENABLE_CLI=ON \ -DENABLE_TESTS=OFF \ -DINSTALL_DOC=OFF \ - -DCMAKE_BUILD_TYPE=Release -RUN cmake --build build --target install + -DCMAKE_BUILD_TYPE=Release && \ + cmake --build build --target install # Expose the necessary ports EXPOSE 51413 diff --git a/tests/docker/webtorrent.dockerfile b/tests/docker/webtorrent.dockerfile index d18a493..f1e3d82 100644 --- a/tests/docker/webtorrent.dockerfile +++ b/tests/docker/webtorrent.dockerfile @@ -1,11 +1,10 @@ -FROM ubuntu:24.10 +FROM ubuntu:24.04 # Install nodejs and npm RUN apt-get update && \ - apt-get install -y npm iptables && \ - rm -rf /var/lib/apt/lists/* - -RUN npm install webtorrent-cli -g + apt-get install -y npm iptables iproute2 && \ + rm -rf /var/lib/apt/lists/* && \ + npm install webtorrent-cli -g COPY webtorrent_entrypoint.sh /usr/local/bin/webtorrent_entrypoint.sh RUN chmod +x /usr/local/bin/webtorrent_entrypoint.sh diff --git a/tests/docker/webtorrent_entrypoint.sh b/tests/docker/webtorrent_entrypoint.sh index 78e6ecf..69cabb5 100644 --- a/tests/docker/webtorrent_entrypoint.sh +++ b/tests/docker/webtorrent_entrypoint.sh @@ -1,14 +1,18 @@ #!/bin/sh set -e -# Resolve the host IP address -HOST_IP=$(getent ahostsv4 host.docker.internal | grep 'STREAM' | awk '{ print $1 }' | head -n 1) -#HOST_IP=192.168.0.18 +HOST_IP="${HOST_IP:-}" + +if [ -z "$HOST_IP" ]; then + echo "ERROR: HOST_IP not set." >&2 + echo "You can set HOST_IP env or run container with --add-host=host.docker.internal:host-gateway" >&2 + exit 1 +fi # Set up iptables rule to route traffic to host.docker.internal -# This is necessary because opentracker uses will use the Docker gateway IP +# This is necessary because opentracker will use the Docker gateway IP # for the peer list which will not work from within the container -iptables -t nat -A OUTPUT -d 172.17.0.1 -j DNAT --to-destination "$HOST_IP" +/usr/sbin/iptables -t nat -A OUTPUT -d 172.17.0.1 -j DNAT --to-destination "$HOST_IP" # Run opentracker with the provided arguments exec webtorrent "$@" diff --git a/tests/test_integrate.cpp b/tests/test_integrate.cpp index 9bb0b48..333bc9e 100644 --- a/tests/test_integrate.cpp +++ b/tests/test_integrate.cpp @@ -1,6 +1,13 @@ /** * Currently using linux programs for integration testing * but inside docker containers. + * + * sudo apt install docker.io + * sudo usermod -aG docker $USER + * newgrp docker + * + * cd tests/docker + * ./build.sh */ /** @@ -12,6 +19,8 @@ * - sudo apt install transmission-cli */ +#include +#include #include #include #include @@ -49,6 +58,51 @@ class TestConfig : public zit::Config { namespace { +std::string get_host_ip_from_host() { + if (const char* env = getenv("HOST_IP")) { + if (strlen(env) > 0) + return std::string(env); + } + // Try to obtain the host IP used for outbound traffic (robust on Linux) + FILE* f = popen( + "ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) " + "if($i==\"src\") {print $(i+1); exit}}'", + "r"); + if (f) { + char buf[128]; + if (fgets(buf, sizeof(buf), f)) { + pclose(f); + // strip newline + std::string s(buf); + if (!s.empty() && s.back() == '\n') + s.pop_back(); + if (!s.empty()) + return s; + } else { + pclose(f); + } + } + // Try docker bridge gateway as a fallback + f = popen( + "docker network inspect bridge --format '{{(index .IPAM.Config " + "0).Gateway}}' 2>/dev/null", + "r"); + if (f) { + char buf[128]; + if (fgets(buf, sizeof(buf), f)) { + pclose(f); + std::string s(buf); + if (!s.empty() && s.back() == '\n') + s.pop_back(); + if (!s.empty()) + return s; + } else { + pclose(f); + } + } + throw std::runtime_error("Failed to determine host IP address"); +} + [[maybe_unused]] fs::path home_dir() { const auto home = getenv("HOME"); if (!home || strlen(home) < 2) { @@ -57,21 +111,61 @@ namespace { return {home}; } +// Generate a .torrent file from data using the test torrent writer +// If `TORRENT_WRITER_EXE` is not available, fall back to existing torrent. +fs::path generate_torrent_with_announce(const fs::path& data, + const std::string& announce, + const fs::path& out_dir) { +#ifdef TORRENT_WRITER_EXE + const auto out_torrent = + out_dir / ("gen_" + data.filename().string() + ".torrent"); + const std::string cmd = std::string(TORRENT_WRITER_EXE) + " --torrent " + + out_torrent.generic_string() + " --data " + + data.generic_string() + + " --comment 'Generated for testing' --announce " + + announce + " --piece-length 32768"; + // Use exec() from test_utils.hpp which throws on non-zero exit + exec(cmd); + return out_torrent; +#else + (void)announce; + (void)out_dir; + return data_dir / "1MiB.torrent"; +#endif +} + auto start_tracker(const fs::path& data_dir) { + // Ensure any previous tracker container is gone to avoid name conflicts + try { + exec("docker rm -f zit-opentracker > /dev/null 2>&1 || true"); + } catch (const std::exception&) { + // Ignore cleanup errors + } + + std::vector args = { + "docker", "run", "--rm", "--name", "zit-opentracker", "--volume", + fmt::format("{}:{}", data_dir.generic_string(), "/data"), + // Needed to be able to run iptables in the container + "--cap-add", "NET_ADMIN", + // + "--publish", "8000:8000"}; + // Add host mapping + const auto host_ip = get_host_ip_from_host(); + if (!host_ip.empty()) { + args.push_back("--add-host"); + args.push_back(fmt::format("host.docker.internal:{}", host_ip)); + } else { + args.push_back("--add-host"); + args.push_back("host.docker.internal:host-gateway"); + } + args.push_back("opentracker"); + args.push_back("-p"); + args.push_back("8000"); + args.push_back("-w"); + args.push_back("/data/opentracker.whitelist"); + auto tracker = - Process("tracker", - {"docker", "run", "--rm", "--name", "zit-opentracker", "--volume", - fmt::format("{}:{}", data_dir.generic_string(), "/data"), - // Needed to be able to run iptables in the container - "--cap-add", "NET_ADMIN", - // - "--publish", "8000:8000", "opentracker", "-p", "8000", - // The default opentracker build seem to be compiled in closed - // mode such that all torrent must be explicitly listed in a - // whitelist. It would be possible to recompile it without that - // but for now lets just use it like this. - "-w", "/data/opentracker.whitelist"}, - nullptr, {"docker", "stop", "zit-opentracker"}); + Process("tracker", args, nullptr, {"docker", "stop", "zit-opentracker"}); // Allow some time for the tracker to start this_thread::sleep_for(1s); @@ -91,30 +185,63 @@ auto start_seeder(const fs::path& data_dir, static int seeder_count{0}; const auto container_name = fmt::format("zit-webtorrent-{}", seeder_count++); - const auto port = std::to_string(webtorrent_seeding_port); - return Process( - name, - {"docker", "run", "--rm", "--name", container_name, "--publish", - fmt::format("{}:{}", port, port), "--volume", - fmt::format("{}:{}", data_dir.generic_string(), container_data_dir), - "--volume", - fmt::format("{}:{}", torrent_file.parent_path().generic_string(), - container_torrent_dir), - // Needed to be able to run iptables in the container - "--cap-add", "NET_ADMIN", - // For verbose output - "--env", - fmt::format("DEBUG={}", - logger()->should_log(spdlog::level::debug) ? "*" : ""), - // - "webtorrent", "seed", - // Torrent file - fmt::format("{}/{}", container_torrent_dir, torrent_file.filename()), - "--out", container_data_dir, "--torrent-port", - std::to_string(webtorrent_seeding_port++), "--port", - std::to_string(webtorrent_http_port++), "--keep-seeding"}, - nullptr, {"docker", "stop", container_name}); + + // Ensure any previous tracker container is gone to avoid name conflicts + try { + exec(fmt::format("docker rm -f {} > /dev/null 2>&1 || true", + container_name)); + } catch (const std::exception&) { + // Ignore cleanup errors + } + + std::vector args = { + "docker", + "run", + "--rm", + "--name", + container_name, + "--publish", + fmt::format("{}:{}", port, port), + "--volume", + fmt::format("{}:{}", data_dir.generic_string(), container_data_dir), + "--volume", + fmt::format("{}:{}", torrent_file.parent_path().generic_string(), + container_torrent_dir), + // Needed to be able to run iptables in the container + "--cap-add", + "NET_ADMIN", + // For verbose output + //"--env", + // fmt::format("DEBUG={}", + // logger()->should_log(spdlog::level::debug) ? "*" : "") + }; + + // Add host mapping so container can resolve host.docker.internal to host LAN + // IP + const auto host_ip = get_host_ip_from_host(); + if (!host_ip.empty()) { + args.push_back("--add-host"); + args.push_back(fmt::format("host.docker.internal:{}", host_ip)); + } else { + args.push_back("--add-host"); + args.push_back("host.docker.internal:host-gateway"); + } + + // Command and arguments for webtorrent + args.push_back("webtorrent"); + args.push_back("seed"); + args.push_back( + fmt::format("{}/{}", container_torrent_dir, torrent_file.filename())); + args.push_back("--out"); + args.push_back(container_data_dir); + args.push_back("--torrent-port"); + args.push_back(std::to_string(webtorrent_seeding_port++)); + args.push_back("--port"); + args.push_back(std::to_string(webtorrent_http_port++)); + args.push_back("--keep-seeding"); + + return Process(name, args, nullptr, {"docker", "stop", container_name}); } auto start_leecher(const fs::path& target, const fs::path& torrent_file) { @@ -222,7 +349,10 @@ TEST_P(IntegrateF, download) { TEST_P(IntegrateF, DISABLED_download) { #endif // INTEGRATION_TESTS const auto data_dir = fs::path(DATA_DIR); - const auto torrent_file = data_dir / "1MiB.torrent"; + const auto announce = + std::format("http://{}:8000/announce", get_host_ip_from_host()); + const auto torrent_file = generate_torrent_with_announce( + data_dir / "1MiB.dat", announce, tmp_dir()); const uint8_t number_of_seeders = GetParam(); auto tracker = start_tracker(data_dir); @@ -255,7 +385,10 @@ TEST_F(IntegrateOodF, download_ood) { TEST_F(IntegrateOodF, DISABLED_download_ood) { #endif // INTEGRATION_TESTS const auto data_dir = fs::path(DATA_DIR); - const auto torrent_file = data_dir / "1MiB.torrent"; + const auto announce = + std::format("http://{}:8000/announce", get_host_ip_from_host()); + const auto torrent_file = generate_torrent_with_announce( + data_dir / "1MiB.dat", announce, tmp_dir()); constexpr uint8_t number_of_seeders = 1; auto tracker = start_tracker(data_dir); @@ -298,8 +431,12 @@ TEST_F(IntegrateF, DISABLED_download_dual_torrents) { const auto download_dir = tmp_dir(); TestConfig test_config; - const auto torrent_file_1 = data_dir / "1MiB.torrent"; - const auto torrent_file_2 = data_dir / "multi.torrent"; + const auto torrent_file_1 = generate_torrent_with_announce( + data_dir / "1MiB.dat", + "http://" + get_host_ip_from_host() + ":8000/announce", tmp_dir()); + const auto torrent_file_2 = generate_torrent_with_announce( + data_dir / "multi", + "http://" + get_host_ip_from_host() + ":8000/announce", tmp_dir()); auto tracker = start_tracker(data_dir); @@ -332,7 +469,9 @@ TEST_F(Integrate, download_part) { TEST_P(IntegrateF, DISABLED_download_part) { #endif // INTEGRATION_TESTS const auto data_dir = fs::path(DATA_DIR); - const auto torrent_file = data_dir / "1MiB.torrent"; + const auto torrent_file = generate_torrent_with_announce( + data_dir / "1MiB.dat", + "http://" + get_host_ip_from_host() + ":8000/announce", tmp_dir()); const auto download_dir = tmp_dir(); auto tracker = start_tracker(data_dir); @@ -372,7 +511,9 @@ TEST_F(Integrate, download_multi_part) { TEST_F(Integrate, DISABLED_download_multi_part) { #endif // INTEGRATION_TESTS const auto data_dir = fs::path(DATA_DIR); - const auto torrent_file = data_dir / "multi.torrent"; + const auto torrent_file = generate_torrent_with_announce( + data_dir / "multi", + "http://" + get_host_ip_from_host() + ":8000/announce", tmp_dir()); const auto download_dir = tmp_dir(); auto tracker = start_tracker(data_dir); @@ -414,7 +555,9 @@ TEST_F(Integrate, download_multi_file) { TEST_F(Integrate, DISABLED_download_multi_file) { #endif // INTEGRATION_TESTS const auto data_dir = fs::path(DATA_DIR); - const auto torrent_file = data_dir / "multi.torrent"; + const auto torrent_file = generate_torrent_with_announce( + data_dir / "multi", + "http://" + get_host_ip_from_host() + ":8000/announce", tmp_dir()); auto tracker = start_tracker(data_dir); @@ -435,7 +578,9 @@ TEST_F(Integrate, upload) { TEST_F(Integrate, DISABLED_upload) { #endif // INTEGRATION_TESTS const auto data_dir = fs::path(DATA_DIR); - const auto torrent_file = data_dir / "1MiB.torrent"; + const auto torrent_file = generate_torrent_with_announce( + data_dir / "1MiB.dat", + "http://" + get_host_ip_from_host() + ":8000/announce", tmp_dir()); auto tracker = start_tracker(data_dir); @@ -463,21 +608,18 @@ TEST_F(Integrate, DISABLED_upload) { EXPECT_FALSE(ec); leecher = start_leecher(target, torrent_file); - torrent.set_disconnect_callback([&](zit::Peer*) { - logger()->info("Peer disconnect - stopping"); + torrent.set_not_interested_callback([&](zit::Peer*) { + logger()->info("Peer not interested - upload complete, stopping"); torrent.stop(); leecher->terminate(); }); }); - // FIXME: How to avoid this sleep? - // this_thread::sleep_for(5s); - // Connects to tracker and retrieves peers torrent.start(); // Run the peer connections - m_io_context.run(); + torrent.run(); // Transfer done - Verify content verify_download(torrent, destination); @@ -489,7 +631,9 @@ TEST_F(Integrate, multi_upload) { TEST_F(Integrate, DISABLED_multi_upload) { #endif // INTEGRATION_TESTS const auto data_dir = fs::path(DATA_DIR); - const auto torrent_file = data_dir / "multi.torrent"; + const auto torrent_file = generate_torrent_with_announce( + data_dir / "multi", + "http://" + get_host_ip_from_host() + ":8000/announce", tmp_dir()); auto tracker = start_tracker(data_dir); @@ -498,18 +642,28 @@ TEST_F(Integrate, DISABLED_multi_upload) { zit::Torrent torrent(m_io_context, torrent_file, data_dir, test_config); ASSERT_TRUE(torrent.done()); + sigint_function = [&](int /*s*/) { + logger()->warn("CTRL-C pressed. Stopping torrent..."); + torrent.stop(); + }; + // Start a leecher that we will upload to - auto target = tmp_dir() / "multi_upload_test"; - auto leecher = start_leecher(target, torrent_file); + const auto target = tmp_dir() / "multi_upload_test"; - torrent.set_disconnect_callback([&](zit::Peer*) { - logger()->info("Peer disconnect - stopping"); - torrent.stop(); - leecher.terminate(); - }); + std::optional leecher; + asio::steady_timer timer{m_io_context}; + timer.expires_after(15s); - // FIXME: How to avoid this sleep? - this_thread::sleep_for(15s); + timer.async_wait([&](auto ec) { + EXPECT_FALSE(ec); + leecher = start_leecher(target, torrent_file); + + torrent.set_not_interested_callback([&](zit::Peer*) { + logger()->info("Peer not interested - upload complete, stopping"); + torrent.stop(); + leecher->terminate(); + }); + }); // Connects to tracker and retrieves peers torrent.start(); From 47ffc51da277d47a6c0d145a525d426c44c7c7b3 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sun, 21 Dec 2025 17:19:37 +0100 Subject: [PATCH 14/26] Do not restrict clang-tidy headers as long as we have no related issues --- .clang-tidy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.clang-tidy b/.clang-tidy index 292350d..76aaf28 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -69,7 +69,7 @@ Checks: '*, -readability-named-parameter, ' WarningsAsErrors: '*' -HeaderFilterRegex: '(src|tests)/*.h$' +HeaderFilterRegex: '.*' FormatStyle: none User: db CheckOptions: From 0f5952618e305b4c256ccdf6caef6a55db2bebc0 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sun, 21 Dec 2025 17:20:19 +0100 Subject: [PATCH 15/26] Copy msg to avoid it going out of scope (use after free) --- src/peer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/peer.cpp b/src/peer.cpp index 5b0ed19..256689e 100644 --- a/src/peer.cpp +++ b/src/peer.cpp @@ -163,7 +163,7 @@ void PeerConnection::send(bool start_read) { // NOLINTNEXTLINE(misc-include-cleaner) asio::async_write( *socket_, asio::buffer(msg.c_str(), msg.size()), - [this, start_read](auto err, auto len) { + [this, start_read, msg](auto err, auto len) { if (!err) { logger()->debug("{}: Data of len {} sent", peer_.str(), len); } else { From 5d64d756f8678d32eb63f46dec629e26d9f7ad63 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sun, 21 Dec 2025 17:20:51 +0100 Subject: [PATCH 16/26] Better torrent dump info --- src/torrent.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/torrent.cpp b/src/torrent.cpp index 683f937..30fc190 100644 --- a/src/torrent.cpp +++ b/src/torrent.cpp @@ -1264,13 +1264,13 @@ ostream& operator<<(ostream& os, const zit::Torrent& torrent) { if (!torrent.encoding().empty()) { os << "Encoding: " << torrent.encoding() << "\n"; } - os << "Piece length: " << torrent.piece_length() << "\n"; + os << "Piece count: " << torrent.pieces().size() << "\n"; + os << "Piece length: " << torrent.piece_length() << " bytes (" + << bytesToHumanReadable(torrent.piece_length()) << ")\n"; os << "Info hash: " << torrent.info_hash() << "\n"; os << "Private: " << (torrent.is_private() ? "Yes" : "No") << "\n"; + os << "Name: " << torrent.name() << "\n"; if (torrent.is_single_file()) { - os << "Name: " << torrent.name() << "\n"; - os << "Length: " << torrent.length() << " bytes (" - << bytesToHumanReadable(torrent.length()) << ")\n"; if (!torrent.md5sum().empty()) { os << "MD5Sum: " << torrent.md5sum() << "\n"; } @@ -1280,6 +1280,8 @@ ostream& operator<<(ostream& os, const zit::Torrent& torrent) { os << " " << fi << "\n"; } } + os << "Length: " << torrent.length() << " bytes (" + << bytesToHumanReadable(torrent.length()) << ")\n"; os << "Announce: " << torrent.announce() << "\n"; os << "Announce List:\n"; for (const auto& list : torrent.announce_list()) { From 208f1fda28c265e985db682e22cf2205375e63b1 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sun, 21 Dec 2025 17:21:15 +0100 Subject: [PATCH 17/26] Improve torrent writer error message --- src/torrent_writer/torrent_writer.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/torrent_writer/torrent_writer.cpp b/src/torrent_writer/torrent_writer.cpp index 957301c..823f487 100644 --- a/src/torrent_writer/torrent_writer.cpp +++ b/src/torrent_writer/torrent_writer.cpp @@ -180,8 +180,9 @@ void write_torrent(const TorrentFile& torrent_file, info["files"] = bencode::Element::build(files); } else { - throw std::runtime_error( - "Data path is neither an accessible file nor directory"); + throw std::runtime_error(fmt::format( + "Data path '{}' is neither an accessible file nor directory", + data_path.get())); } // common info fields From 00eb108fab38947be7a424b6e30a4f1e2c1176b5 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sun, 21 Dec 2025 17:23:06 +0100 Subject: [PATCH 18/26] Mark as not interested properly --- src/peer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/peer.cpp b/src/peer.cpp index 256689e..2557f0f 100644 --- a/src/peer.cpp +++ b/src/peer.cpp @@ -514,7 +514,7 @@ void Peer::set_interested(bool interested) { } if (m_interested && !interested) { logger()->info("Peer is Not interested"); - // TODO: I guess we don't need to choke it since the peer told us + m_torrent.not_interested(this); } m_interested = interested; } From be6bdea8c05131dce0d9be28ad6b96da718cacbd Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sun, 21 Dec 2025 17:35:33 +0100 Subject: [PATCH 19/26] Fix clang-tidy issues --- src/torrent_writer/torrent_writer.cpp | 27 +++++++++++++------------- tests/data/1MiB.torrent | Bin 937 -> 945 bytes tests/data/test.torrent | Bin 35581 -> 853 bytes 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/torrent_writer/torrent_writer.cpp b/src/torrent_writer/torrent_writer.cpp index 823f487..f0efbae 100644 --- a/src/torrent_writer/torrent_writer.cpp +++ b/src/torrent_writer/torrent_writer.cpp @@ -5,10 +5,12 @@ #include "sha1.hpp" #include "string_utils.hpp" #include "strong_type.hpp" +#include "types.hpp" #include "version.hpp" #include +#include #include #include #include @@ -17,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -40,11 +43,13 @@ namespace rv = std::ranges::views; * @param m Subsequent chunk size * @return std::generator> */ -std::generator get_chunks(std::istream& is, size_t n, size_t m) { +std::generator get_chunks(std::shared_ptr is, + size_t n, + size_t m) { const auto read_chunk = [&](size_t size) { zit::bytes buf; - char c; - for (size_t i = 0; i < size && is.get(c); ++i) { + char c{}; + for (size_t i = 0; i < size && is->get(c); ++i) { buf.push_back(static_cast(c)); } return buf; @@ -56,7 +61,7 @@ std::generator get_chunks(std::istream& is, size_t n, size_t m) { } // 2. Read subsequent chunks (size m) - while (is) { + while (*is) { if (auto next = read_chunk(m); !next.empty()) { co_yield next; } else { @@ -149,18 +154,14 @@ void write_torrent(const TorrentFile& torrent_file, // Now read data for sha1 pieces - std::ifstream file_stream(entry.path(), - std::ios::in | std::ios::binary); - if (!file_stream) { + auto is = std::make_shared( + entry.path(), std::ios::in | std::ios::binary); + if (!*is) { throw std::runtime_error("Could not open data file for reading"); } - auto file_range = - std::ranges::subrange(std::istreambuf_iterator(file_stream), - std::istreambuf_iterator()); - - auto chunk_generator = get_chunks( - file_stream, piece_length - piece_data.size(), piece_length); + auto chunk_generator = + get_chunks(is, piece_length - piece_data.size(), piece_length); for (auto&& chunk : chunk_generator) { piece_data.insert(piece_data.end(), chunk.begin(), chunk.end()); if (piece_data.size() == piece_length) { diff --git a/tests/data/1MiB.torrent b/tests/data/1MiB.torrent index c006dad3d409d7ecd144ce3e03befc6674e406fa..010a85a62e231d4592aef7ec7bb94c3eefb49b6a 100644 GIT binary patch delta 50 zcmZ3q`*pF-_X)X&(O?5&p^-6VxwjUGXNk83~~Sf diff --git a/tests/data/test.torrent b/tests/data/test.torrent index f5c198aa7f4c6eb78d0154e839ce79e22f860f94..419398ed9ecfada8386cbcec8bdc4f3303b5744c 100644 GIT binary patch literal 853 zcmV-b1FHOFI67f&Zf|vNV`VfkI%srsa5^t9Xm4|LE@W?GYh`jSX>N37a&BR4Iyf*e zFfUVgE@WYJF)})EX=P(&AZ%rBXLM+3Gcq?eIAu0E zaA{>@Wpg$(FggfAYhvCc&fJ)58Mf0huI0W94L9WtDe+y@b7^gZ7d&-$rp#cO`9`cL zKqSjz6TaJqDR(C#E{5-H86~!;vaVViqgbH<7{mH)qnmi;Df1r8V<4&-$b5^RvIcJ|>`qSMHAb$ zfHjVS1>B++PX2#*ORv3pRKI?$sg1oW=ah5i842F1(|!dtx+K0tQ1H^r6v{nFtDA!O zz#`E|npTQQf|7S;SqiO0d#^$63{>P(0-i4qVTWBD?7# z;PC`yUwk>LBIu3JSl?OKOHmGuR>yWYk+U$I?Hq~cMs9A`4=;B^KEI+KnQtGx)1TG~ znK(*U!Zl(MFcTkvRD%7P`6>PEca6^<1lrRXNk`rbz2uA^iD;oDd#vP~C_N54ct?Bb z*_W}x8yI=Da{5;1AeuVD4!w`8En%7{^r$3dqE6iwaAYUgEL*X&D}?L@!tY~Cfx?Hc f5waY9HqcS?es;ZqhLOD^Sot3O+|kvrPKIS=4=se; literal 35581 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 WW~p2aAc{mY`Ja>dNT>UlYh`7;jnyjv From 6f51734f23ae61cd5415f9790726381455aee63d Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sun, 21 Dec 2025 20:23:48 +0100 Subject: [PATCH 20/26] Add HOST_IP fallbacks --- tests/docker/opentracker_entrypoint.sh | 24 +++++++++++++++++++++++- tests/docker/webtorrent_entrypoint.sh | 24 +++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/tests/docker/opentracker_entrypoint.sh b/tests/docker/opentracker_entrypoint.sh index e53a150..65ceaf7 100644 --- a/tests/docker/opentracker_entrypoint.sh +++ b/tests/docker/opentracker_entrypoint.sh @@ -3,8 +3,30 @@ set -e HOST_IP="${HOST_IP:-}" +# 1) If user set HOST_IP env, use it (already handled by expansion above) + +# 2) Try getent ahostsv4 (Docker Desktop / some setups) +if [ -z "$HOST_IP" ] && command -v getent >/dev/null 2>&1; then + HOST_IP=$(getent ahostsv4 host.docker.internal 2>/dev/null | awk '{print $1; exit}' || true) +fi + +# 3) Try classic getent hosts lookup +if [ -z "$HOST_IP" ] && command -v getent >/dev/null 2>&1; then + HOST_IP=$(getent hosts host.docker.internal 2>/dev/null | awk '{print $1; exit}' || true) +fi + +# 4) Fallback: use container default gateway (usually the host's docker bridge IP) +if [ -z "$HOST_IP" ] && command -v ip >/dev/null 2>&1; then + HOST_IP=$(ip route 2>/dev/null | awk '/default/ {print $3; exit}' || true) +fi + +# 5) Last-resort: try /sbin/ip route (some minimal images) +if [ -z "$HOST_IP" ] && [ -x /sbin/ip ]; then + HOST_IP=$(/sbin/ip route 2>/dev/null | awk '/default/ {print $3; exit}' || true) +fi + if [ -z "$HOST_IP" ]; then - echo "ERROR: HOST_IP not set." >&2 + echo "Warning: HOST_IP could not be determined inside the container." >&2 echo "You can set HOST_IP env or run container with --add-host=host.docker.internal:host-gateway" >&2 exit 1 fi diff --git a/tests/docker/webtorrent_entrypoint.sh b/tests/docker/webtorrent_entrypoint.sh index 69cabb5..0c6eb09 100644 --- a/tests/docker/webtorrent_entrypoint.sh +++ b/tests/docker/webtorrent_entrypoint.sh @@ -3,8 +3,30 @@ set -e HOST_IP="${HOST_IP:-}" +# 1) If user set HOST_IP env, use it (already handled by expansion above) + +# 2) Try getent ahostsv4 (Docker Desktop / some setups) +if [ -z "$HOST_IP" ] && command -v getent >/dev/null 2>&1; then + HOST_IP=$(getent ahostsv4 host.docker.internal 2>/dev/null | awk '{print $1; exit}' || true) +fi + +# 3) Try classic getent hosts lookup +if [ -z "$HOST_IP" ] && command -v getent >/dev/null 2>&1; then + HOST_IP=$(getent hosts host.docker.internal 2>/dev/null | awk '{print $1; exit}' || true) +fi + +# 4) Fallback: use container default gateway (usually the host's docker bridge IP) +if [ -z "$HOST_IP" ] && command -v ip >/dev/null 2>&1; then + HOST_IP=$(ip route 2>/dev/null | awk '/default/ {print $3; exit}' || true) +fi + +# 5) Last-resort: try /sbin/ip route (some minimal images) +if [ -z "$HOST_IP" ] && [ -x /sbin/ip ]; then + HOST_IP=$(/sbin/ip route 2>/dev/null | awk '/default/ {print $3; exit}' || true) +fi + if [ -z "$HOST_IP" ]; then - echo "ERROR: HOST_IP not set." >&2 + echo "Warning: HOST_IP could not be determined inside the container." >&2 echo "You can set HOST_IP env or run container with --add-host=host.docker.internal:host-gateway" >&2 exit 1 fi From 9e07a0b3fd6e209c66d2f8e7523d1a141d45c6ce Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sun, 21 Dec 2025 20:24:17 +0100 Subject: [PATCH 21/26] Fix permissions when webtorrent write files --- tests/docker/webtorrent.dockerfile | 4 ++-- tests/docker/webtorrent_entrypoint.sh | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/docker/webtorrent.dockerfile b/tests/docker/webtorrent.dockerfile index f1e3d82..3f4d912 100644 --- a/tests/docker/webtorrent.dockerfile +++ b/tests/docker/webtorrent.dockerfile @@ -1,8 +1,8 @@ FROM ubuntu:24.04 -# Install nodejs and npm +# Install nodejs, webtorrent-cli, iptables tools, and gosu for UID/GID drop RUN apt-get update && \ - apt-get install -y npm iptables iproute2 && \ + apt-get install -y npm iptables iproute2 gosu && \ rm -rf /var/lib/apt/lists/* && \ npm install webtorrent-cli -g diff --git a/tests/docker/webtorrent_entrypoint.sh b/tests/docker/webtorrent_entrypoint.sh index 0c6eb09..48eff06 100644 --- a/tests/docker/webtorrent_entrypoint.sh +++ b/tests/docker/webtorrent_entrypoint.sh @@ -36,5 +36,13 @@ fi # for the peer list which will not work from within the container /usr/sbin/iptables -t nat -A OUTPUT -d 172.17.0.1 -j DNAT --to-destination "$HOST_IP" -# Run opentracker with the provided arguments +# If host UID/GID are provided, fix ownership of the bind mount and +# drop privileges using gosu so files get correct ownership +if [ -n "$HOST_UID" ] && [ -n "$HOST_GID" ] && command -v gosu >/dev/null 2>&1; then + mkdir -p /data 2>/dev/null || true + chown -R "$HOST_UID:$HOST_GID" /data 2>/dev/null || true + exec gosu "$HOST_UID:$HOST_GID" webtorrent "$@" +fi + +# Fallback: run as root if HOST_UID/GID not provided exec webtorrent "$@" From 76439112ff35d9231e8f06ec27a0c188e7391daa Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Sun, 21 Dec 2025 20:26:28 +0100 Subject: [PATCH 22/26] Ensure correct destination for multi_upload --- tests/test_integrate.cpp | 46 +++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/tests/test_integrate.cpp b/tests/test_integrate.cpp index 333bc9e..03c2059 100644 --- a/tests/test_integrate.cpp +++ b/tests/test_integrate.cpp @@ -196,26 +196,21 @@ auto start_seeder(const fs::path& data_dir, } std::vector args = { - "docker", - "run", - "--rm", - "--name", - container_name, - "--publish", - fmt::format("{}:{}", port, port), - "--volume", + "docker", "run", "--rm", "--name", container_name, + // Pass host UID/GID so container can fix file ownership on exit + "--env", fmt::format("HOST_UID={}", getuid()), "--env", + fmt::format("HOST_GID={}", getgid()), "--publish", + fmt::format("{}:{}", port, port), "--volume", fmt::format("{}:{}", data_dir.generic_string(), container_data_dir), "--volume", fmt::format("{}:{}", torrent_file.parent_path().generic_string(), container_torrent_dir), // Needed to be able to run iptables in the container - "--cap-add", - "NET_ADMIN", + "--cap-add", "NET_ADMIN", // For verbose output - //"--env", - // fmt::format("DEBUG={}", - // logger()->should_log(spdlog::level::debug) ? "*" : "") - }; + "--env", + fmt::format("DEBUG={}", + logger()->should_log(spdlog::level::debug) ? "*" : "")}; // Add host mapping so container can resolve host.docker.internal to host LAN // IP @@ -245,7 +240,19 @@ auto start_seeder(const fs::path& data_dir, } auto start_leecher(const fs::path& target, const fs::path& torrent_file) { - fs::remove(target); + try { + if (fs::exists(target)) { + // Ensure the directory is empty to avoid leftover files + for (const auto& entry : fs::directory_iterator(target)) { + fs::remove_all(entry); + } + } + // Ensure the mount directory exists and is owned by the host user + fs::create_directories(target); + } catch (const std::exception& e) { + logger()->warn("Failed preparing target directory {}: {}", target.string(), + e.what()); + } return start_seeder(target, torrent_file, "leecher"); } @@ -598,7 +605,8 @@ TEST_F(Integrate, DISABLED_upload) { const auto target = tmp_dir() / "upload_test"; const auto destination = target / "1MiB.dat"; // Be fully sure we do not have the file there yet - ASSERT_FALSE(fs::exists(destination)); + ASSERT_FALSE(fs::exists(destination)) + << "File already exists: " << destination; std::optional leecher; asio::steady_timer timer{m_io_context}; @@ -649,6 +657,10 @@ TEST_F(Integrate, DISABLED_multi_upload) { // Start a leecher that we will upload to const auto target = tmp_dir() / "multi_upload_test"; + const auto destination = target / "multi"; // folder name inside torrent + // Be fully sure we do not have the files there yet + ASSERT_FALSE(fs::exists(destination)) + << "File already exists: " << destination; std::optional leecher; asio::steady_timer timer{m_io_context}; @@ -672,5 +684,5 @@ TEST_F(Integrate, DISABLED_multi_upload) { torrent.run(); // Transfer done - Verify content - verify_download(torrent, data_dir / torrent.name(), false); + verify_download(torrent, destination, false); } From 3ecbc09085b928be381cb99508bf68fa82a001c5 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Mon, 22 Dec 2025 12:36:33 +0100 Subject: [PATCH 23/26] Revert test .torrent files to master Changes on branch no longer needed since we autogenerate them for integration tests now. --- tests/data/1MiB.torrent | Bin 945 -> 937 bytes tests/data/test.torrent | Bin 853 -> 35581 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/data/1MiB.torrent b/tests/data/1MiB.torrent index 010a85a62e231d4592aef7ec7bb94c3eefb49b6a..c006dad3d409d7ecd144ce3e03befc6674e406fa 100644 GIT binary patch delta 42 xcmdnUzLH%r#lk8vFE76|FFDoN*eau>q`*pF-_X)X&(O?5&p^-6VxwjUGXNk83~~Sf delta 50 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 WW~p2aAc{mY`Ja>dNT>UlYh`7;jnyjv literal 853 zcmV-b1FHOFI67f&Zf|vNV`VfkI%srsa5^t9Xm4|LE@W?GYh`jSX>N37a&BR4Iyf*e zFfUVgE@WYJF)})EX=P(&AZ%rBXLM+3Gcq?eIAu0E zaA{>@Wpg$(FggfAYhvCc&fJ)58Mf0huI0W94L9WtDe+y@b7^gZ7d&-$rp#cO`9`cL zKqSjz6TaJqDR(C#E{5-H86~!;vaVViqgbH<7{mH)qnmi;Df1r8V<4&-$b5^RvIcJ|>`qSMHAb$ zfHjVS1>B++PX2#*ORv3pRKI?$sg1oW=ah5i842F1(|!dtx+K0tQ1H^r6v{nFtDA!O zz#`E|npTQQf|7S;SqiO0d#^$63{>P(0-i4qVTWBD?7# z;PC`yUwk>LBIu3JSl?OKOHmGuR>yWYk+U$I?Hq~cMs9A`4=;B^KEI+KnQtGx)1TG~ znK(*U!Zl(MFcTkvRD%7P`6>PEca6^<1lrRXNk`rbz2uA^iD;oDd#vP~C_N54ct?Bb z*_W}x8yI=Da{5;1AeuVD4!w`8En%7{^r$3dqE6iwaAYUgEL*X&D}?L@!tY~Cfx?Hc f5waY9HqcS?es;ZqhLOD^Sot3O+|kvrPKIS=4=se; From b57e2d7b35c1ecc38ad17e937c7ffa69fdd7d99d Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Mon, 22 Dec 2025 17:39:01 +0100 Subject: [PATCH 24/26] Ifdef out non-win code on win --- tests/test_integrate.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_integrate.cpp b/tests/test_integrate.cpp index 03c2059..81b3516 100644 --- a/tests/test_integrate.cpp +++ b/tests/test_integrate.cpp @@ -59,6 +59,7 @@ class TestConfig : public zit::Config { namespace { std::string get_host_ip_from_host() { +#ifndef WIN32 if (const char* env = getenv("HOST_IP")) { if (strlen(env) > 0) return std::string(env); @@ -100,6 +101,7 @@ std::string get_host_ip_from_host() { pclose(f); } } +#endif // WIN32 throw std::runtime_error("Failed to determine host IP address"); } @@ -197,10 +199,12 @@ auto start_seeder(const fs::path& data_dir, std::vector args = { "docker", "run", "--rm", "--name", container_name, +#ifndef WIN32 // Pass host UID/GID so container can fix file ownership on exit "--env", fmt::format("HOST_UID={}", getuid()), "--env", - fmt::format("HOST_GID={}", getgid()), "--publish", - fmt::format("{}:{}", port, port), "--volume", + fmt::format("HOST_GID={}", getgid()), +#endif // WIN32 + "--publish", fmt::format("{}:{}", port, port), "--volume", fmt::format("{}:{}", data_dir.generic_string(), container_data_dir), "--volume", fmt::format("{}:{}", torrent_file.parent_path().generic_string(), From e85dca13348fefcc3021b4c7b6aaa03cc9899ce8 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Mon, 22 Dec 2025 23:25:49 +0100 Subject: [PATCH 25/26] Remove unused transmission code --- tests/docker/transmission.dockerfile | 33 ---------------------------- tests/test_integrate.cpp | 15 +++++-------- 2 files changed, 5 insertions(+), 43 deletions(-) delete mode 100644 tests/docker/transmission.dockerfile diff --git a/tests/docker/transmission.dockerfile b/tests/docker/transmission.dockerfile deleted file mode 100644 index 3cd1301..0000000 --- a/tests/docker/transmission.dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -# Use fixed ubuntu image for reproducibility -FROM ubuntu:24.04 - -# Install necessary packages -RUN apt-get update && apt-get install -y \ - # Requirements from the repositories documentation: - # https://github.com/transmission/transmission/blob/main/docs/Building-Transmission.md#on-unix - build-essential cmake git libcurl4-openssl-dev libssl-dev \ - # Additional packages for the build process added by me: - ninja-build python-is-python3 \ - && rm -rf /var/lib/apt/lists/* && \ - # Clone the Transmission repository - git clone --recurse-submodules --branch 4.0.6 https://github.com/transmission/transmission.git /opt/transmission - -# Set the working directory -WORKDIR /opt/transmission - -# Build the Transmission project -RUN cmake -B build -G Ninja \ - -DENABLE_DAEMON=OFF \ - -DINSTALL_WEB=OFF \ - -DENABLE_UTILS=OFF \ - -DENABLE_CLI=ON \ - -DENABLE_TESTS=OFF \ - -DINSTALL_DOC=OFF \ - -DCMAKE_BUILD_TYPE=Release && \ - cmake --build build --target install - -# Expose the necessary ports -EXPOSE 51413 - -# Set the entry point to run transmission-cli -ENTRYPOINT ["/opt/transmission/build/cli/transmission-cli"] diff --git a/tests/test_integrate.cpp b/tests/test_integrate.cpp index 81b3516..733a1c4 100644 --- a/tests/test_integrate.cpp +++ b/tests/test_integrate.cpp @@ -15,8 +15,10 @@ * * - Tracker (opentracker) * - https://erdgeist.org/arts/software/opentracker/ - * - Seeder (transmission-cli) - * - sudo apt install transmission-cli + * - Seeder (webtorrent-cli) + * - https://github.com/webtorrent/webtorrent-cli + * + * Both currently handled by the docker images built above. */ #include @@ -41,14 +43,7 @@ namespace fs = std::filesystem; class TestConfig : public zit::Config { public: - TestConfig() : zit::Config() { - // As long as we use Transmission and test with all processes on localhost - // we need to make sure we initiate the connections. - // m_bool_settings[zit::BoolSetting::INITIATE_PEER_CONNECTIONS] = false; - - // Transmission does not want to connect to localhost, so trick it - // m_string_settings[zit::StringSetting::BIND_ADDRESS] = TEST_BIND_ADDRESS; - } + TestConfig() : zit::Config() {} void set(IntSetting setting, int val) { m_int_settings[setting] = val; } void set(StringSetting setting, std::string val) { From 50dbc15cc72d755a6f9dbc121cf000a1e9a3fec4 Mon Sep 17 00:00:00 2001 From: Daniel Bengtsson Date: Mon, 22 Dec 2025 23:26:19 +0100 Subject: [PATCH 26/26] Minor polish --- src/global_config.cpp | 2 +- src/torrent.hpp | 7 ++++++- tests/test_integrate.cpp | 14 +++++++------- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/global_config.cpp b/src/global_config.cpp index 31dec89..f9e0f38 100644 --- a/src/global_config.cpp +++ b/src/global_config.cpp @@ -119,7 +119,7 @@ std::optional parse_bool(const std::string& _str) { template const std::map settings_map; -// Should be fine - possibly try maxing them constexpr +// Should be fine - possibly try making them constexpr // NOLINTBEGIN(bugprone-throwing-static-initialization) template <> diff --git a/src/torrent.hpp b/src/torrent.hpp index 3f2be7b..886e4c3 100644 --- a/src/torrent.hpp +++ b/src/torrent.hpp @@ -255,7 +255,12 @@ class Torrent { m_disconnect_callback = std::move(disconnect_callback); } - void set_not_interested_callback(NotInterestedCallback not_interested_callback) { + /** + * Callback when a peer is not interested anymore, i.e. the remote peer + * is not interested in our pieces anymore. + */ + void set_not_interested_callback( + NotInterestedCallback not_interested_callback) { m_not_interested_callback = std::move(not_interested_callback); } diff --git a/tests/test_integrate.cpp b/tests/test_integrate.cpp index 733a1c4..cfa495a 100644 --- a/tests/test_integrate.cpp +++ b/tests/test_integrate.cpp @@ -33,6 +33,12 @@ #include "test_main.hpp" #include "test_utils.hpp" +// Path to the writer executable provided by CMake as a compile definition. +// Example: "C:/path/to/build/src/torrent_writer/zit_torrent_writer" +#ifndef TORRENT_WRITER_EXE +#error "TORRENT_WRITER_EXE not defined" +#endif + using namespace std; using namespace std::chrono_literals; using namespace std::string_literals; @@ -109,11 +115,10 @@ std::string get_host_ip_from_host() { } // Generate a .torrent file from data using the test torrent writer -// If `TORRENT_WRITER_EXE` is not available, fall back to existing torrent. +// `TORRENT_WRITER_EXE` is expected to be set by the build system. fs::path generate_torrent_with_announce(const fs::path& data, const std::string& announce, const fs::path& out_dir) { -#ifdef TORRENT_WRITER_EXE const auto out_torrent = out_dir / ("gen_" + data.filename().string() + ".torrent"); const std::string cmd = std::string(TORRENT_WRITER_EXE) + " --torrent " + @@ -124,11 +129,6 @@ fs::path generate_torrent_with_announce(const fs::path& data, // Use exec() from test_utils.hpp which throws on non-zero exit exec(cmd); return out_torrent; -#else - (void)announce; - (void)out_dir; - return data_dir / "1MiB.torrent"; -#endif } auto start_tracker(const fs::path& data_dir) {