diff --git a/CMakeLists.txt b/CMakeLists.txt index 52bb6855..39f46c1e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,5 +74,11 @@ target_link_libraries( flute # ---- Tests Subdirectory (optional) ---- option(BUILD_TESTING "Build unit tests" ON) if (BUILD_TESTING AND NOT DEFINED GTEST_DISABLE) + # enable_testing() must be called here, in the top-level list file, and not only in tests/. + # CTest writes CTestTestfile.cmake into the directory that enables testing and its children, so + # calling it only in the subdirectory leaves the build root with no test list at all. `ctest` run + # from the build root then prints "No tests were found" and exits 0, which is the normal + # invocation and the one CI uses: a green result having executed nothing. + enable_testing() add_subdirectory(tests) endif() diff --git a/README.md b/README.md index 589939e8..6b97a9b7 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,66 @@ sudo setcap 'cap_net_admin=eip' ./flute-transmitter sudo setcap 'cap_net_admin=eip' ./flute-receiver ```` +## Conformance profiles: 3GPP MBMS versus general FLUTE + +This library serves two different sets of obligations, and they are not degrees of strictness. +A session correct as general FLUTE can be non-conformant as 3GPP MBMS, because the MBMS Download +Profile forbids the sender things RFC 3926 permits. + +Select with the trailing `profile` argument on the `Transmitter` and `FileDeliveryTable` +constructors. **The default is `Profile::Mbms3gpp`**, since that is what the specifications +mandating FLUTE for this project require. Pass `Profile::GeneralFlute` for a non-3GPP session. + +```cpp +LibFlute::Transmitter tx(addr, port, tsi, mtu, rate, io); // 3GPP, default +LibFlute::Transmitter tx(addr, port, tsi, mtu, rate, io, {}, ns, true, {}, + LibFlute::Profile::GeneralFlute); // plain RFC 3926 +``` + +The profile decides which obligations apply. The FDT namespace, a separate argument, decides +which XML schema is emitted. They are independent. + +### What the 3GPP profile adds, over general FLUTE + +Every row below is a restriction on the **sender** only. Receive-side parsing is unchanged in all +cases, because TS 26.346 annex L.4 keeps most of these optional-to-support for receivers and +mandatory for two of them, so a receiver that refused them would break against a conformant peer. + +| Attribute / element | General FLUTE (RFC 3926) | 3GPP MBMS (TS 26.346 annex L.4) | +|---|---|---| +| `Transfer-Length` | permitted | not carried (clause L.4.4) | +| `Complete` | permitted on FDT-Instance | not used by the sender (clause L.4.3) | +| `FEC-OTI-FEC-Instance-ID` | permitted | not used at either level (clause L.4.2) | +| `Content-Encoding` | any value | absent, or `gzip` only; other values refused (clause L.4.2) | +| `Group` element | permitted | not used (clause L.4.2); this library never emits it | + +Behaviour required by RFC 3926 and the ALC/LCT documents beneath it applies in **both** profiles +and is not switchable: the LCT header format, the 20-bit FDT Instance ID and its wraparound, the +mandatory `Expires` attribute on FDT-Instance, and EXT_FTI support on any TOI other than 0. + +FLUTE version 2 (RFC 6726) and RaptorQ (RFC 6330) are referenced by neither TS 26.346 nor +TS 26.517 at this baseline and are not part of either profile here. They live on their own +branches. + +### Congestion control: conformant for 3GPP, not implemented for general FLUTE + +This library implements no congestion control. It offers a static, operator-set transmit rate +limit, which is rate limiting with no feedback and no response to loss, and it writes the CCI +field as zeros without reading it on receive. + +Under the **3GPP profile that is conformant**, and deliberately so. +TS 26.346 V18.2.0 clause L.4.7: "As indicated in clause 7.2.4 of this specification, congestion +control is not used for FLUTE delivery in MBMS, and therefore, FLUTE channelization should be +provided by a single FLUTE channel with single rate transport." + +Under **general FLUTE it is not implemented**, and the requirement is a MUST. +RFC 3450 clause 2.2: "Implementors of ALC MUST implement a multiple rate feedback-free +congestion control building block that is in accordance to RFC 2357 [12]." + +So a non-3GPP deployment using `Profile::GeneralFlute` over a path where congestion matters is +outside RFC 3450, and this is stated rather than claimed either way. Nothing here is presented as +implementing that clause. + ## Testing To execute the tests make sure to have built the project with testing enabled (see Step 3: Build setup). diff --git a/examples/flute-receiver.cpp b/examples/flute-receiver.cpp index be33bdad..22321e7e 100644 --- a/examples/flute-receiver.cpp +++ b/examples/flute-receiver.cpp @@ -128,6 +128,44 @@ void print_version(FILE *stream, struct argp_state * /*state*/) { * @param argv Command line arguments * @return 0 on clean exit, -1 on failure */ +/** + * Turn a sender-supplied Content-Location into a path that is safe to write. + * + * Content-Location is a URI under the sender's control, so it is untrusted: it may be absolute, + * walk upwards with "..", or carry a query or fragment, and any of those used directly as a path + * lets the sender choose where the receiver writes. The URI's path is kept, so hierarchical + * content locations still land in matching subdirectories, but every segment that could escape is + * dropped: a leading slash, "." and "..". The result always sits under `output_path`, or the + * current directory when none was given. Returns an empty string when nothing usable remains. + */ +static auto safe_output_path(const std::string& content_location, const char* output_path) -> std::string { + std::string s = content_location; + auto scheme = s.find("://"); + if (scheme != std::string::npos) { + auto slash = s.find('/', scheme + 3); + s = (slash == std::string::npos) ? std::string() : s.substr(slash); + } + s = s.substr(0, s.find_first_of("?#")); + + std::filesystem::path rel; + size_t pos = 0; + while (pos <= s.size()) { + auto next = s.find('/', pos); + auto seg = s.substr(pos, (next == std::string::npos) ? std::string::npos : next - pos); + if (!seg.empty() && seg != "." && seg != "..") { + rel /= seg; + } + if (next == std::string::npos) break; + pos = next + 1; + } + if (rel.empty() || rel.filename().empty()) return {}; + + std::filesystem::path dir = (output_path && std::strlen(output_path) > 0) + ? std::filesystem::path(output_path) + : std::filesystem::path("."); + return (dir / rel).string(); +} + auto main(int argc, char **argv) -> int { struct ft_arguments arguments; /* Default values */ @@ -168,9 +206,14 @@ auto main(int argc, char **argv) -> int { receiver.register_completion_callback( [output_path = arguments.output_path](std::shared_ptr file) { //NOLINT - std::string out_file = file->meta().content_location; - if (output_path && std::strlen(output_path) > 0) { - out_file = (std::filesystem::path(output_path) / std::filesystem::path(out_file).filename()).string(); + // Content-Location is a URI chosen by the sender, so it is untrusted input: it can name an + // absolute path, walk upwards with "..", or carry a query string. Reduce it to a single safe + // filename before it reaches the filesystem, under the chosen output directory. + std::string out_file = safe_output_path(file->meta().content_location, output_path); + if (out_file.empty()) { + spdlog::warn("Refusing to write TOI {}: Content-Location yields no usable filename", + file->meta().toi); + return; } spdlog::info("{} (TOI {}) has been received", diff --git a/include/AlcPacket.h b/include/AlcPacket.h index 643d32d7..cce7ff54 100644 --- a/include/AlcPacket.h +++ b/include/AlcPacket.h @@ -43,8 +43,32 @@ namespace LibFlute { * @param symbols Vector of encoding symbols * @param max_size Maximum payload size * @param fdt_instance_id FDT instance ID (only relevant for FDT with TOI=0) + * @param close_session_flag Set the LCT Close Session flag (RFC 3451 clause 5.1, 'A' bit) on this packet + * @param close_object_flag Set the LCT Close Object flag (RFC 3451 clause 5.1, 'B' bit) on this packet */ - AlcPacket(uint16_t tsi, uint16_t toi, FecOti fec_oti, const std::vector& symbols, size_t max_size, uint32_t fdt_instance_id); + /** + * Tag selecting the data-less Close Session packet constructor below. + */ + struct CloseSession {}; + + /** + * Build a packet that carries the Close Session flag and nothing else: no payload, and + * therefore no FEC Payload ID and no TOI. + * + * RFC 3450 clause 4.1 provides for such a packet: "In some special cases an ALC sender may + * need to produce ALC packets that do not contain any payload." RFC 3926 clause 3.1 gives it + * this shape in a FLUTE session, requiring that it not carry the TOI. + * + * Only expressible for a TSI of 32 bits or fewer. Dropping the TOI means dropping the + * half-word flag the two fields share, which leaves the TSI a whole number of 32-bit words, + * and one word is all this encoding uses. Throws above that. + * + * @param tsi The session's Transport Session Identifier. + */ + AlcPacket(uint64_t tsi, CloseSession); + + AlcPacket(uint64_t tsi, uint16_t toi, FecOti fec_oti, const std::vector& symbols, size_t max_size, uint32_t fdt_instance_id, + bool close_session_flag = false, bool close_object_flag = false); /** * Default destructor. @@ -72,15 +96,40 @@ namespace LibFlute { size_t header_length() const { return _lct_header.lct_header_len * 4; }; /** - * Get the FDT instance ID + * Get the FDT instance ID */ uint32_t fdt_instance_id() const { return _fdt_instance_id; }; + /** + * Whether the sender set the LCT Close Session flag on this packet, signalling that no + * further objects will be sent in this session (RFC 3451 clause 5.1, 'A' bit). + */ + bool close_session_flag() const { return _lct_header.close_session_flag; }; + + /** + * Whether the sender set the LCT Close Object flag on this packet, signalling that this + * is the last packet for this TOI (RFC 3451 clause 5.1, 'B' bit). + */ + bool close_object_flag() const { return _lct_header.close_object_flag; }; + /** * Get the FEC scheme */ FecScheme fec_scheme() const { return _fec_oti.encoding_id; }; + /** + * Whether this packet carried its own EXT_FTI header extension. + * + * A sender may put EXT_FTI on individual object packets (TOI > 0), not only on the FDT + * (TOI 0), and a receiver is obliged to accept it there, so an object's FEC OTI can be + * bootstrapped straight from the packet stream without waiting for, or ever seeing, that + * object's entry in the FDT. + * + * RFC 3926 clause 5: "For the TOI values other than 0 the receiver MUST support both + * methods: the use of EXT_FTI and the use of FDT." + */ + bool has_fec_oti() const { return _has_fti; }; + /** * Get the content encoding */ @@ -107,11 +156,12 @@ namespace LibFlute { ContentEncoding _content_encoding = ContentEncoding::NONE; FecOti _fec_oti = {}; + bool _has_fti = false; char* _buffer = nullptr; size_t _len; - // RFC5651 5.1 - LCT Header Format + // RFC 3451 clause 5.1 - LCT Header Format struct __attribute__((packed)) lct_header_t { #if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ uint8_t res1:1; @@ -121,7 +171,8 @@ namespace LibFlute { uint8_t close_object_flag:1; uint8_t close_session_flag:1; - uint8_t res:2; + uint8_t ert_flag:1; + uint8_t sct_flag:1; uint8_t half_word_flag:1; uint8_t toi_flag:2; uint8_t tsi_flag:1; @@ -134,7 +185,8 @@ namespace LibFlute { uint8_t tsi_flag:1; uint8_t toi_flag:2; uint8_t half_word_flag:1; - uint8_t res2:2; + uint8_t sct_flag:1; + uint8_t ert_flag:1; uint8_t close_session_flag:1; uint8_t close_object_flag:1; #else diff --git a/include/File.h b/include/File.h index 3ade6bb8..de3a7f95 100644 --- a/include/File.h +++ b/include/File.h @@ -116,6 +116,21 @@ namespace LibFlute { */ const LibFlute::FileDeliveryTable::FileEntry& meta() const { return _meta; }; + /** + * Fill in the FDT-derived fields (content_location, content_type, ...) once the FDT + * entry for this TOI becomes available. Used when reception started from a packet's own + * EXT_FTI before the describing FDT arrived, so the in-progress reception isn't discarded + * and restarted once it does. Only content_location/content_type/content_md5/expires are + * taken from the FDT entry -- fec_oti is left as-is, since it already came from the + * packet's own EXT_FTI and is what the in-flight reassembly is keyed on. + */ + void adopt_fdt_metadata(const LibFlute::FileDeliveryTable::FileEntry& fdt_entry) { + _meta.content_location = fdt_entry.content_location; + _meta.content_type = fdt_entry.content_type; + _meta.content_md5 = fdt_entry.content_md5; + _meta.expires = fdt_entry.expires; + }; + /** * Timestamp of file reception */ diff --git a/include/FileDeliveryTable.h b/include/FileDeliveryTable.h index 5073be2c..15c853af 100644 --- a/include/FileDeliveryTable.h +++ b/include/FileDeliveryTable.h @@ -17,6 +17,7 @@ #pragma once #include #include +#include #include #include #include @@ -46,7 +47,8 @@ namespace LibFlute { * @param fec_oti Global FEC OTI parameters * @param fdt_namespace The XML namespace to use for FDT */ - FileDeliveryTable(uint32_t instance_id, FecOti fec_oti, FdtNamespace fdt_namespace = FDT_NS_NONE); + FileDeliveryTable(uint32_t instance_id, FecOti fec_oti, FdtNamespace fdt_namespace = FDT_NS_NONE, + Profile profile = Profile::Ts26517); /** * Parse an XML string and create a FDT class from it @@ -67,6 +69,34 @@ namespace LibFlute { */ uint32_t instance_id() { return _instance_id; }; + /** + * Whether this FDT Instance may still be used to interpret arriving packets. + * + * TS 26.346 V18.2.0 clause 7.2.9: "For MBMS operation, the UE shall not use a received FDT + * Instance to interpret packets received beyond the expiration time of the FDT Instance." + * The same clause notes this is stricter than RFC 3926, which only says SHOULD NOT, so it is + * enforced under the 3GPP profiles and advisory outside them. + */ + bool expired(uint64_t now) const { return _expires != 0 && now > _expires; } + + /** The FDT-Instance Expires attribute, in NTP-epoch seconds. */ + uint64_t expires() const { return _expires; } + + /** 20-bit field width (RFC 3926 clause 3.4.1, "FDT Instance ID, 20 bits"). */ + static constexpr uint32_t kMaxFdtInstanceId = 0xFFFFF; + + /** + * Next FDT Instance ID in the sequence RFC 3926 clause 3.4.1 defines, exposed as a pure + * function so the wraparound is testable without a live session. + */ + static uint32_t next_instance_id(uint32_t current, uint64_t current_expires, uint64_t now, + std::map& expired_instance_ids); + + /** + * Which obligation set this FDT is emitted under. See Profile. + */ + Profile profile() const { return _profile; }; + /** * An entry for a file in the FDT */ @@ -76,7 +106,7 @@ namespace LibFlute { uint32_t content_length; std::string content_md5; std::string content_type; - uint64_t expires; + uint64_t expires; //< File@Expires, 0 when the attribute was absent FecOti fec_oti; struct { bool no_cache; @@ -89,10 +119,40 @@ namespace LibFlute { bool operator!=(const FileEntry &other) const { return !(*this == other); }; }; + /** + * When the given entry stops being usable, in NTP-epoch seconds. + * + * TS 26.346 V18.2.0 annex L: "When the optional File@Expires attribute is provided, its value + * shall take precedence over that of the FDT@Expires attribute." So the File attribute wins + * where present, and the FDT-Instance value applies otherwise. + */ + uint64_t effective_expiry(const FileEntry& entry) const { + return entry.expires ? entry.expires : _expires; + } + /** * Set the expiry value */ - void set_expires(uint64_t exp) { _expires = exp; }; + /** + * Set the FDT-Instance Expires attribute, in NTP-epoch seconds. + * + * Refuses a time that is not in the future. RFC 3926 clause 3.3: "A sender MUST use an expiry + * time in the future upon creation of an FDT Instance relative to its Sender Current Time + * (SCT)." Binding under every profile, the 3GPP ones inheriting it through TS 26.346 clause + * 7.2.0's adoption of RFC 3926. + */ + void set_expires(uint64_t exp); + + /** + * Set the RFC 3926 clause 3.4.2 Complete attribute: true once this FDT Instance describes the full, + * final set of files for the session (no further files will ever be announced). + */ + void set_complete(bool complete) { _complete = complete; }; + + /** + * Get the Complete attribute (defaults to false if the FDT-Instance never carried one). + */ + bool complete() const { return _complete; }; /** * Add a file entry @@ -122,13 +182,41 @@ namespace LibFlute { void sent() { _instance_id_sent = _instance_id; }; private: + /** + * Advance _instance_id to a value that is safe to reuse. + * + * The wrap itself is required. + * RFC 3926 clause 3.4.1: "After reaching the maximum value (2^20-1), the numbering starts + * again from '0'." + * + * Waiting for the previous holder of an ID to expire before reusing it is a recommendation + * on the sender, not an obligation, so this is deliberately stronger than the clause asks. + * Per RFC 3926 clause 3.4.1 it would be reasonable for + * "FLUTE Senders to only construct and deliver FDT Instances with wraparound IDs after the + * previous FDT Instance using the same ID has expired." + * (The clause's own sentence begins "It would be reasonable for"; it is split across a page + * boundary in the published text, so only the contiguous remainder is quoted here.) + * + * Records the outgoing ID's expiry, then either increments linearly or, once the 20-bit + * space is exhausted, wraps to the smallest ID whose recorded expiry has already passed. + */ + void advance_instance_id(); + uint32_t _instance_id; uint32_t _instance_id_sent; + Profile _profile = Profile::Ts26517; + + /** FDT Instance IDs that have been sent, and the (NTP-epoch-seconds) time each stops + * being live -- i.e. the Expires value that was in effect while that ID was in use. + * Read on wraparound to warn when an ID is reused before the previous instance expired. */ + std::map _expired_instance_ids; + std::vector _file_entries; FecOti _global_fec_oti; uint64_t _expires; + bool _complete = false; FdtNamespace _fdt_namespace; }; diff --git a/include/IpSec.h b/include/IpSec.h index c46c8c9e..0cdd8861 100644 --- a/include/IpSec.h +++ b/include/IpSec.h @@ -14,9 +14,36 @@ // under the License. // #pragma once +#include #include namespace LibFlute::IpSec { enum class Direction { In, Out }; - void enable_esp(uint32_t spi, const std::string& dest_address, Direction direction, const std::string& key); + /** + * Configure an IPsec ESP security association and policy for FLUTE traffic to or from + * @p dest_address. The association carries both an encryption and an authentication + * algorithm; see the citation at the call site in `IpSec.cpp` for why. + * + * @param spi Security Parameter Index value to use + * @param dest_address Destination address to apply the SA and policy to + * @param direction In or Out + * @param key AES encryption key, as a hex string, without a leading 0x, of even length + * @param auth_key HMAC-SHA256 authentication key, in the same form. If empty, one is derived + * from @p key, so a caller supplying a single key still gets authentication. + */ + /** + * Install an ESP security association and the policy that selects the session's traffic into + * it. + * + * @param dest_port The session's UDP port. The policy selector names the protocol and this + * port as well as the destination address, so it captures this session's packets and + * not everything else addressed to the same group. + * + * RFC 5775 clause 5.1.1: "The sender IPsec SPD entry MUST be configured to process + * outbound packets to the destination address and UDP port number of the applicable ALC + * session." + */ + void enable_esp(uint32_t spi, const std::string& dest_address, unsigned short dest_port, + Direction direction, const std::string& key, + const std::string& auth_key = ""); }; diff --git a/include/Receiver.h b/include/Receiver.h index 8b63d3b5..0fd8f1b8 100644 --- a/include/Receiver.h +++ b/include/Receiver.h @@ -37,6 +37,17 @@ namespace LibFlute { * @returns shared_ptr to the received file */ typedef std::function)> completion_callback_t; + + /** + * Definition of a callback invoked whenever an incoming packet carries the LCT Close + * Session and/or Close Object flag (RFC 3451 clause 5.1, 'A' and 'B' bits), registered through + * ::register_close_notification_callback. + * + * @param session_closed True if the sender signalled the whole session is ending + * @param object_closed True if the sender signalled no further data for this TOI + * @param toi The TOI the packet carrying the flag(s) was for + */ + typedef std::function close_notification_callback_t; /** * Default constructor. * @@ -70,7 +81,7 @@ namespace LibFlute { * @param spi Security Parameter Index value to use * @param key AES key as a hex string (without leading 0x). Must be an even number of characters long. */ - void enable_ipsec( uint32_t spi, const std::string& aes_key); + void enable_ipsec( uint32_t spi, const std::string& aes_key, const std::string& auth_key = ""); /** * List all current files @@ -96,6 +107,13 @@ namespace LibFlute { */ void register_completion_callback(completion_callback_t cb) { _completion_cb = cb; }; + /** + * Register a callback for LCT Close Session / Close Object notifications + * + * @param cb Function to call when an incoming packet carries either flag + */ + void register_close_notification_callback(close_notification_callback_t cb) { _close_cb = cb; }; + void stop() { _running = false; } private: @@ -126,9 +144,15 @@ namespace LibFlute { uint32_t _fdt_in_progress_instance_id = 0xFFFFFFFF; std::map> _files; std::mutex _files_mutex; + /** The session's source address where the caller named one, parsed once at construction so + * the per-packet check costs no parsing. Empty for an any-source session. */ + std::optional _expected_source; + std::string _mcast_address; + unsigned short _mcast_port; completion_callback_t _completion_cb = nullptr; + close_notification_callback_t _close_cb = nullptr; bool _running = true; diff --git a/include/Transmitter.h b/include/Transmitter.h index 5ff7af1d..1a56c0bd 100644 --- a/include/Transmitter.h +++ b/include/Transmitter.h @@ -22,6 +22,7 @@ #include #include #include +#include #include #include //#include "File.h" @@ -286,13 +287,32 @@ namespace LibFlute { FileDescription &set_content_type(const std::string &content_type); /** - * Change the file expiry time + * Change the file expiry time. + * + * This is the File element's own Expires attribute, an optional attribute of FileType in + * the TS 26.346 annex L.6.1 profiled schema. It says when the file itself stops being + * valid, and it is NOT the cache directive: see set_cache_expiry_time() for that. The two + * were previously set together and could not be given different values. * * @param expiry_time The expiry time of the file in the FLUTE session. * @return this file description */ FileDescription &set_expiry_time(const date_time_type &expiry_time); + /** + * Change the cache expiry time carried in the mbms2007:Cache-Control element. + * + * A separate value from set_expiry_time(): CacheControlType in the annex L.6.1 profiled + * schema is an xs:choice of no-cache, max-stale and Expires, so at most one of those + * appears, and the Expires it carries is a caching directive to intermediates rather than + * a statement about the file's own validity. Leave it unset to emit no Cache-Control + * element at all, which the schema permits since the element is minOccurs="0". + * + * @param expiry_time The cache expiry time. + * @return this file description + */ + FileDescription &set_cache_expiry_time(const date_time_type &expiry_time); + /** * Get the currently set expiry time * @@ -420,7 +440,8 @@ namespace LibFlute { const std::optional& tunnel_endpoint = std::nullopt, FdtNamespace fdt_namespace = FileDeliveryTable::FDT_NS_NONE, bool active = true, - const std::optional& source_address = std::nullopt); + const std::optional& source_address = std::nullopt, + Profile profile = Profile::Ts26517); /** * Default destructor. @@ -490,6 +511,12 @@ namespace LibFlute { * * @return The maximum bit rate. */ + /** + * Read-only access to the session's current FDT, for tests that need to + * observe what the sender is advertising. Not part of the sending API. + */ + const FileDeliveryTable& fdt() const { return *_fdt; } + uint32_t rate_limit() const { return _rate_limit; }; /** @@ -573,7 +600,7 @@ namespace LibFlute { * @param spi Security Parameter Index value to use * @param aes_key AES key as a hex string (without leading 0x). Must be an even number of characters long. */ - void enable_ipsec( uint32_t spi, const std::string& aes_key); + void enable_ipsec( uint32_t spi, const std::string& aes_key, const std::string& auth_key = ""); /** * Transmit a file (deprecated). @@ -658,9 +685,26 @@ namespace LibFlute { */ size_t number_of_files() { std::lock_guard guard(_files_mutex); return _files.size(); }; + /** + * Signal that this session is ending: no further files will ever be added. Marks the FDT + * as Complete (RFC 3926 clause 3.4.2, so receivers know the file set is final) and sets the LCT Close + * Session flag (RFC 3451 clause 5.1, 'A' bit) on every packet sent from this point on, including for + * files already in flight. + */ + void close_session(); + + /** + * Signal that no further data will be sent for a specific TOI. Sets the LCT Close Object + * flag (RFC 3451 clause 5.1, 'B' bit) on subsequent packets carrying that TOI. + * + * @param toi The TOI to close. + */ + void close_object(uint32_t toi); + private: void send_fdt(); void send_next_packet(); + void send_close_session_packet(); void fdt_send_tick(const boost::system::error_code& error); void start_fdt_repeat_timer(); @@ -671,6 +715,10 @@ namespace LibFlute { void handle_send_to(const boost::system::error_code& error); boost::asio::ip::udp::endpoint _endpoint; std::optional _source_address; + + /** Which obligation set this session is held to. Fixed at construction; the profile decides + * what may be signalled, so it cannot change once a session is running. */ + Profile _profile; boost::asio::ip::udp::socket _socket; boost::asio::io_context& _io_context; boost::asio::steady_timer _send_timer; @@ -683,6 +731,9 @@ namespace LibFlute { std::map> _files; std::mutex _files_mutex; + bool _session_closing = false; + std::set _closing_objects; + unsigned _fdt_repeat_interval = 5; uint16_t _toi = 1; diff --git a/include/flute_types.h b/include/flute_types.h index 173232df..e58b99fc 100644 --- a/include/flute_types.h +++ b/include/flute_types.h @@ -45,6 +45,58 @@ namespace LibFlute { CompactNoCode }; + /** + * Which set of obligations this session is held to, named by the document that imposes them. + * + * The three are not interchangeable, and two of them mandate a different FDT schema with a + * different mandatory schemaVersion value, so the schema is derived from this rather than chosen + * separately: a session cannot be conformant while its profile and its FDT schema disagree. + * + * Named after the governing documents rather than after the profiles, because only one of the + * three profiles is named in the specifications at all. Annex L.4 of TS 26.346 is titled "MBMS + * Download Profile". Its TS 26.517 variant has no name, being described only as that profile + * plus the additional requirements of clause 6.2 (quoted at Ts26517 below). Plain FLUTE outside + * any 3GPP profile has no name because it is simply the absence of one. + */ + enum class Profile { + /** + * TS 26.517 clause 6.2, layered on TS 26.346 clause 7.2 and annex L.4. The default. + * + * TS 26.517 V18.6.0 clause 6.2.1: "If FLUTE [12] is used to realise the Object Distribution + * Method, the MBS Distribution Session shall conform to the MBMS Download Profile as defined + * in clause L.4 of TS 26.346 [7] with the additional requirements in clause 6.2 of the present + * document." The same clause fixes the schema: "The MBSTF shall use the Profiled FDT Schema + * according to clause L.6 of TS 26.346 [7] to describe the object list currently being + * transmitted in the MBS Distribution Session." + */ + Ts26517, + + /** + * TS 26.346 clause 7.2 and annex L.4, without TS 26.517's additions. + * + * TS 26.346 V18.2.0 clause 7.2.9 fixes its schema instead: "The extended FLUTE FDT instance + * schema defined in clause 7.2.10.1 (based on the one in RFC 3926 [9]) shall be used." + */ + Ts26346, + + /** + * No 3GPP profile: the session is bound only by the FLUTE specification in force and the ALC + * and LCT documents beneath it. + * + * Deliberately not named after a document, unlike the two above. Which FLUTE specification + * applies here is decided separately, by the protocol version: RFC 3926 for version 1 and + * RFC 6726 for version 2. Naming this value RFC 3926 would contradict itself the moment a + * caller selected version 2, RFC 6726 being the document that obsoletes RFC 3926. + * + * "Unprofiled" is the complement of the term TS 26.346 uses for the other direction, annex L.6 + * being titled "Profiled FLUTE FDT schema". + */ + Unprofiled + }; + + /** True for the profiles bound by the 3GPP obligations, i.e. anything but an unprofiled session. */ + constexpr bool is_3gpp(Profile p) { return p != Profile::Unprofiled; } + /** * OTI values struct */ diff --git a/src/AlcPacket.cpp b/src/AlcPacket.cpp index c4a96cca..0ab3ca64 100644 --- a/src/AlcPacket.cpp +++ b/src/AlcPacket.cpp @@ -30,6 +30,43 @@ LibFlute::AlcPacket::AlcPacket(char* data, size_t len) throw std::runtime_error("Unsupported LCT version"); } + /* Everything below walks the header using lengths taken from the packet itself, so the packet's + own claim about its header size is validated first, against both the flags that imply a + minimum and the number of bytes actually received. + + RFC 3451 clause 5.1 on the field being trusted here: + "Total length of the LCT header in units of 32-bit words." + + Two ways this goes wrong without the checks. A header claiming fewer words than its own + flags require makes the extension-space calculation below negative, which becomes a very + large size_t. A header claiming more words than were received sends every read past the end + of the buffer. Both are reachable from one datagram, so neither is a theoretical concern. + `code-derived, no spec claim` beyond the field's own definition. */ + /* RFC 3451 clause 5.1 places two optional 32-bit fields inside the header, after the TOI and + before any header extension, each present only when its flag is set: + "Sender Current Time (SCT, if T = 1)" and "Expected Residual Time (ERT, if R = 1)". + Both count toward HDR_LEN. Omitting them made a conformant peer's SCT and ERT words get + walked as HET/HEL extension pairs, corrupting extension parsing. + + TS 26.346 V18.2.0 clause L.4.7 on the MBMS side: "The network should set these flags/fields + to zero, and the UE should ignore them." Ignoring a field still means stepping over it, so + this is needed in both profiles: robustness against a non-conformant sender under the 3GPP + profile, plain correctness under general FLUTE. */ + const size_t standard_header_words = 2 + + _lct_header.congestion_control_flag + + _lct_header.half_word_flag + + _lct_header.tsi_flag + + _lct_header.toi_flag + + _lct_header.sct_flag + + _lct_header.ert_flag; + + if (_lct_header.lct_header_len < standard_header_words) { + throw std::runtime_error("LCT header length is shorter than its own flags require"); + } + if ((size_t)_lct_header.lct_header_len * 4 > len) { + throw std::runtime_error("LCT header length exceeds the received packet length"); + } + char* hdr_ptr = data + 4; if (_lct_header.congestion_control_flag != 0) { throw std::runtime_error("Unsupported CCI field length"); @@ -80,19 +117,21 @@ LibFlute::AlcPacket::AlcPacket(char* data, size_t len) throw std::runtime_error("TOI fields over 64 bits in length are not supported"); } + // Step over the SCT and ERT words when present, so the extension walk below starts where the + // extensions actually begin. RFC 3451 clause 5.1 field order is CCI, TSI, TOI, SCT, ERT. + if (_lct_header.sct_flag) hdr_ptr += 4; + if (_lct_header.ert_flag) hdr_ptr += 4; + if (_lct_header.codepoint == 0) { _fec_oti.encoding_id = FecScheme::CompactNoCode; } else { throw std::runtime_error("Only Compact No-Code FEC is supported"); } - auto expected_header_len = 2 + - _lct_header.congestion_control_flag + - _lct_header.half_word_flag + - _lct_header.tsi_flag + - _lct_header.toi_flag; - - size_t ext_header_len = (_lct_header.lct_header_len - expected_header_len) * 4; + /* RFC 3451 clause 5.1: "if HDR_LEN is larger than the length of the standard header then the + remaining header space is taken by Header Extension fields." Both terms were validated + above, so this subtraction cannot wrap. */ + size_t ext_header_len = ((size_t)_lct_header.lct_header_len - standard_header_words) * 4; while (ext_header_len > 0) { auto ext_ptr = hdr_ptr; uint8_t het = *ext_ptr; @@ -105,6 +144,13 @@ LibFlute::AlcPacket::AlcPacket(char* data, size_t len) ext_ptr += 1; // Skip HEL } + /* A variable-length extension declaring HEL 0 gives a zero-length extension, which the + bound below does not catch: the loop would then consume nothing and never terminate on a + single malformed packet. HEL counts the whole extension including its own HET and HEL + bytes, so zero is never legitimate. */ + if (ext_len == 0) { + throw std::runtime_error("Header extension declares a zero length"); + } if (ext_len > ext_header_len) { throw std::runtime_error("Header extension length exceeds remaining header length"); } @@ -124,17 +170,42 @@ LibFlute::AlcPacket::AlcPacket(char* data, size_t len) ext_ptr += 2; _fec_oti.transfer_length |= (uint64_t)(ntohl(*(uint32_t*)ext_ptr)); ext_ptr += 4; - ext_ptr += 2; // reserved + /* The FEC Instance ID, not a reserved field. RFC 3926 clause 5.1.1: + "It is only present if the value of FEC Encoding ID is in the range of + 128-255. When the value of FEC Encoding ID is in the range of 0-127, this + field is set to 0." Compact No-Code is 0, so it is stepped over rather + than read; the width is the same either way. */ + ext_ptr += 2; _fec_oti.encoding_symbol_length = ntohs(*(uint16_t*)ext_ptr); ext_ptr += 2; _fec_oti.max_source_block_length = ntohl(*(uint32_t*)ext_ptr); + _has_fti = true; } break; } case EXT_FDT: { uint8_t flute_version = (*ext_ptr & 0xF0) >> 4; - if (flute_version > 2) { - throw std::runtime_error("Unsupported FLUTE version"); + /* This branch implements FLUTE version 1, and the version field is not + advisory: it identifies which protocol the packet belongs to. + + RFC 3926 clause 3.4.1: "This document specifies FLUTE version 1. Hence + in any ALC packet that carries FDT Instance and that belongs to the file + delivery session as specified in this specification MUST set this field + to '1'." + + Accepting 2 was accepting a packet from a protocol this build does not + implement, and the two are not interchangeable underneath. + RFC 6726 clause 11.1: "Therefore, an implementation that relies on + [RFC3926] and RFC 3451 will not be backwards compatible with FLUTE as + specified in this document." + + General FLUTE, not a 3GPP restriction: it holds in both profiles. What + TS 26.346 adds is only that version 1 is the one it selects, so a 3GPP + session could never legitimately carry 2 either. */ + if (flute_version != 1) { + throw std::runtime_error("Unsupported FLUTE version " + + std::to_string(flute_version) + + "; this implementation is FLUTE version 1"); } _fdt_instance_id = (*ext_ptr & 0x0F) << 16; ext_ptr++; @@ -142,12 +213,22 @@ LibFlute::AlcPacket::AlcPacket(char* data, size_t len) break; } case EXT_CENC: { + /* The set of algorithms is open: RFC 3926 clause 3.4.3 says of the CENC + field that "The definition of this field is outside the scope of this + specification." A value this library does not know therefore has to be + refused rather than ignored. Falling through to NONE would hand the + undecoded bytes to the FDT parser as though they were XML, which fails + somewhere further on with an error naming the wrong thing. */ uint8_t encoding = *ext_ptr; switch (encoding) { case 0: _content_encoding = ContentEncoding::NONE; break; case 1: _content_encoding = ContentEncoding::ZLIB; break; case 2: _content_encoding = ContentEncoding::DEFLATE; break; case 3: _content_encoding = ContentEncoding::GZIP; break; + default: + throw std::runtime_error( + "EXT_CENC names content encoding " + std::to_string(encoding) + + ", which this library cannot decode"); } break; } @@ -158,11 +239,25 @@ LibFlute::AlcPacket::AlcPacket(char* data, size_t len) } } -LibFlute::AlcPacket::AlcPacket(uint16_t tsi, uint16_t toi, LibFlute::FecOti fec_oti, const std::vector& symbols, size_t max_encoding_symbol_size, uint32_t fdt_instance_id) +LibFlute::AlcPacket::AlcPacket(uint64_t tsi, uint16_t toi, LibFlute::FecOti fec_oti, const std::vector& symbols, size_t max_encoding_symbol_size, uint32_t fdt_instance_id, + bool close_session_flag, bool close_object_flag) : _fec_oti(fec_oti) { + // TSI width: this wire scheme always carries a 16-bit half-word component (half_word_flag=1, + // shared with TOI's own 16-bit half-word below) plus, when tsi_flag=1, an extra 32-bit word + // holding the high-order bits -- giving a 48-bit ceiling, matching the decoder in this same + // file. Values that fit in 16 bits keep the original on-wire size; anything larger sets + // tsi_flag and adds the extra word, instead of silently truncating to the low 16 bits. + if (tsi > 0xFFFFFFFFFFFFULL) { + throw std::runtime_error("TSI exceeds the 48-bit field width supported by this LCT encoding"); + } + const bool wide_tsi = tsi > 0xFFFF; + const size_t max_alc_header_size = 4; auto lct_header_len = 3; + if (wide_tsi) { + lct_header_len += 1; + } if (toi == 0) { // Add extensions for FDT lct_header_len += 5; } @@ -177,18 +272,26 @@ LibFlute::AlcPacket::AlcPacket(uint16_t tsi, uint16_t toi, LibFlute::FecOti fec_ lct_header->version = 1; lct_header->half_word_flag = 1; + lct_header->tsi_flag = wide_tsi ? 1 : 0; + lct_header->close_session_flag = close_session_flag ? 1 : 0; + lct_header->close_object_flag = close_object_flag ? 1 : 0; lct_header->lct_header_len = lct_header_len; auto hdr_ptr = _buffer + 4; auto payload_ptr = _buffer + 4 * lct_header_len; auto payload_size = EncodingSymbol::to_payload(symbols, payload_ptr, max_encoding_symbol_size + max_alc_header_size, _fec_oti, ContentEncoding::NONE); _len = 4 * lct_header_len + payload_size; - + hdr_ptr += 4; // CCI = 0 - - *((uint16_t*)hdr_ptr) = htons(tsi); + + *((uint16_t*)hdr_ptr) = htons(static_cast(tsi & 0xFFFF)); hdr_ptr += 2; - + + if (wide_tsi) { + *((uint32_t*)hdr_ptr) = htonl(static_cast(tsi >> 16)); + hdr_ptr += 4; + } + *((uint16_t*)hdr_ptr) = htons(toi); hdr_ptr += 2; @@ -214,13 +317,57 @@ LibFlute::AlcPacket::AlcPacket(uint16_t tsi, uint16_t toi, LibFlute::FecOti fec_ hdr_ptr += 2; *((uint32_t*)hdr_ptr) = htonl(static_cast(_fec_oti.transfer_length & 0xFFFFFFFF)); hdr_ptr += 4; - hdr_ptr += 2; // reserved + /* The FEC Instance ID, left at the zero the buffer already holds, which is what a + Fully-Specified scheme requires. + + RFC 3926 clause 5.1.1: "When the value of FEC Encoding ID is in the range of 0-127, this + field is set to 0." */ + hdr_ptr += 2; *((uint16_t*)hdr_ptr) = htons(_fec_oti.encoding_symbol_length); hdr_ptr += 2; *((uint32_t*)hdr_ptr) = htonl(_fec_oti.max_source_block_length); } } +LibFlute::AlcPacket::AlcPacket(uint64_t tsi, CloseSession) +{ + /* RFC 3926 clause 3.1: "the exception that ALC packets sent in a FLUTE session with the Close + Session (A) flag set to 1 (signaling the end of the session) and that contain no payload + (carrying no information for any file or FDT) SHALL NOT carry the TOI" + + The TSI and TOI share the half-word flag, so dropping the TOI drops the half-word too and the + TSI becomes a whole number of 32-bit words. + RFC 5651 clause 5.1: "The TSI field is 32*S + 16*H + bits in length" + One word is what this builds, which caps the TSI at 32 bits; a session with a + wider TSI cannot express this packet at all and says so rather than emitting a TOI the clause + forbids. */ + if (tsi > 0xFFFFFFFFULL) { + throw std::runtime_error( + "a data-less Close Session packet carries no TOI, so its TSI must fit in 32 bits"); + } + + /* Base word, CCI, TSI. No TOI, no extensions, no FEC Payload ID, no payload. */ + const uint8_t lct_header_len = 3; + _len = 4 * lct_header_len; + _buffer = (char*)calloc(_len, sizeof(char)); + + /* Written through the member so that this object's own accessors describe the packet it built, + not just the bytes on the wire. */ + std::memset(&_lct_header, 0, sizeof(_lct_header)); + _lct_header.version = 1; + _lct_header.half_word_flag = 0; + _lct_header.tsi_flag = 1; + _lct_header.toi_flag = 0; + _lct_header.close_session_flag = 1; + _lct_header.lct_header_len = lct_header_len; + std::memcpy(_buffer, &_lct_header, sizeof(_lct_header)); + + /* The CCI word stays at the zero calloc left. A session running a congestion control building + block has nothing to say in a packet that carries no data. */ + *((uint32_t*)(_buffer + 8)) = htonl(static_cast(tsi)); +} + LibFlute::AlcPacket::~AlcPacket() { if (_buffer) free(_buffer); diff --git a/src/FileDeliveryTable.cpp b/src/FileDeliveryTable.cpp index 5aab2ab0..24dbcfdb 100644 --- a/src/FileDeliveryTable.cpp +++ b/src/FileDeliveryTable.cpp @@ -17,6 +17,7 @@ #include #include "FileDeliveryTable.h" #include "tinyxml2.h" +#include #include #include #include @@ -132,12 +133,41 @@ bool LibFlute::FileDeliveryTable::FileEntry::operator==(const LibFlute::FileDeli etag == other.etag; } -LibFlute::FileDeliveryTable::FileDeliveryTable(uint32_t instance_id, FecOti fec_oti, FdtNamespace fdt_namespace) +LibFlute::FileDeliveryTable::FileDeliveryTable(uint32_t instance_id, FecOti fec_oti, FdtNamespace fdt_namespace, + Profile profile) : _instance_id( instance_id ) , _instance_id_sent( instance_id - 1 ) , _global_fec_oti( fec_oti ) , _fdt_namespace( fdt_namespace ) + , _profile( profile ) { + /* Each 3GPP profile fixes the FDT schema, so the namespace is taken from the profile rather than + from a separate argument that could disagree with it. + + TS 26.517 V18.6.0 clause 6.2.1, for Ts26517: "The MBSTF shall use the Profiled FDT Schema + according to clause L.6 of TS 26.346 [7] to describe the object list currently being + transmitted in the MBS Distribution Session." + + TS 26.346 V18.2.0 clause 7.2.9, for Ts26346: "The extended FLUTE FDT instance schema + defined in clause 7.2.10.1 (based on the one in RFC 3926 [9]) shall be used." + + General FLUTE keeps whatever the caller asked for, RFC 3926 fixing no namespace. */ + /* TS 26.346 V18.2.0 clause 7.2.9: "When the FEC Encoding ID indicates the "Compact No-Code FEC + scheme", the value of this data element shall not exceed 65535, consistent with the 16-bit + constraint on the Encoding Symbol ID". Refused at construction rather than clamped: clamping + would silently repartition the object and leave the operator's configuration unexplained. */ + if (is_3gpp(_profile) && _global_fec_oti.encoding_id == FecScheme::CompactNoCode && + _global_fec_oti.max_source_block_length > 65535) { + throw std::runtime_error( + "FEC-OTI-Maximum-Source-Block-Length exceeds the 65535 the 3GPP profiles allow for the " + "Compact No-Code FEC scheme"); + } + + switch (_profile) { + case Profile::Ts26517: _fdt_namespace = FDT_NS_3GPP_CONSOLIDATED_V2; break; + case Profile::Ts26346: _fdt_namespace = FDT_NS_DRAFT_2005; break; + case Profile::Unprofiled: break; + } } LibFlute::FileDeliveryTable::FileDeliveryTable(uint32_t instance_id, char* buffer, size_t len) @@ -182,6 +212,12 @@ LibFlute::FileDeliveryTable::FileDeliveryTable(uint32_t instance_id, char* buffe _expires = std::stoull(root_ns.findAttribute(fdt_instance, "Expires", fdt_ns)->Value()); + auto complete_attr = root_ns.findAttribute(fdt_instance, "Complete", fdt_ns); + if (complete_attr != nullptr) { + std::string val(complete_attr->Value()); + _complete = (val == "true" || val == "1"); + } + spdlog::debug("Received new FDT with instance ID {}: {}", instance_id, buffer); auto val = root_ns.findAttribute(fdt_instance, "FEC-OTI-FEC-Encoding-ID", fdt_ns); @@ -299,6 +335,18 @@ LibFlute::FileDeliveryTable::FileDeliveryTable(uint32_t instance_id, char* buffe bool no_cache = false; //bool max_stale = false; + /* The File element's own Expires attribute, read into its own member. Previously this was + left at whatever the cache directive said, which made the two indistinguishable on a round + trip and hid the emit-side defect. */ + uint64_t file_expires = 0; + auto file_expires_attr = file_ns.findAttribute(file, "Expires", fdt_ns); + if (file_expires_attr != nullptr) { + /* TS 26.346 V18.2.0 annex L: "When the optional File@Expires attribute is provided, its value + shall take precedence over that of the FDT@Expires attribute." Recorded on the entry; the + effective expiry accessor below applies the precedence so every caller gets it. */ + file_expires = strtoull(file_expires_attr->Value(), nullptr, 0); + } + std::optional cache_expires = std::nullopt; auto cc = file_ns.findChildElement(file, "Cache-Control", mbms2007_ns); if (cc) { @@ -337,7 +385,7 @@ LibFlute::FileDeliveryTable::FileDeliveryTable(uint32_t instance_id, char* buffe content_length, content_md5, content_type, - (cache_expires)?(cache_expires.value()):0, + file_expires, fec_oti, { no_cache, @@ -350,9 +398,82 @@ LibFlute::FileDeliveryTable::FileDeliveryTable(uint32_t instance_id, char* buffe } } +namespace { + auto ntp_seconds_since_epoch() -> uint64_t + { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count() + + 2'208'988'800; /* Unix epoch -> NTP epoch offset, matching Transmitter::seconds_since_epoch() */ + } +} + +void LibFlute::FileDeliveryTable::set_expires(uint64_t exp) +{ + /* RFC 3926 clause 3.3: "A sender MUST use an expiry time in the future upon creation of an FDT + Instance relative to its Sender Current Time (SCT)." An instance created already expired can + never be used to interpret anything, and a receiver following clause 7.2.9 discards it on + arrival, so this is refused rather than sent. */ + const auto now = ntp_seconds_since_epoch(); + if (exp <= now) { + throw std::runtime_error( + "FDT Instance expiry must be in the future. A receiver discards an instance that has " + "already expired, so such a session delivers nothing. See the citation at this check."); + } + _expires = exp; +} + +uint32_t LibFlute::FileDeliveryTable::next_instance_id(uint32_t current, uint64_t current_expires, + uint64_t now, + std::map& expired_instance_ids) +{ + /* RFC 3926 clause 3.4.1: "After reaching the maximum value (2^20-1), the numbering starts again + from '0'." + + That is the whole sequence, and it has no failure case. The same clause recommends, but does + not require, that a sender wait for the previous instance carrying a wraparound ID to expire, + so a reuse that is still live is warned about rather than replaced with a different ID or + turned into an error. RFC 6726 clause 3.4.1 does make that a prohibition, but this library + implements RFC 3926, which TS 26.346 clause L.4.1 references as its FLUTE specification. */ + expired_instance_ids[current] = current_expires; + + if (current < kMaxFdtInstanceId) { + return current + 1; + } + + const auto previous = expired_instance_ids.find(0); + if (previous != expired_instance_ids.cend() && previous->second >= now) { + spdlog::warn("FDT Instance ID wrapping to 0 while the previous instance using it has not yet " + "expired (Expires {}, now {})", previous->second, now); + } + expired_instance_ids.erase(0); + return 0; +} + +auto LibFlute::FileDeliveryTable::advance_instance_id() -> void +{ + _instance_id = next_instance_id(_instance_id, _expires, ntp_seconds_since_epoch(), + _expired_instance_ids); +} + auto LibFlute::FileDeliveryTable::add(const FileEntry& fe) -> void { - if (_instance_id == _instance_id_sent) _instance_id++; + /* The MBMS Download Profile permits exactly one content encoding, and forbids every other. + TS 26.346 V18.2.0 clause L.4.2, second list: "The following FDT attribute, defined at both + the FDT-Instance and File levels, may be carried in the FDT sent by the FLUTE sender, under + either the File-Instance or File element, and shall be supported by the FLUTE receiver:" + the single item there is Content-Encoding set to 'gzip'. The third list of the same clause + then prohibits the attribute "set to a value other than 'gzip'". + + Refused here rather than silently dropped from the emitted FDT. Dropping the attribute would + leave the payload encoded and the receiver with nothing saying so, which is undecodable + content rather than a conformant session; RULES.md rule 12 prefers failing loudly over + quietly adjusting away a caller's misconfiguration. Absent is fine: the attribute is a may. */ + if (is_3gpp(_profile) && !fe.content_encoding.empty() && fe.content_encoding != "gzip") { + throw std::invalid_argument( + "Content-Encoding must be absent or gzip in the MBMS Download Profile, got: " + + fe.content_encoding + ". Use Profile::Unprofiled for a non-3GPP session."); + } + if (_instance_id == _instance_id_sent) advance_instance_id(); _file_entries.push_back(fe); } @@ -365,7 +486,7 @@ auto LibFlute::FileDeliveryTable::remove(uint32_t toi) -> void ++it; } } - if (_instance_id == _instance_id_sent) _instance_id++; + if (_instance_id == _instance_id_sent) advance_instance_id(); } auto LibFlute::FileDeliveryTable::to_string() const -> std::string { @@ -393,8 +514,52 @@ auto LibFlute::FileDeliveryTable::to_string() const -> std::string { break; } root->SetAttribute("Expires", std::to_string(_expires).c_str()); + /* The Complete attribute is permitted by RFC 3926 clause 3.4.2, which makes it optional on the + FDT-Instance element, but the MBMS Download Profile forbids a sender using it. + TS 26.346 V18.2.0 clause L.4.3: "The following parameters, defined at the FDT-Instance level, + shall not be used by the FLUTE sender:" + Complete is the first item of that list. + + Sender-only. The parser above is deliberately untouched, because the same clause's NOTE makes + receiver support for this one mandatory: "With the exception of Complete, which is mandatory, + these parameters are optional to support by the FLUTE receiver." Reading the prohibition as + binding both directions would break reception from a conformant peer. */ + if (_complete && !is_3gpp(_profile)) root->SetAttribute("Complete", "true"); root->SetAttribute("FEC-OTI-FEC-Encoding-ID", (unsigned)_global_fec_oti.encoding_id); - if (_global_fec_oti.instance_id) root->SetAttribute("FEC-OTI-FEC-Instance-ID", (unsigned)_global_fec_oti.instance_id); + /* The existing guard is on the value, not on the profile: it withholds the attribute only when + the instance ID happens to be 0. The MBMS Download Profile forbids it outright, at both + levels, whatever the value. + TS 26.346 V18.2.0 clause L.4.2, third list: "The following FDT parameters, defined at both + the FDT-Instance and File levels, shall not be used by the FLUTE sender, in either the + File-Instance or File element:" + FEC-OTI-FEC-Instance-ID is the second item, annotated there as not applicable to the + Release 9 FEC schemes. Sender-only: that clause's NOTE 2 leaves these "optional to support + by the FLUTE receiver", so both parsers stay. */ + /* Stricter than the profile: the FEC building block forbids this element outright for the + schemes this library implements, so it is withheld in both profiles rather than only under + the 3GPP one. + RFC 5052 clause 6.2.4: "The FEC Instance ID MUST be used by all Under-Specified FEC schemes + and MUST NOT be used by Fully-Specified FEC Schemes." + + Both schemes here are Fully-Specified, stated by their own defining documents. + RFC 3695: "This document also describes the Fully-Specified FEC scheme corresponding to FEC + Encoding ID 0." + RFC 5053: "The Raptor FEC Scheme is a Fully-Specified FEC Scheme corresponding to FEC + Encoding ID 1." + + The 3GPP profile forbids it too, so this satisfies that as well. + TS 26.346 V18.2.0 clause L.4.2, third list: "The following FDT parameters, defined at both + the FDT-Instance and File levels, shall not be used by the FLUTE sender, in either the + File-Instance or File element:" + FEC-OTI-FEC-Instance-ID is the second item, annotated there as not applicable to the + Release 9 FEC schemes. + + Sender-only, so both parsers stay. The member is kept rather than removed because an + Under-Specified scheme would need it, and removing it would erase the reason it exists + (rule 14). + TS 26.346 V18.2.0 clause L.4.2, NOTE 2: "These parameters are optional to support by the + FLUTE receiver." */ + (void)_global_fec_oti.instance_id; // never emitted: see above root->SetAttribute("FEC-OTI-Maximum-Source-Block-Length", (unsigned)_global_fec_oti.max_source_block_length); root->SetAttribute("FEC-OTI-Encoding-Symbol-Length", (unsigned)_global_fec_oti.encoding_symbol_length); root->SetAttribute("xmlns:mbms2007", "urn:3GPP:metadata:2007:MBMS:FLUTE:FDT"); // 3GPP TS 26.346 Clause 7.2.10.2 @@ -406,14 +571,28 @@ auto LibFlute::FileDeliveryTable::to_string() const -> std::string { f->SetAttribute("TOI", file.toi); f->SetAttribute("Content-Location", file.content_location.c_str()); f->SetAttribute("Content-Length", file.content_length); - if (file.fec_oti.transfer_length) f->SetAttribute("Transfer-Length", file.fec_oti.transfer_length); + /* TS 26.346 V18.2.0 clause L.4.4, on the File-level attributes, fourth list: + "The following attributes shall not be carried in the FDT sent by the FLUTE sender:" + Transfer-Length is the first item of that list. + + The prohibition binds a sender operating the MBMS Download Profile, which is what + Profile::Ts26517 selects. Under Profile::Unprofiled the session is plain RFC 3926, where + the attribute is permitted, so it is kept. Keyed on the profile rather than on the FDT + namespace because the namespace says which schema is emitted, not which obligations apply. + + The parser below is deliberately unchanged, because the same clause's NOTE keeps this one + mandatory for receivers: "With the exception of Transfer-Length, which is mandatory, these + parameters are optional to support by the FLUTE receiver." Nothing is lost on the wire either: + the receive path falls back to Content-Length when the attribute is absent. */ + if (!is_3gpp(_profile) && file.fec_oti.transfer_length) + f->SetAttribute("Transfer-Length", file.fec_oti.transfer_length); if (!file.content_md5.empty()) f->SetAttribute("Content-MD5", file.content_md5.c_str()); if (!file.content_encoding.empty()) f->SetAttribute("Content-Encoding", file.content_encoding.c_str()); if (!file.content_type.empty()) f->SetAttribute("Content-Type", file.content_type.c_str()); if (file.fec_oti.encoding_id != _global_fec_oti.encoding_id) f->SetAttribute("FEC-OTI-FEC-Encoding-ID", (unsigned)file.fec_oti.encoding_id); - if (file.fec_oti.instance_id != 0 && file.fec_oti.instance_id != _global_fec_oti.instance_id) - f->SetAttribute("FEC-OTI-FEC-Instance-ID", (unsigned)file.fec_oti.instance_id); + // Same RFC 5052 clause 6.2.4 prohibition as at the FDT-Instance level above, and the same + // clause L.4.2 one, applied at the File level. Never emitted for a Fully-Specified scheme. if (file.fec_oti.max_source_block_length != 0 && file.fec_oti.max_source_block_length != _global_fec_oti.max_source_block_length) f->SetAttribute("FEC-OTI-Maximum-Source-Block-Length", (unsigned)file.fec_oti.max_source_block_length); @@ -421,6 +600,11 @@ auto LibFlute::FileDeliveryTable::to_string() const -> std::string { file.fec_oti.encoding_symbol_length != _global_fec_oti.encoding_symbol_length) f->SetAttribute("FEC-OTI-Encoding-Symbol-Length", (unsigned)file.fec_oti.encoding_symbol_length); if (!file.etag.empty()) f->SetAttribute("mbms2012:File-ETag", file.etag.c_str()); + /* FileType's own Expires attribute, use="optional" in the annex L.6.1 profiled schema, so it + is emitted only when the caller set one and omitted otherwise. It was never emitted before, + which meant a File-level expiry could be set through the API and silently not reach the + wire. Distinct from the cache directive below. */ + if (file.expires) f->SetAttribute("Expires", std::to_string(file.expires).c_str()); if (file.cache_control.no_cache || file.cache_control.cache_expires) { auto cc = doc.NewElement("mbms2007:Cache-Control"); if (file.cache_control.no_cache) { @@ -428,8 +612,13 @@ auto LibFlute::FileDeliveryTable::to_string() const -> std::string { noc->SetText("true"); cc->InsertEndChild(noc); } else { + /* From the cache-control member, not from the File element's own Expires. The two are + different things: CacheControlType in the annex L.6.1 profiled schema is an xs:choice + of no-cache, max-stale and Expires, and its Expires is a caching directive, while + FileType's Expires attribute says when the file itself stops being valid. Taking this + from file.expires made the emitted directive whatever the file expiry happened to be. */ auto exp = doc.NewElement("mbms2007:Expires"); - exp->SetText(std::to_string(file.expires).c_str()); + exp->SetText(std::to_string(file.cache_control.cache_expires.value()).c_str()); cc->InsertEndChild(exp); } f->InsertEndChild(cc); @@ -438,6 +627,27 @@ auto LibFlute::FileDeliveryTable::to_string() const -> std::string { } + /* Both 3GPP schemas make schemaVersion a mandatory child element of FDT-Instance, placed after + the File elements, and each fixes its own value. Omitting it, or emitting the other schema's + value, produces a document that does not validate against the schema it declares. + + Keyed on the FDT namespace, because this belongs to the schema being emitted and is meaningless + in a document that declares neither. + + TS 26.346 V18.2.0 clause L.6.3, for the annex L.6.1 profiled schema: "The BM-SC shall set the + schemaVersion element to 2 in all instance documents" + + TS 26.346 V18.2.0 clause 7.2.10.1, for the extended schema of that clause: "In this version of + the present document the network shall set the content of the schemaVersion element, defined as + a child of the FDT-Instance element, to the value 4." + + The delimiter element is deliberately not emitted: neither schema's sequence contains one. */ + if (_fdt_namespace == FDT_NS_3GPP_CONSOLIDATED_V2 || _fdt_namespace == FDT_NS_DRAFT_2005) { + auto sv = doc.NewElement("schemaVersion"); + sv->SetText(_fdt_namespace == FDT_NS_3GPP_CONSOLIDATED_V2 ? 2 : 4); + root->InsertEndChild(sv); + } + tinyxml2::XMLPrinter printer; doc.Print(&printer); return std::string(printer.CStr()); diff --git a/src/IpSec.cpp b/src/IpSec.cpp index 1af79b39..ec653fec 100644 --- a/src/IpSec.cpp +++ b/src/IpSec.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include "spdlog/spdlog.h" #include @@ -29,8 +30,39 @@ #include "IpSec.h" #include +/* The one-shot SHA256() API is deprecated in later OpenSSL versions. Transmitter.cpp uses the + equally-deprecated one-shot MD5() for the same reason, so the two stay consistent. */ +#define OPENSSL_SUPPRESS_DEPRECATED 1 +#include + namespace LibFlute::IpSec { - void configure_policy(uint32_t spi, const std::string& dest_address, Direction direction) + namespace { + // A colon unambiguously identifies IPv6 text notation (dotted-decimal IPv4 never contains + // one) -- avoids pulling in a full address-parsing library just to pick a netlink family. + bool is_ipv6_address(const std::string& address) { + return address.find(':') != std::string::npos; + } + + // Fills in an xfrm_address_t's a4 (IPv4) or a6 (IPv6) member, and returns the address + // family/prefix length pair the caller should set alongside it -- the two are always used + // together (sel.family+sel.daddr, tmpl.family+tmpl.id.daddr, xsinfo.family+xsinfo.id.daddr), + // so keeping the parse and the family selection in one place avoids them silently drifting + // apart if only one were updated in a future edit. + struct addr_family_info { int family; uint8_t prefixlen; }; + addr_family_info fill_xfrm_address(xfrm_address_t& addr, const std::string& text_address) { + if (is_ipv6_address(text_address)) { + if (inet_pton(AF_INET6, text_address.c_str(), &addr.a6) != 1) { + throw std::runtime_error("Invalid IPv6 address: " + text_address); + } + return {AF_INET6, 128}; + } + addr.a4 = inet_addr(text_address.c_str()); + return {AF_INET, 32}; + } + } + + void configure_policy(uint32_t spi, const std::string& dest_address, unsigned short dest_port, + Direction direction) { struct nl_sock *sk; struct nl_msg *msg; @@ -42,22 +74,49 @@ namespace LibFlute::IpSec { xpinfo.lft.hard_packet_limit = XFRM_INF; xpinfo.dir = (direction == Direction::In) ? XFRM_POLICY_IN : XFRM_POLICY_OUT; - xpinfo.sel.family = AF_INET; - xpinfo.sel.saddr.a4 = INADDR_ANY; - xpinfo.sel.daddr.a4 = inet_addr(dest_address.c_str()); - xpinfo.sel.prefixlen_d = 32; + // sel.saddr is left all-zero (INADDR_ANY for v4, "::" for v6 -- the same all-zero + // xfrm_address_t union represents both) regardless of family: this policy selector matches + // any source address, only the destination is pinned. + auto dest_info = fill_xfrm_address(xpinfo.sel.daddr, dest_address); + xpinfo.sel.family = dest_info.family; + xpinfo.sel.prefixlen_d = dest_info.prefixlen; + + /* The selector names the protocol and the session's own UDP port, not the destination address + alone. Without them the policy captures every datagram to that group whatever it is for, so + another session sharing the group on a different port, or any other protocol addressed + there, would be pushed through this association too. + + RFC 5775 clause 5.1.1: + "The sender IPsec SPD entry MUST be configured to process outbound packets to the + destination address and UDP port number of the applicable ALC session." + + RFC 5775 clause 5.1.2.1: + "The implementation MUST be able to use the source address, destination address, protocol + (UDP), and UDP port numbers as selectors in the SPD." + + Brought into FLUTE by the version 2 specification. + RFC 6726 clause 7.5: + "Since FLUTE relies on ALC/LCT, it inherits the "baseline secure ALC operation" of + [RFC5775]." + + The source port is deliberately not selected on. A sender's source port is not part of the + session description, so pinning it would exclude legitimate traffic; the destination pair is + what identifies the session's channel. */ + xpinfo.sel.proto = IPPROTO_UDP; + xpinfo.sel.dport = htons(dest_port); + xpinfo.sel.dport_mask = 0xFFFF; struct xfrm_user_tmpl tmpl = {}; - tmpl.id.daddr.a4 = inet_addr(dest_address.c_str()); + fill_xfrm_address(tmpl.id.daddr, dest_address); tmpl.id.spi = htonl(spi); tmpl.id.proto = IPPROTO_ESP; - tmpl.saddr.a4 = INADDR_ANY; + // tmpl.saddr left all-zero, same reasoning as sel.saddr above. tmpl.reqid = spi; tmpl.mode = XFRM_MODE_TRANSPORT; tmpl.aalgos = (~(__u32)0); tmpl.ealgos = (~(__u32)0); tmpl.calgos = (~(__u32)0); - tmpl.family = AF_INET; + tmpl.family = dest_info.family; msg = nlmsg_alloc_simple(XFRM_MSG_UPDPOLICY, 0); nlmsg_append(msg, &xpinfo, sizeof(xpinfo), NLMSG_ALIGNTO); @@ -67,24 +126,26 @@ namespace LibFlute::IpSec { nl_connect(sk, NETLINK_XFRM); nl_send_auto(sk, msg); nlmsg_free(msg); + /* Without this the netlink socket and its fd leak on every call. */ + nl_socket_free(sk); } - void configure_state(uint32_t spi, const std::string& dest_address, Direction direction, const std::string& key) + void configure_state(uint32_t spi, const std::string& dest_address, Direction direction, const std::string& key, + const std::string& auth_key) { struct nl_sock *sk; struct nl_msg *msg; struct xfrm_usersa_info xsinfo = {}; - xsinfo.sel.family = AF_INET; - xsinfo.sel.saddr.a4 = INADDR_ANY; - xsinfo.sel.daddr.a4 = inet_addr(dest_address.c_str()); - xsinfo.sel.prefixlen_d = 32; - - xsinfo.id.daddr.a4 = inet_addr(dest_address.c_str()); + // sel.saddr and (further below) xsinfo.saddr are left all-zero -- same reasoning as + // configure_policy() above, this SA's own source isn't pinned to a specific address. + auto dest_info = fill_xfrm_address(xsinfo.sel.daddr, dest_address); + xsinfo.sel.family = dest_info.family; + xsinfo.sel.prefixlen_d = dest_info.prefixlen; + + fill_xfrm_address(xsinfo.id.daddr, dest_address); xsinfo.id.spi = htonl(spi); xsinfo.id.proto = IPPROTO_ESP; - - xsinfo.saddr.a4 = INADDR_ANY; xsinfo.lft.soft_byte_limit = XFRM_INF; xsinfo.lft.hard_byte_limit = XFRM_INF; @@ -92,7 +153,7 @@ namespace LibFlute::IpSec { xsinfo.lft.hard_packet_limit = XFRM_INF; xsinfo.reqid = spi; - xsinfo.family = AF_INET; + xsinfo.family = dest_info.family; xsinfo.mode = XFRM_MODE_TRANSPORT; std::vector algo_buf(sizeof(struct xfrm_algo) + 512, 0); @@ -109,19 +170,58 @@ namespace LibFlute::IpSec { algo->alg_key_len = binary_key.size() * 8; memcpy(algo->alg_key, &binary_key[0], binary_key.size()); + /* RFC 4303 clause 1: "Using encryption without a strong integrity mechanism on top of it + (either in ESP or separately via AH) may render the confidentiality service insecure against + some forms of active attacks". The same clause makes confidentiality with integrity a MUST + for an ESP implementation and confidentiality alone a MAY, and RFC 3926 clause 7 recommends + packet level authentication for a FLUTE session, which this is the only means of providing. + So HMAC-SHA256 is attached alongside the AES encryption above. Neither document names an + algorithm; HMAC-SHA256 is an engineering choice, not a quoted requirement. */ + std::vector auth_algo_buf(sizeof(struct xfrm_algo) + 512, 0); + auto* auth_algo = reinterpret_cast(auth_algo_buf.data()); + + std::vector auth_key_bytes; + if (!auth_key.empty()) { + for (unsigned int i = 0; i < auth_key.length(); i += 2) { + auth_key_bytes.push_back((unsigned char)strtol(auth_key.substr(i, 2).c_str(), nullptr, 16)); + } + } else { + /* A caller that supplies one key predates this parameter. Deriving the authentication key + from the encryption key gives such a caller integrity protection without an API break, + and gives the two algorithms distinct key bytes. It is weaker than two independent keys, + which is why the parameter exists. */ + static const std::string context = "libflute-ipsec-auth-key-v1"; + std::vector input(binary_key.begin(), binary_key.end()); + input.insert(input.end(), context.begin(), context.end()); + unsigned char digest[SHA256_DIGEST_LENGTH]; + SHA256(input.data(), input.size(), digest); + auth_key_bytes.assign(digest, digest + SHA256_DIGEST_LENGTH); + } + if (auth_key_bytes.size() > 512) { + throw std::runtime_error("Authentication key is too long"); + } + strcpy(auth_algo->alg_name, "hmac(sha256)"); + auth_algo->alg_key_len = auth_key_bytes.size() * 8; + memcpy(auth_algo->alg_key, auth_key_bytes.data(), auth_key_bytes.size()); + msg = nlmsg_alloc_simple(XFRM_MSG_NEWSA, 0); nlmsg_append(msg, &xsinfo, sizeof(xsinfo), NLMSG_ALIGNTO); nla_put(msg, XFRMA_ALG_CRYPT, algo_buf.size(), algo); + nla_put(msg, XFRMA_ALG_AUTH, auth_algo_buf.size(), auth_algo); sk = nl_socket_alloc(); nl_connect(sk, NETLINK_XFRM); nl_send_auto(sk, msg); nlmsg_free(msg); + /* Without this the netlink socket and its fd leak on every call. */ + nl_socket_free(sk); } - void enable_esp(uint32_t spi, const std::string& dest_address, Direction direction, const std::string& key) + void enable_esp(uint32_t spi, const std::string& dest_address, unsigned short dest_port, + Direction direction, const std::string& key, + const std::string& auth_key) { - configure_state(spi, dest_address, direction, key); - configure_policy(spi, dest_address, direction); + configure_state(spi, dest_address, direction, key, auth_key); + configure_policy(spi, dest_address, dest_port, direction); } }; diff --git a/src/Receiver.cpp b/src/Receiver.cpp index d79b3f6d..70be42a8 100644 --- a/src/Receiver.cpp +++ b/src/Receiver.cpp @@ -13,6 +13,7 @@ // See the License for the specific language governing permissions and limitations // under the License. // +#include #include "Receiver.h" #include "AlcPacket.h" #include @@ -72,6 +73,7 @@ LibFlute::Receiver::Receiver ( const std::string& iface, const std::string& addr : _socket(io_context) , _tsi(tsi) , _mcast_address(address) + , _mcast_port(static_cast(port)) { // Restored alongside the ANY-bind/specific-interface-join fixes below: // an earlier version of those fixes made this whole constructor IPv4 @@ -101,6 +103,10 @@ LibFlute::Receiver::Receiver ( const std::string& iface, const std::string& addr _socket.set_option(boost::asio::socket_base::receive_buffer_size(16*1024*1024)); _socket.bind(listen_endpoint); + if (!source_address.empty()) { + _expected_source = boost::asio::ip::make_address(source_address); + } + if (!source_address.empty()) { // Source-specific multicast (SSM, RFC 4607): admits only packets from source_address, // as indicated by an SDP a=source-filter line (RFC 4570; TS 26.517 cl.6.2.2.3's own @@ -191,9 +197,19 @@ auto LibFlute::Receiver::arm_receive() -> void }); } -auto LibFlute::Receiver::enable_ipsec(uint32_t spi, const std::string& key) -> void +namespace { + /** Current time on the NTP epoch, matching the base the FDT's Expires attribute uses. */ + auto ntp_seconds_now() -> uint64_t + { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count() + 2'208'988'800; + } +} + +auto LibFlute::Receiver::enable_ipsec(uint32_t spi, const std::string& key, const std::string& auth_key) -> void { - LibFlute::IpSec::enable_esp(spi, _mcast_address, LibFlute::IpSec::Direction::In, key); + LibFlute::IpSec::enable_esp(spi, _mcast_address, _mcast_port, LibFlute::IpSec::Direction::In, + key, auth_key); } auto LibFlute::Receiver::handle_receive_from(const boost::system::error_code& error, @@ -204,13 +220,86 @@ auto LibFlute::Receiver::handle_receive_from(const boost::system::error_code& er if (!error) { spdlog::trace("Received {} bytes", bytes_recvd); + /* The source is checked before the packet is parsed, so traffic that is not this session's + cannot reach the parser at all, let alone influence how a parse failure is handled. + + RFC 3450 clause 4.5 orders the receiver's steps and puts this one before any processing of the + payload: "The receiver MUST verify that the sender IP address together with the TSI carried in + the header matches one of the (sender IP address, TSI) pairs that was received in a Session + Description and that the receiver is currently joined to." + + Only checkable where the caller named the source. A source-specific join already has the + kernel filtering on it, so this is defence in depth there against a routing or membership + mistake; for an any-source session the library has no source to compare against and the + obligation cannot be met, which is recorded as a limitation rather than passed over. */ + if (_expected_source && _sender_endpoint.address() != *_expected_source) { + spdlog::warn("Discarding packet from {}, which is not this session's source {}", + _sender_endpoint.address().to_string(), _expected_source->to_string()); + arm_receive(); + return; + } + try { auto alc = LibFlute::AlcPacket(_data, bytes_recvd); if (alc.tsi() == _tsi) { + if (_close_cb && (alc.close_session_flag() || alc.close_object_flag())) { + _close_cb(alc.close_session_flag(), alc.close_object_flag(), alc.toi()); + } + + /* A packet may legitimately carry nothing, and one that does carries no FEC Payload ID + either, so there is nothing here to reassemble. + + RFC 3450 clause 4.1: "In some special cases an ALC sender may need to produce ALC + packets that do not contain any payload." + The same clause says how to tell: "The total datagram length, conveyed by outer protocol + headers (e.g., the IP or UDP header), enables receivers to detect the absence of the ALC + payload and FEC Payload ID." + + FLUTE gives this shape a specific meaning and a specific header. + RFC 3926 clause 3.1: "the exception that ALC packets sent in a FLUTE session with the + Close Session (A) flag set to 1 (signaling the end of the session) and that contain no + payload (carrying no information for any file or FDT) SHALL NOT carry the TOI" + + Falling through was not merely useless. With no TOI the decoded value is zero, so such a + packet was taken for an FDT packet and restarted FDT reassembly, discarding the instance + in progress; then the payload walk subtracted a four-byte FEC Payload ID from a length of + zero, wrapped, and read far past the buffer. One datagram from a conformant peer ending + its session, and RFC 3926 clause 3.1 is what makes such a peer send one. */ + const size_t payload_len = bytes_recvd - alc.header_length(); + if (payload_len == 0) { + arm_receive(); + return; + } + + /* Anything shorter than a FEC Payload ID is not a valid packet, and step 1 of the receiver + procedure disposes of it before the payload is touched. + RFC 3450 clause 4.5: "The receiver MUST parse the packet header and verify that it is a + valid header. If it is not valid then the packet MUST be discarded without further + processing." */ + if (payload_len < 4) { + spdlog::warn("Discarding a {}-byte payload, too short to hold a FEC Payload ID", + payload_len); + arm_receive(); + return; + } + const std::lock_guard lock(_files_mutex); + /* An expired FDT Instance may not be used to interpret anything that arrives after it. + TS 26.346 V18.2.0 clause 7.2.9: "For MBMS operation, the UE shall not use a received FDT + Instance to interpret packets received beyond the expiration time of the FDT Instance." + The same clause records that this is stricter than RFC 3926, which says only that the + receiver SHOULD NOT, so the held instance is dropped here and packets for TOIs it + described stop being interpreted until a fresh instance arrives. Reception of the next + FDT itself, on TOI 0, is unaffected. */ + if (_fdt && _fdt->expired(ntp_seconds_now())) { + spdlog::debug("Discarding FDT instance {}, expired at {}", _fdt->instance_id(), + _fdt->expires()); + _fdt.reset(); + } + if (alc.toi() == 0 && (!_fdt || _fdt->instance_id() != alc.fdt_instance_id())) { // (Re)start reception of the FDT (TOI 0) for THIS instance. The FDT is // a FLUTE object reassembled from its symbols like any file, but unlike @@ -231,10 +320,20 @@ auto LibFlute::Receiver::handle_receive_from(const boost::system::error_code& er } } + if (alc.toi() != 0 && _files.find(alc.toi()) == _files.end() && alc.has_fec_oti()) { + // No entry for this TOI yet (the FDT describing it hasn't arrived, or won't -- + // RFC 3926 clause 5 makes EXT_FTI support mandatory for a receiver on any TOI other + // reception doesn't have to wait on that). Bootstrap the FEC OTI straight from this + // packet instead of discarding it; content_location is filled in later, either from + // the FDT once it arrives (see the merge below) or left blank if it never does. + FileDeliveryTable::FileEntry fe{static_cast(alc.toi()), "", static_cast(alc.fec_oti().transfer_length), "", "", 0, alc.fec_oti()}; + _files[alc.toi()] = std::make_shared(fe); + } + if (_files.find(alc.toi()) != _files.end() && !_files[alc.toi()]->complete()) { auto encoding_symbols = LibFlute::EncodingSymbol::from_payload( _data + alc.header_length(), - bytes_recvd - alc.header_length(), + payload_len, _files[alc.toi()]->fec_oti(), alc.content_encoding()); @@ -247,7 +346,12 @@ auto LibFlute::Receiver::handle_receive_from(const boost::system::error_code& er if (_files[alc.toi()]->complete()) { for (auto it = _files.cbegin(); it != _files.cend();) { - if (it->second.get() != file && it->second->meta().content_location == file->meta().content_location) + // An empty content location is not an identifying URL. It is what the TOI 0 FDT's + // own transient file carries, and what a file bootstrapped from a packet's own + // EXT_FTI carries until its FDT entry arrives. Matching on it would erase an + // unrelated bootstrapped file the moment the FDT completed, which is every time. + if (it->second.get() != file && !file->meta().content_location.empty() && + it->second->meta().content_location == file->meta().content_location) { spdlog::debug("Replacing file with TOI {}", it->first); it = _files.erase(it); @@ -274,7 +378,14 @@ auto LibFlute::Receiver::handle_receive_from(const boost::system::error_code& er for (const auto& file_entry : _fdt->file_entries()) { // automatically receive all files in the FDT auto existing_file = _files.find(file_entry.toi); - if (existing_file != _files.end() && + if (existing_file != _files.end() && existing_file->second->meta().content_location.empty() && + !existing_file->second->complete()) { + // Reception for this TOI was bootstrapped from a packet's own EXT_FTI before + // this FDT arrived (content_location wasn't known yet) -- this is that same + // in-progress transfer, not a stale one. Adopt the FDT's metadata in place + // rather than discarding and restarting it. + existing_file->second->adopt_fdt_metadata(file_entry); + } else if (existing_file != _files.end() && existing_file->second->meta().content_location != file_entry.content_location) { // TOI numbers get reused across FDT instances (the live window // rolls forward). If a File is still sitting here incomplete diff --git a/src/Transmitter.cpp b/src/Transmitter.cpp index 119e9feb..4ee8eeda 100644 --- a/src/Transmitter.cpp +++ b/src/Transmitter.cpp @@ -53,6 +53,13 @@ static void create_udp_pkt( char *udp_buffer, const boost::asio::ip::udp::endpoi static void create_ip_hdr( char *ip_buffer, const boost::asio::ip::udp::endpoint &endpoint, size_t pkt_size, const boost::asio::ip::address &local_address ); static uint16_t calculate_sum( const uint8_t *buffer, size_t len ); + +/** Fixed IP header length for the family in use: 20 bytes for an IPv4 header without options, + * 40 for an IPv6 header, which is fixed length and needs no extension header here. + * + * RFC 8200 clause 8.3: "an upper-layer protocol must take into account the larger size of the + * IPv6 header relative to the IPv4 header." */ +static size_t ip_header_length(bool is_v6) { return is_v6 ? 40 : 20; } static void write_uint16_be( uint8_t *buffer, uint16_t value ); static void write_uint32_be( uint8_t *buffer, uint32_t value ); @@ -361,7 +368,15 @@ Transmitter::FileDescription &Transmitter::FileDescription::set_expiry_time( { auto diff = std::chrono::duration_cast(expiry_time - _get_ntp_epoch()); _file_entry.expires = diff.count(); - _file_entry.cache_control.cache_expires = _file_entry.expires; + + return *this; +} + +Transmitter::FileDescription &Transmitter::FileDescription::set_cache_expiry_time( + const Transmitter::FileDescription::date_time_type &expiry_time) +{ + auto diff = std::chrono::duration_cast(expiry_time - _get_ntp_epoch()); + _file_entry.cache_control.cache_expires = diff.count(); return *this; } @@ -477,7 +492,8 @@ Transmitter::Transmitter ( const std::string& destination_address, short port, boost::asio::io_context& io_context, const std::optional &tunnel_endpoint, Transmitter::FdtNamespace fdt_namespace, bool active, - const std::optional &source_address ) + const std::optional &source_address, + Profile profile ) : _endpoint(boost::asio::ip::make_address(destination_address), port) , _source_address() , _socket(io_context, _endpoint.protocol()) @@ -493,18 +509,36 @@ Transmitter::Transmitter ( const std::string& destination_address, short port, , _tunnel_endpoint(tunnel_endpoint) , _tunnel_local_address() , _active(active) + , _profile(profile) { + /* The 3GPP profiles fix the TSI field at its narrowest width, so a value that would need the + wider encoding cannot be signalled under either of them. This is a clause 7.2 rule, binding on + MBMS download generally, not one of annex L.4's profile restrictions. + + TS 26.346 V18.2.0 clause 7.2.7: "-The Transmission Session Identifier (TSI) field shall be of + length 16 bits (S=0, H=1, 16 bits)." + + Outside the profile RFC 3451 permits 16, 32 or 48 bits and the wider encoding is used, which is + what the TSI widening on this branch is for. Refused rather than truncated, since truncation + puts the session on an identifier nobody configured, and rather than widened, since that emits + a header the profile forbids. */ + if (is_3gpp(_profile) && tsi > 0xFFFF) { + throw std::runtime_error( + "TSI does not fit the 16-bit field TS 26.346 clause 7.2.7 fixes for it; use a TSI of 65535 " + "or less, or Profile::Unprofiled where RFC 3451 permits the wider encoding"); + } + if (source_address) { _source_address = boost::asio::ip::make_address(source_address.value()); } _max_payload = mtu - - 20 - // IPv4 header + ip_header_length(_endpoint.address().is_v6()) - // IP header, v4 or v6 8 - // UDP header 32 - // ALC Header with EXT_FDT and EXT_FTI 4; // SBN and ESI for compact no-code FEC if (_tunnel_endpoint.has_value()) { // Remove extra overhead for UDP tunnelling, if set - _max_payload -= 20 + // IPv4 header + _max_payload -= ip_header_length(_endpoint.address().is_v6()) + // IP header, v4 or v6 8; // UDP header boost::asio::ip::udp::socket local_socket(_io_context, _tunnel_endpoint.value().protocol()); local_socket.connect(_tunnel_endpoint.value()); @@ -515,7 +549,12 @@ Transmitter::Transmitter ( const std::string& destination_address, short port, _socket.set_option(boost::asio::ip::multicast::enable_loopback(true)); _socket.set_option(boost::asio::ip::udp::socket::reuse_address(true)); - if (_source_address && !_tunnel_endpoint) { + /* A tunnelled session still sends an untunnelled copy to the real multicast destination, and + that copy has to originate from the configured source address too, or a receiver filtering + on the announced source (an SDP a=source-filter, say) never matches it. Binding was skipped + whenever a tunnel was configured, on the assumption the socket only ever reached the tunnel + endpoint. */ + if (_source_address) { _socket.bind(boost::asio::ip::udp::endpoint(_source_address.value(),0)); } @@ -523,7 +562,7 @@ Transmitter::Transmitter ( const std::string& destination_address, short port, .encoding_id = FecScheme::CompactNoCode, .encoding_symbol_length = _max_payload, .max_source_block_length = max_source_block_length}; - _fdt = std::make_unique(1, _fec_oti, fdt_namespace); + _fdt = std::make_unique(1, _fec_oti, fdt_namespace, profile); if (_active) { start_fdt_repeat_timer(); @@ -555,13 +594,13 @@ auto Transmitter::udp_tunnel_address(std::optional Tran auto Transmitter::source_address(const std::optional &source_address) -> Transmitter& { _source_address = source_address; - if (_source_address && !_tunnel_endpoint) { + /* A tunnelled session still sends an untunnelled copy to the real multicast destination, and + that copy has to originate from the configured source address too, or a receiver filtering + on the announced source (an SDP a=source-filter, say) never matches it. Binding was skipped + whenever a tunnel was configured, on the assumption the socket only ever reached the tunnel + endpoint. */ + if (_source_address) { _socket.bind(boost::asio::ip::udp::endpoint(_source_address.value(),0)); } return *this; @@ -607,15 +651,21 @@ auto Transmitter::source_address(const std::optional & auto Transmitter::source_address(std::optional &&source_address) -> Transmitter& { _source_address = std::move(source_address); - if (_source_address && !_tunnel_endpoint) { + /* A tunnelled session still sends an untunnelled copy to the real multicast destination, and + that copy has to originate from the configured source address too, or a receiver filtering + on the announced source (an SDP a=source-filter, say) never matches it. Binding was skipped + whenever a tunnel was configured, on the assumption the socket only ever reached the tunnel + endpoint. */ + if (_source_address) { _socket.bind(boost::asio::ip::udp::endpoint(_source_address.value(),0)); } return *this; } -auto Transmitter::enable_ipsec(uint32_t spi, const std::string& key) -> void +auto Transmitter::enable_ipsec(uint32_t spi, const std::string& key, const std::string& auth_key) -> void { - IpSec::enable_esp(spi, _mcast_address, IpSec::Direction::Out, key); + IpSec::enable_esp(spi, _mcast_address, _endpoint.port(), IpSec::Direction::Out, key, + auth_key); } auto Transmitter::handle_send_to(const boost::system::error_code& error) -> void @@ -759,12 +809,21 @@ auto Transmitter::file_transmitted(uint32_t toi) -> void } } + bool drained = false; { std::lock_guard guard(_files_mutex); - if (_deactivate_when_all_files_sent && _files.empty()) { + drained = _files.empty(); + if (_deactivate_when_all_files_sent && drained) { _complete_deactivation(); } } + + /* The last packet with the flag set has now gone out, but a receiver that lost it has no other + way to learn the session ended: once the file set empties, send_fdt() has nothing to repeat. + One data-less packet, once. */ + if (_session_closing && drained) { + send_close_session_packet(); + } } auto Transmitter::send_next_packet() -> void @@ -791,49 +850,66 @@ auto Transmitter::send_next_packet() -> void for(const auto& symbol : symbols) { spdlog::debug("sending TOI {} SBN {} ID {}", file->meta().toi, symbol.source_block_number(), symbol.id() ); } - auto packet = std::make_shared(_tsi, file->meta().toi, file->meta().fec_oti, symbols, _max_payload, file->fdt_instance_id()); + auto packet = std::make_shared(_tsi, file->meta().toi, file->meta().fec_oti, symbols, _max_payload, file->fdt_instance_id(), + _session_closing, _closing_objects.count(file->meta().toi) > 0); bytes_queued += packet->size(); - boost::asio::ip::udp::endpoint send_endpoint; - const char *data = nullptr; - size_t data_size = 0; - std::shared_ptr> tunnel_data; + /* A tunnel is an additional path, not a replacement for the announced one. Sending only the + encapsulated copy leaves a receiver that joins the announced destination directly, rather + than sitting behind the tunnel's decapsulation, with no packets at all. Both copies go out + when a tunnel is configured, and completion is tracked from the tunnelled send, which is + the primary path in that configuration; the plain copy is fire-and-forget. Without a + tunnel the plain send is the only one, and it carries the completion. */ if (_tunnel_endpoint) { - send_endpoint = _tunnel_endpoint.value(); - data_size = packet->size() + 20 /* IP header */ + 8 /* UDP header */; - // Own the encapsulated packet buffer via a shared_ptr held by the - // async_send_to completion lambda below, so it stays alive until the - // send actually completes (a raw new[] here with no matching delete[] - // would leak on every tunnelled packet). - tunnel_data = std::make_shared>(data_size); - data = tunnel_data->data(); + _socket.async_send_to( + boost::asio::buffer(packet->data(), packet->size()), _endpoint, + [packet](const boost::system::error_code& error, std::size_t /*bytes_transferred*/) + { + if (error) { + spdlog::debug("sent_to (plain) error: {}", error.message()); + } + }); + + /* The completion handler owns the encapsulated buffer through this shared_ptr, so it + outlives the asynchronous send. A raw new[] freed straight after issuing the send would + be read after free. */ + const size_t ip_hdr_len = ip_header_length(_endpoint.address().is_v6()); + const size_t data_size = packet->size() + ip_hdr_len + 8 /* UDP header */; + auto tunnel_data = std::make_shared>(data_size); auto local_address = _source_address ? _source_address.value() : _tunnel_local_address; - create_udp_pkt(const_cast(data) + 20, _endpoint, packet->data(), packet->size(), local_address); - create_ip_hdr(const_cast(data), _endpoint, data_size, local_address); + create_udp_pkt(tunnel_data->data() + ip_hdr_len, _endpoint, packet->data(), packet->size(), local_address); + create_ip_hdr(tunnel_data->data(), _endpoint, data_size, local_address); + + _socket.async_send_to( + boost::asio::buffer(*tunnel_data), _tunnel_endpoint.value(), + [file, symbols, packet, tunnel_data, this]( + const boost::system::error_code& error, std::size_t /*bytes_transferred*/) + { + if (error) { + spdlog::debug("sent_to (tunnel) error: {}", error.message()); + } else { + file->mark_completed(symbols, !error); + if (file->complete()) { + file_transmitted(file->meta().toi); + } + } + }); } else { - send_endpoint = _endpoint; - data = packet->data(); - data_size = packet->size(); - } - _socket.async_send_to( - boost::asio::buffer(data, data_size), - send_endpoint, - [file, symbols, packet, tunnel_data, this]( - const boost::system::error_code& error, - std::size_t bytes_transferred) - { - (void)packet; - (void)tunnel_data; - (void)bytes_transferred; - if (error) { - spdlog::debug("sent_to error: {}", error.message()); - } else { - file->mark_completed(symbols, !error); - if (file->complete()) { - file_transmitted(file->meta().toi); + _socket.async_send_to( + boost::asio::buffer(packet->data(), packet->size()), _endpoint, + [file, symbols, packet, this]( + const boost::system::error_code& error, std::size_t /*bytes_transferred*/) + { + if (error) { + spdlog::debug("sent_to error: {}", error.message()); + } else { + file->mark_completed(symbols, !error); + if (file->complete()) { + file_transmitted(file->meta().toi); + } } - } - }); + }); + } } } if (_active) { @@ -891,6 +967,78 @@ auto Transmitter::_complete_deactivation() -> void _send_timer.cancel(); } +auto Transmitter::close_session() -> void +{ + _session_closing = true; + _fdt->set_complete(true); + + /* Every packet from here on carries the flag, which is the whole signal while there is still + something to send. With an empty queue there is nothing to attach it to, so the flag would + reach no receiver at all; RFC 3926 clause 3.1 provides a packet for exactly that case and this + is where it is due. */ + bool queue_empty = false; + { + std::lock_guard guard(_files_mutex); + queue_empty = _files.empty(); + } + if (queue_empty) { + send_close_session_packet(); + } +} + +/* RFC 3450 clause 4.1: "In some special cases an ALC sender may need to produce ALC packets that do + not contain any payload. This may be required, for example, to signal the end of a session or to + convey congestion control information." + + Fire and forget on both paths. There is no file to mark complete and nothing to retransmit: the + flag is advisory on the receiver's side, which RFC 5651 clause 5.1 puts as "the receiver SHOULD + assume that no more packets will be sent to the session", so a lost one costs a receiver a + timeout rather than data. */ +auto Transmitter::send_close_session_packet() -> void +{ + std::shared_ptr packet; + try { + packet = std::make_shared(_tsi, AlcPacket::CloseSession{}); + } catch (const std::exception& ex) { + spdlog::warn("Not signalling end of session: {}", ex.what()); + return; + } + + _socket.async_send_to( + boost::asio::buffer(packet->data(), packet->size()), _endpoint, + [packet](const boost::system::error_code& error, std::size_t /*bytes_transferred*/) + { + if (error) { + spdlog::debug("close session send error: {}", error.message()); + } + }); + + if (_tunnel_endpoint) { + const size_t ip_hdr_len = ip_header_length(_endpoint.address().is_v6()); + const size_t data_size = packet->size() + ip_hdr_len + 8 /* UDP header */; + auto tunnel_data = std::make_shared>(data_size); + auto local_address = _source_address ? _source_address.value() : _tunnel_local_address; + create_udp_pkt(tunnel_data->data() + ip_hdr_len, _endpoint, packet->data(), packet->size(), + local_address); + create_ip_hdr(tunnel_data->data(), _endpoint, data_size, local_address); + + _socket.async_send_to( + boost::asio::buffer(*tunnel_data), _tunnel_endpoint.value(), + [packet, tunnel_data](const boost::system::error_code& error, + std::size_t /*bytes_transferred*/) + { + if (error) { + spdlog::debug("close session tunnel send error: {}", error.message()); + } + }); + } +} + +auto Transmitter::close_object(uint32_t toi) -> void +{ + _closing_objects.insert(toi); +} + auto Transmitter::start_fdt_repeat_timer() -> void { _fdt_timer.expires_after(std::chrono::seconds(_fdt_repeat_interval)); @@ -901,8 +1049,7 @@ static void create_udp_pkt(char *udp_buffer, const boost::asio::ip::udp::endpoin { auto *udp_bytes = reinterpret_cast(udp_buffer); const auto udp_length = static_cast(data_len + 8); - const auto source_address = local_address.to_v4().to_uint(); - const auto destination_address = endpoint.address().to_v4().to_uint(); + const bool is_v6 = endpoint.address().is_v6(); write_uint16_be(udp_bytes, endpoint.port()); write_uint16_be(udp_bytes + 2, endpoint.port()); @@ -910,21 +1057,60 @@ static void create_udp_pkt(char *udp_buffer, const boost::asio::ip::udp::endpoin write_uint16_be(udp_bytes + 6, 0); memcpy(udp_buffer + 8, data, data_len); - std::vector checksum_bytes(12 + udp_length); - write_uint32_be(checksum_bytes.data(), source_address); - write_uint32_be(checksum_bytes.data() + 4, destination_address); - checksum_bytes[8] = 0; - checksum_bytes[9] = endpoint.protocol().protocol(); - write_uint16_be(checksum_bytes.data() + 10, udp_length); - memcpy(checksum_bytes.data() + 12, udp_bytes, udp_length); + std::vector checksum_bytes; + if (is_v6) { + /* RFC 8200 clause 8.1's pseudo-header: source (16), destination (16), upper-layer packet + length (32), three zero octets, next header (8). */ + checksum_bytes.resize(40 + udp_length); + auto src_bytes = local_address.to_v6().to_bytes(); + auto dst_bytes = endpoint.address().to_v6().to_bytes(); + memcpy(checksum_bytes.data(), src_bytes.data(), src_bytes.size()); + memcpy(checksum_bytes.data() + 16, dst_bytes.data(), dst_bytes.size()); + write_uint32_be(checksum_bytes.data() + 32, udp_length); + checksum_bytes[36] = 0; + checksum_bytes[37] = 0; + checksum_bytes[38] = 0; + checksum_bytes[39] = endpoint.protocol().protocol(); + memcpy(checksum_bytes.data() + 40, udp_bytes, udp_length); + } else { + checksum_bytes.resize(12 + udp_length); + write_uint32_be(checksum_bytes.data(), local_address.to_v4().to_uint()); + write_uint32_be(checksum_bytes.data() + 4, endpoint.address().to_v4().to_uint()); + checksum_bytes[8] = 0; + checksum_bytes[9] = endpoint.protocol().protocol(); + write_uint16_be(checksum_bytes.data() + 10, udp_length); + memcpy(checksum_bytes.data() + 12, udp_bytes, udp_length); + } - write_uint16_be(udp_bytes + 6, calculate_sum(checksum_bytes.data(), checksum_bytes.size())); + uint16_t checksum = calculate_sum(checksum_bytes.data(), checksum_bytes.size()); + if (is_v6 && checksum == 0) { + /* RFC 8200 clause 8.1: "whenever originating a UDP packet, an IPv6 node must compute a UDP + checksum over the packet and the pseudo-header, and, if that computation yields a result + of zero, it must be changed to hex FFFF for placement in the UDP header." */ + checksum = 0xFFFF; + } + write_uint16_be(udp_bytes + 6, checksum); } static void create_ip_hdr(char *ip_buffer, const boost::asio::ip::udp::endpoint &endpoint, size_t pkt_size, const boost::asio::ip::address &local_address) { auto *ip_bytes = reinterpret_cast(ip_buffer); + if (endpoint.address().is_v6()) { + /* RFC 8200 clause 3 gives the header format. It is fixed at 40 bytes and carries no + checksum field of its own, which is why the UDP checksum above is mandatory. */ + memset(ip_bytes, 0, 40); + ip_bytes[0] = 0x60; // version 6, traffic class high nibble zero + write_uint16_be(ip_bytes + 4, static_cast(pkt_size - 40)); // payload, excluding this header + ip_bytes[6] = endpoint.protocol().protocol(); // next header + ip_bytes[7] = 63; // hop limit, matching the IPv4 path's TTL below + auto src_bytes = local_address.to_v6().to_bytes(); + auto dst_bytes = endpoint.address().to_v6().to_bytes(); + memcpy(ip_bytes + 8, src_bytes.data(), src_bytes.size()); + memcpy(ip_bytes + 24, dst_bytes.data(), dst_bytes.size()); + return; + } + memset(ip_bytes, 0, 20); ip_bytes[0] = 0x45; // IPv4, 20-byte header ip_bytes[1] = 0; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cea11e0f..eb701625 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -46,3 +46,5 @@ endfunction() add_flute_test_executable(flute_unit_tests test_transmitter.cpp "unit:") add_flute_test_executable(flute_e2e_tests test_end_to_end.cpp "e2e:") +add_flute_test_executable(flute_protocol_tests test_protocol_fixes.cpp "protocol:") +add_flute_test_executable(flute_fdt_growth_tests test_fdt_growth.cpp "fdt_growth:") diff --git a/tests/test_fdt_growth.cpp b/tests/test_fdt_growth.cpp new file mode 100644 index 00000000..c334d1a2 --- /dev/null +++ b/tests/test_fdt_growth.cpp @@ -0,0 +1,106 @@ +// libflute - FLUTE/ALC library +// +// Copyright (C) 2026 5G-MAG Association (Jordi J. Gimenez ) +// +// Licensed under the License terms and conditions for use, reproduction, and +// distribution of 5G-MAG software (the “License”). You may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// https://www.5g-mag.com/reference-tools. Unless required by applicable law or +// agreed to in writing, software distributed under the License is distributed on +// an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. +// +// See the License for the specific language governing permissions and limitations +// under the License. +// +// Regression coverage for two FDT-growth bugs found and fixed while +// harmonizing divergent branches of this project (see the commit history +// for the full story): repeated carousel sends of an unchanged +// FileDescription used to leave one stale entry per resend in the +// FDT, and a FileDescription whose content genuinely changes used to leave +// its *previous* TOI's entry orphaned forever. Both grew the serialised FDT +// XML without bound over a long-running carousel. +#include +#include +#include "Transmitter.h" + +using namespace LibFlute; + +namespace { +// Transmitter has no public accessor for the current FDT XML; construct one +// with no tunnel/network side effects that matter for this test (the +// io_context is never run, so nothing actually gets sent) and inspect FDT +// size growth indirectly via repeated identical sends. +size_t fdt_entry_count(const std::string& fdt_xml) { + size_t count = 0, pos = 0; + while ((pos = fdt_xml.find("("carousel/item.txt", content.c_str(), content.size()); + + // First send assigns a TOI; every subsequent send reuses it unchanged -- + // exactly the carousel-repeat pattern that used to leak one FDT entry per + // cycle. + uint16_t toi1 = tx.send(desc); + size_t fdt1 = tx.fdt().to_string().size(); + size_t entries1 = fdt_entry_count(tx.fdt().to_string()); + + for (int i = 0; i < 20; i++) { + tx.send(desc); + } + + uint16_t toi_final = desc->toi(); + EXPECT_EQ(toi1, toi_final) << "unchanged content must keep the same TOI across resends"; + EXPECT_EQ(fdt_entry_count(tx.fdt().to_string()), entries1) << "resending unchanged content must not add FDT entries"; + EXPECT_EQ(tx.fdt().to_string().size(), fdt1) << "FDT XML size must not grow across identical resends"; +} + +TEST(FdtGrowthTest, ContentChangeRemovesThePreviousToisFdtEntry) { + boost::asio::io_context io; + Transmitter tx("239.255.9.2", 19002, 9002, 1400, 0, io); + + std::string content1 = "version one of the content"; + auto desc = std::make_shared("changing/item.txt", content1.c_str(), content1.size()); + uint16_t toi1 = tx.send(desc); + size_t entries_after_first = fdt_entry_count(tx.fdt().to_string()); + + // Changing the content zeroes the TOI (via _reset_toi(), remembering + // toi1 as _previous_toi); the next send must assign a fresh TOI AND clean + // up toi1's now-stale FDT entry, not just add a new one alongside it. + std::string content2 = "version two -- genuinely different, much longer content than before"; + desc->set_content(content2.c_str(), content2.size()); + uint16_t toi2 = tx.send(desc); + + EXPECT_NE(toi1, toi2) << "changed content must get a fresh TOI"; + EXPECT_EQ(fdt_entry_count(tx.fdt().to_string()), entries_after_first) + << "the old TOI's FDT entry must be removed, not left orphaned alongside the new one"; +} + +TEST(FdtGrowthTest, RepeatedContentChangesDoNotAccumulateOrphans) { + boost::asio::io_context io; + Transmitter tx("239.255.9.3", 19003, 9003, 1400, 0, io); + + std::string content = "iteration 0"; + auto desc = std::make_shared("changing/loop.txt", content.c_str(), content.size()); + tx.send(desc); + size_t entries_after_first = fdt_entry_count(tx.fdt().to_string()); + + for (int i = 1; i <= 10; i++) { + std::string next_content = "iteration " + std::to_string(i) + " with some extra padding to change length too"; + desc->set_content(next_content.c_str(), next_content.size()); + tx.send(desc); + } + + EXPECT_EQ(fdt_entry_count(tx.fdt().to_string()), entries_after_first) + << "10 content changes must still leave exactly one FDT entry for this object, not 11"; +} diff --git a/tests/test_protocol_fixes.cpp b/tests/test_protocol_fixes.cpp new file mode 100644 index 00000000..7bbef13a --- /dev/null +++ b/tests/test_protocol_fixes.cpp @@ -0,0 +1,882 @@ +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "AlcPacket.h" +#include "File.h" +#include "FileDeliveryTable.h" +#include "Receiver.h" +#include "Transmitter.h" + +using namespace LibFlute; +using namespace std::chrono_literals; + +namespace { + +FecOti make_fec_oti() { + FecOti oti{}; + oti.encoding_id = FecScheme::CompactNoCode; + oti.instance_id = 0; + oti.transfer_length = 4096; + oti.encoding_symbol_length = 1400; + oti.max_source_block_length = 64; + oti.max_number_of_encoding_symbols = 0; + return oti; +} + +FileDeliveryTable::FileEntry make_entry(const FecOti &oti) { + FileDeliveryTable::FileEntry e{}; + e.toi = 1; + e.content_location = "http://example.invalid/seg1.m4s"; + e.content_length = 4096; + e.expires = 0; + e.fec_oti = oti; + e.cache_control.no_cache = false; + return e; +} + +// Serialise a one-file FDT. The profile argument is left at its library default unless a +// test is specifically about the non-3GPP behaviour, so the default itself stays under test. +/* General FLUTE, so the namespace under test is the one actually emitted. The 3GPP profiles derive + their own namespace from the profile, which is the point of those, so they cannot be used to + exercise an arbitrary namespace. */ +std::string emit(FileDeliveryTable::FdtNamespace ns) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, ns, Profile::Unprofiled); + fdt.add(make_entry(oti)); + return fdt.to_string(); +} + +std::string emit(FileDeliveryTable::FdtNamespace ns, Profile profile) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, ns, profile); + fdt.add(make_entry(oti)); + return fdt.to_string(); +} + + +/* NTP-epoch seconds a little way ahead of now. RFC 3926 clause 3.3 requires an FDT Instance's expiry + to be in the future, so a fixed small constant is no longer a usable test value. */ +uint64_t future_ntp(uint64_t seconds_ahead) { + return (uint64_t)std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count() + + 2208988800ULL + seconds_ahead; +} +} // namespace + +/* TS 26.346 V18.2.0 clause L.4.4 lists Transfer-Length among the attributes that + "shall not be carried in the FDT sent by the FLUTE sender". These assert on the + emitted XML rather than on the FileEntry, because the object carrying a value the + serialiser then withholds is exactly the case that has to pass. */ + +TEST(MbmsDownloadProfileTest, TransferLengthNotCarriedUnderTs26517) { + EXPECT_EQ(emit(FileDeliveryTable::FDT_NS_NONE, Profile::Ts26517).find("Transfer-Length"), + std::string::npos); +} + +TEST(MbmsDownloadProfileTest, TransferLengthNotCarriedUnderTs26346) { + EXPECT_EQ(emit(FileDeliveryTable::FDT_NS_NONE, Profile::Ts26346).find("Transfer-Length"), + std::string::npos); +} + +/* The namespace argument is ignored under a 3GPP profile, which derives its own. TS 26.517 V18.6.0 + clause 6.2.1: "The MBSTF shall use the Profiled FDT Schema according to clause L.6 of TS 26.346 + [7] to describe the object list currently being transmitted in the MBS Distribution Session." */ +TEST(MbmsDownloadProfileTest, ProfileDecidesTheSchemaNotTheNamespaceArgument) { + auto mbs = emit(FileDeliveryTable::FDT_NS_RFC3926, Profile::Ts26517); + EXPECT_NE(mbs.find("urn:3GPP:metadata:2022:FLUTE:FDT"), std::string::npos) << mbs; + EXPECT_NE(mbs.find("2"), std::string::npos); + + auto mbms = emit(FileDeliveryTable::FDT_NS_RFC3926, Profile::Ts26346); + EXPECT_NE(mbms.find("urn:IETF:metadata:2005:FLUTE:FDT"), std::string::npos) << mbms; + EXPECT_NE(mbms.find("4"), std::string::npos); +} + +TEST(GeneralFluteTest, TransferLengthStillCarriedOutsideTheProfile) { + // Plain RFC 3926, where clause 3.4.2 permits the attribute, so it is kept. This has to be + // asked for explicitly: the library default is the 3GPP profile. + EXPECT_NE(emit(FileDeliveryTable::FDT_NS_RFC3926, Profile::Unprofiled).find("Transfer-Length"), + std::string::npos); +} + +/* The delimitation itself. A session is bound by the general FLUTE documents always, and by + TS 26.346 annex L.4 only under the 3GPP profile, which is the default. */ + +TEST(ProfileDefaultTest, DefaultIsTheMbms3gppProfile) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti); + EXPECT_EQ(fdt.profile(), Profile::Ts26517); +} + +TEST(ProfileDefaultTest, ProfileNotFdtNamespaceDecidesTheRestriction) { + // The namespace says which schema is emitted; the profile says which obligations apply. + // Same namespace, opposite outcomes, driven only by the profile. + const auto ns = FileDeliveryTable::FDT_NS_RFC3926; + EXPECT_EQ(emit(ns, Profile::Ts26517).find("Transfer-Length"), std::string::npos); + EXPECT_NE(emit(ns, Profile::Unprofiled).find("Transfer-Length"), std::string::npos); +} + +TEST(MbmsDownloadProfileTest, ContentLengthIsCarriedInEveryMode) { + // Guards the fallback the prohibition relies on: with Transfer-Length withheld, a + // receiver derives the transfer length from Content-Length. + for (auto ns : {FileDeliveryTable::FDT_NS_3GPP_CONSOLIDATED_V2, + FileDeliveryTable::FDT_NS_DRAFT_2005, + FileDeliveryTable::FDT_NS_RFC3926}) { + EXPECT_NE(emit(ns).find("Content-Length"), std::string::npos); + } +} + +/* TS 26.346 V18.2.0 clause L.4.3 forbids the sender using the Complete attribute, while its + NOTE keeps receiver support mandatory. These cover the sender half; the parser is unchanged. */ + +TEST(MbmsDownloadProfileTest, CompleteNotCarriedUnderThe3gppProfile) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_3GPP_CONSOLIDATED_V2); + fdt.add(make_entry(oti)); + fdt.set_complete(true); + EXPECT_EQ(fdt.to_string().find("Complete"), std::string::npos); +} + +TEST(GeneralFluteTest, CompleteStillCarriedOutsideTheProfile) { + // RFC 3926 clause 3.4.2 permits it, so plain FLUTE keeps it. + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_RFC3926, Profile::Unprofiled); + fdt.add(make_entry(oti)); + fdt.set_complete(true); + EXPECT_NE(fdt.to_string().find("Complete"), std::string::npos); +} + +/* TS 26.346 V18.2.0 clause L.4.2 forbids FEC-OTI-FEC-Instance-ID at both levels. The pre-existing + guard was on the value, so a non-zero instance ID leaked the attribute into a 3GPP session. */ + +TEST(MbmsDownloadProfileTest, FecInstanceIdNotCarriedUnderThe3gppProfile) { + auto oti = make_fec_oti(); + oti.instance_id = 7; // non-zero, so the old value-only guard would have emitted it + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_3GPP_CONSOLIDATED_V2); + auto e = make_entry(oti); + e.fec_oti.instance_id = 9; // differs from the global, so the File-level guard would fire too + fdt.add(e); + EXPECT_EQ(fdt.to_string().find("FEC-OTI-FEC-Instance-ID"), std::string::npos); +} + +TEST(GeneralFluteTest, FecInstanceIdNotCarriedOutsideTheProfileEither) { + /* Corrected from an earlier version of this test, which asserted the attribute WAS carried + under general FLUTE. RFC 5052 clause 6.2.4 forbids it for a Fully-Specified FEC scheme + regardless of profile, and both schemes this library implements are Fully-Specified, so the + 3GPP profile was never the binding constraint. */ + auto oti = make_fec_oti(); + oti.instance_id = 7; + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_RFC3926, Profile::Unprofiled); + fdt.add(make_entry(oti)); + EXPECT_EQ(fdt.to_string().find("FEC-OTI-FEC-Instance-ID"), std::string::npos); +} + +/* TS 26.346 V18.2.0 clause L.4.2 permits Content-Encoding only when set to 'gzip', and + prohibits any other value. Refused rather than silently dropped: dropping it would leave + encoded payload with nothing on the wire saying so. */ + +TEST(MbmsDownloadProfileTest, GzipContentEncodingIsAccepted) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_3GPP_CONSOLIDATED_V2); + auto e = make_entry(oti); + e.content_encoding = "gzip"; + EXPECT_NO_THROW(fdt.add(e)); + EXPECT_NE(fdt.to_string().find("gzip"), std::string::npos); +} + +TEST(MbmsDownloadProfileTest, NonGzipContentEncodingIsRefused) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_3GPP_CONSOLIDATED_V2); + auto e = make_entry(oti); + e.content_encoding = "deflate"; + EXPECT_THROW(fdt.add(e), std::invalid_argument); +} + +TEST(MbmsDownloadProfileTest, AbsentContentEncodingIsAccepted) { + // The attribute is a "may", so carrying nothing is conformant. + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_3GPP_CONSOLIDATED_V2); + EXPECT_NO_THROW(fdt.add(make_entry(oti))); +} + +TEST(GeneralFluteTest, NonGzipContentEncodingIsAllowedOutsideTheProfile) { + // RFC 3926 places no such restriction, so plain FLUTE accepts it. + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_RFC3926, Profile::Unprofiled); + auto e = make_entry(oti); + e.content_encoding = "deflate"; + EXPECT_NO_THROW(fdt.add(e)); + EXPECT_NE(fdt.to_string().find("deflate"), std::string::npos); +} + +/* --------------------------------------------------------------------------------------------- + LCT header parse robustness. General FLUTE, not 3GPP: these are properties of the RFC 3451 + header itself and apply in both profiles. The inputs are built as raw bytes because the + transmitter cannot produce them, which is why nothing previously covered this path. + + Layout, RFC 3451 clause 5.1: byte 0 is V(4) C(2) r(2); byte 1 is S(1) O(2) H(1) T(1) R(1) + A(1) B(1); byte 2 is HDR_LEN in 32-bit words; byte 3 is the Codepoint. + --------------------------------------------------------------------------------------------- */ + +namespace { + +// V=1, H=1 (so a 16-bit TSI and TOI half-word are present), everything else clear. +// Standard header is then 2 + H = 3 words = 12 bytes. +std::vector lct_packet(uint8_t hdr_len_words, const std::vector &extension = {}) { + std::vector p{static_cast(0x10), // V=1, C=0, r=0 + static_cast(0x10), // H=1 + static_cast(hdr_len_words), + static_cast(0x00)}; // Codepoint 0 = Compact No-Code + p.resize(12, 0); // CCI (4) + TSI half-word (2) + TOI half-word (2) + for (auto b : extension) p.push_back(static_cast(b)); + return p; +} + +} // namespace + +TEST(LctHeaderParseTest, WellFormedMinimalHeaderStillParses) { + auto p = lct_packet(3); + EXPECT_NO_THROW(AlcPacket(p.data(), p.size())); +} + +TEST(LctHeaderParseTest, HeaderLongerThanTheDatagramIsRejected) { + // HDR_LEN claims 10 words (40 bytes) but only 12 bytes were received. Without the check every + // subsequent read runs past the end of the buffer. + auto p = lct_packet(10); + EXPECT_THROW(AlcPacket(p.data(), p.size()), std::runtime_error); +} + +TEST(LctHeaderParseTest, HeaderShorterThanItsOwnFlagsIsRejected) { + // Flags require 3 words; the header claims 2. Without the check the extension-space + // calculation goes negative and becomes a very large size_t. + auto p = lct_packet(2); + EXPECT_THROW(AlcPacket(p.data(), p.size()), std::runtime_error); +} + +TEST(LctHeaderParseTest, ZeroLengthHeaderExtensionIsRejectedRatherThanLooping) { + // HET below 128 is a variable-length extension, so HEL is read and gives the length. HEL 0 + // means a zero-length extension: the walk would consume nothing and never terminate. + auto p = lct_packet(4, {100 /* HET: variable-length, and not one this library handles */, + 0 /* HEL = 0 */, 0, 0}); + EXPECT_THROW(AlcPacket(p.data(), p.size()), std::runtime_error); +} + +/* RFC 3451 clause 5.1 puts SCT (T=1) and ERT (R=1) inside the header, after the TOI and before + any extension, and both count toward HDR_LEN. TS 26.346 clause L.4.7 says an MBMS network does + not use them and the UE "should ignore them" -- and ignoring a field still means stepping over + it, so this applies in both profiles. Byte 1 bits, MSB first: S O O H T R A B. */ + +TEST(LctHeaderParseTest, SenderCurrentTimeFieldIsAccountedForInTheHeaderLength) { + // T=1 is bit 4 from the MSB of byte 1, i.e. 0x08, alongside H=1 (0x10). + std::vector p{static_cast(0x10), static_cast(0x10 | 0x08), + static_cast(4), static_cast(0x00)}; + p.resize(16, 0); // CCI(4) + TSI/TOI half-words(4) + SCT(4) = 4 words after the base word + EXPECT_NO_THROW(AlcPacket(p.data(), p.size())); +} + +TEST(LctHeaderParseTest, ExpectedResidualTimeFieldIsAccountedForInTheHeaderLength) { + // R=1 is bit 5 from the MSB of byte 1, i.e. 0x04. + std::vector p{static_cast(0x10), static_cast(0x10 | 0x04), + static_cast(4), static_cast(0x00)}; + p.resize(16, 0); + EXPECT_NO_THROW(AlcPacket(p.data(), p.size())); +} + +TEST(LctHeaderParseTest, BothTimingFieldsPresentIsAccountedFor) { + std::vector p{static_cast(0x10), static_cast(0x10 | 0x08 | 0x04), + static_cast(5), static_cast(0x00)}; + p.resize(20, 0); // ... + SCT(4) + ERT(4) + EXPECT_NO_THROW(AlcPacket(p.data(), p.size())); +} + +/* RFC 3926 clause 3.4.1 requires the EXT_FDT version field to be 1 in a version 1 session, and + RFC 6726 clause 11.1 records that version 1 and version 2 are not interchangeable. General + FLUTE, applying in both profiles. EXT_FDT is HET 192, a fixed-length 4-byte extension, so its + first byte holds V in the top nibble and the FDT Instance ID's top 4 bits in the low nibble. */ + +namespace { + +std::vector packet_with_ext_fdt(uint8_t flute_version) { + std::vector p{static_cast(0x10), static_cast(0x10), + static_cast(4), static_cast(0x00)}; + p.resize(12, 0); + p.push_back(static_cast(192)); // HET = EXT_FDT + p.push_back(static_cast((flute_version & 0x0F) << 4)); // V, then ID bits 19..16 + p.push_back(0); // FDT Instance ID low 16 bits + p.push_back(0); + return p; +} + +} // namespace + +TEST(FluteVersionTest, VersionOneIsAccepted) { + auto p = packet_with_ext_fdt(1); + EXPECT_NO_THROW(AlcPacket(p.data(), p.size())); +} + +TEST(FluteVersionTest, VersionTwoIsRejected) { + // Previously accepted, which meant decoding a session this build does not implement. + auto p = packet_with_ext_fdt(2); + EXPECT_THROW(AlcPacket(p.data(), p.size()), std::runtime_error); +} + +TEST(FluteVersionTest, VersionZeroIsRejected) { + auto p = packet_with_ext_fdt(0); + EXPECT_THROW(AlcPacket(p.data(), p.size()), std::runtime_error); +} + +/* TS 26.346 V18.2.0 clause L.6.3 fixes schemaVersion at 2 for the profiled FDT schema, whose + L.6.1 definition makes it a mandatory child element after the File elements. Keyed on the FDT + namespace, not the profile: it is required by the schema being emitted. */ + +TEST(ProfiledFdtSchemaTest, SchemaVersionTwoIsEmittedForTheProfiledSchema) { + auto out = emit(FileDeliveryTable::FDT_NS_3GPP_CONSOLIDATED_V2); + EXPECT_NE(out.find("2"), std::string::npos); +} + +TEST(ProfiledFdtSchemaTest, SchemaVersionFollowsTheFileElements) { + // The schema's sequence is File then schemaVersion, so order is part of validity. + auto out = emit(FileDeliveryTable::FDT_NS_3GPP_CONSOLIDATED_V2); + auto file_pos = out.find(""); + ASSERT_NE(file_pos, std::string::npos); + ASSERT_NE(sv_pos, std::string::npos); + EXPECT_LT(file_pos, sv_pos); +} + +TEST(ProfiledFdtSchemaTest, SchemaVersionNotEmittedForSchemasThatDoNotDefineIt) { + // Meaningless in a document declaring a schema whose sequence has no such element. + EXPECT_EQ(emit(FileDeliveryTable::FDT_NS_RFC3926).find("schemaVersion"), std::string::npos); + EXPECT_EQ(emit(FileDeliveryTable::FDT_NS_NONE).find("schemaVersion"), std::string::npos); +} + +/* TS 26.346 V18.2.0 clause 7.2.10.1: "In this version of the present document the network shall set + the content of the schemaVersion element, defined as a child of the FDT-Instance element, to the + value 4." That is the extended schema of that clause, which is the one MbmsDownload emits, and it + takes a different value from the annex L.6.1 profiled schema's 2. */ +TEST(ProfiledFdtSchemaTest, SchemaVersionFourForTheExtendedSchema) { + auto xml = emit(FileDeliveryTable::FDT_NS_DRAFT_2005); + EXPECT_NE(xml.find("4"), std::string::npos) << xml; +} + +TEST(ProfiledFdtSchemaTest, DelimiterIsNotEmitted) { + // Clause L.6.3A calls for it only when a future optional element is added; the base sequence + // has none, so emitting one would not match the schema. + EXPECT_EQ(emit(FileDeliveryTable::FDT_NS_3GPP_CONSOLIDATED_V2).find("delimiter"), + std::string::npos); +} + +/* The File element's Expires attribute and the mbms2007:Cache-Control Expires element are + different things: FileType's Expires says when the file stops being valid, while + CacheControlType is an xs:choice whose Expires is a caching directive to intermediates. They + were previously set together, emitted from the same member and parsed into the same member, so + nothing could tell them apart. These give them different values on purpose. */ + +namespace { + +std::string emit_with_expiries(uint64_t file_expires, std::optional cache_expires) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_3GPP_CONSOLIDATED_V2); + auto e = make_entry(oti); + e.expires = file_expires; + e.cache_control.cache_expires = cache_expires; + fdt.add(e); + return fdt.to_string(); +} + +} // namespace + +TEST(ExpiryAttributesTest, FileAndCacheExpiriesAreEmittedIndependently) { + // Distinct values, so a single shared member cannot satisfy both assertions. + auto out = emit_with_expiries(1111, 2222); + EXPECT_NE(out.find("Expires=\"1111\""), std::string::npos); // FileType attribute + EXPECT_NE(out.find(">2222<"), std::string::npos); // Cache-Control element text + EXPECT_EQ(out.find("Expires=\"2222\""), std::string::npos); // not swapped + EXPECT_EQ(out.find(">1111<"), std::string::npos); +} + +TEST(ExpiryAttributesTest, FileExpiresOmittedWhenUnset) { + // use="optional" in the profiled schema, so absent is valid and preferable to a bogus 0. + // Scoped to the File element: FDT-Instance carries its own required Expires attribute, which + // is a different attribute on a different element and must not be confused with this one. + auto out = emit_with_expiries(0, std::nullopt); + auto file_start = out.find("", file_start); + ASSERT_NE(file_end, std::string::npos); + const auto file_tag = out.substr(file_start, file_end - file_start); + EXPECT_EQ(file_tag.find("Expires="), std::string::npos); +} + +TEST(ExpiryAttributesTest, NoCacheControlElementWhenNoDirectiveWasSet) { + // The element is minOccurs="0"; emitting an empty one would be worse than omitting it. + auto out = emit_with_expiries(1111, std::nullopt); + EXPECT_EQ(out.find("Cache-Control"), std::string::npos); +} + +TEST(ExpiryAttributesTest, CacheControlStillCarriesOnlyOneChoiceMember) { + // CacheControlType is an xs:choice, so no-cache and Expires must not both appear. + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_3GPP_CONSOLIDATED_V2); + auto e = make_entry(oti); + e.cache_control.no_cache = true; + e.cache_control.cache_expires = 2222; + fdt.add(e); + auto out = fdt.to_string(); + EXPECT_NE(out.find("no-cache"), std::string::npos); + EXPECT_EQ(out.find(">2222<"), std::string::npos); +} + + +/* RFC 3926 clause 3.4.1 defines the FDT Instance ID sequence and its wraparound. next_instance_id() + is the sequence as a pure function, so these exercise it without a live session. */ + +TEST(FdtInstanceIdWraparoundTest, IdIncrementsBelowTheCeiling) { + std::map expired; + EXPECT_EQ(FileDeliveryTable::next_instance_id(5, /*current_expires*/ 1000, /*now*/ 2000, expired), 6u); + EXPECT_EQ(expired.at(5u), 1000u); +} + +/* RFC 3926 clause 3.4.1: "After reaching the maximum value (2^20-1), the numbering starts again + from '0'." Not to the smallest expired identifier, and not to any other value. */ +TEST(FdtInstanceIdWraparoundTest, IdWrapsToZeroAtTheCeiling) { + std::map expired = {{1u, 500u}, {5u, 500u}, {100u, 500u}}; + auto next = FileDeliveryTable::next_instance_id(FileDeliveryTable::kMaxFdtInstanceId, + /*current_expires*/ 1500, /*now*/ 1000, expired); + EXPECT_EQ(next, 0u); + EXPECT_EQ(expired.at(FileDeliveryTable::kMaxFdtInstanceId), 1500u); +} + +/* The clause states the sequence unconditionally and gives it no failure case. Waiting for the + previous holder to expire is a recommendation in the same clause, so it is warned about rather + than met by choosing a different identifier or by refusing to continue. RFC 6726 clause 3.4.1 + does make it a prohibition, but that is the v2 rule and this library implements RFC 3926. */ +TEST(FdtInstanceIdWraparoundTest, IdWrapsToZeroEvenWhenZeroHasNotExpired) { + std::map expired = {{0u, 5000u}, {1u, 5000u}}; + uint32_t next = 0xFFFFFFFFu; + EXPECT_NO_THROW(next = FileDeliveryTable::next_instance_id(FileDeliveryTable::kMaxFdtInstanceId, + /*current_expires*/ 5000, /*now*/ 1000, + expired)); + EXPECT_EQ(next, 0u); +} + +/* A plain increment would leave the 20-bit field long before a caller could observe it, and the + value would be silently masked on the wire. */ +TEST(FdtInstanceIdWraparoundTest, IdStaysInsideTheFieldAcrossManySends) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(FileDeliveryTable::kMaxFdtInstanceId, oti); + auto entry = make_entry(oti); + for (int i = 0; i < 10; ++i) { + fdt.sent(); + entry.toi = static_cast(i + 1); + fdt.add(entry); + EXPECT_LE(fdt.instance_id(), FileDeliveryTable::kMaxFdtInstanceId); + } +} + +/* Reception bootstrapped from a content packet's own EXT_FTI, for a TOI the FDT has not yet + described. This library's own Transmitter carries EXT_FTI only on the FDT itself, so the packets + below are hand-built to stand in for a different, spec-general sender. */ +namespace { + +/* A minimal ALC/LCT packet for one TOI, Compact No-Code, carrying EXT_FTI and no EXT_FDT. + transfer_length defaults to the payload size, giving a single-packet object that completes on + arrival; a caller passes a larger value to keep the bootstrapped file incomplete. */ +std::vector build_content_packet_with_fti(uint16_t tsi, uint16_t toi, + uint16_t encoding_symbol_length, + uint32_t max_source_block_length, + const std::string& symbol_data, + uint32_t declared_transfer_length = 0) { + const size_t lct_header_len_words = 7; // LCT header + CCI, TSI/TOI half-words, EXT_FTI + const size_t header_bytes = lct_header_len_words * 4; + const size_t sbn_esi_bytes = 4; + std::vector buf(header_bytes + sbn_esi_bytes + symbol_data.size(), 0); + auto* b = reinterpret_cast(buf.data()); + + b[0] = (1 << 4); // FLUTE version 1, no congestion control, no PSI + b[1] = 0x10; // half-word flag set, TSI/TOI flags and both Close flags clear + b[2] = static_cast(lct_header_len_words); + b[3] = 0; // codepoint: Compact No-Code + + uint16_t tsi_be = htons(tsi); + uint16_t toi_be = htons(toi); + std::memcpy(b + 8, &tsi_be, 2); + std::memcpy(b + 10, &toi_be, 2); + + size_t off = 12; + b[off] = 64; // EXT_FTI + b[off + 1] = 4; // HEL: 4 words + uint32_t transfer_length = + declared_transfer_length ? declared_transfer_length : static_cast(symbol_data.size()); + uint16_t transfer_len_hi_be = htons(0); + uint32_t transfer_len_lo_be = htonl(transfer_length); + std::memcpy(b + off + 2, &transfer_len_hi_be, 2); + std::memcpy(b + off + 4, &transfer_len_lo_be, 4); + uint16_t esl_be = htons(encoding_symbol_length); + std::memcpy(b + off + 10, &esl_be, 2); + uint32_t msbl_be = htonl(max_source_block_length); + std::memcpy(b + off + 12, &msbl_be, 4); + + std::memcpy(buf.data() + header_bytes + sbn_esi_bytes, symbol_data.data(), symbol_data.size()); + return buf; +} + +std::vector make_symbols(const char* data, size_t len) { + return {EncodingSymbol(0, 0, const_cast(data), len, FecScheme::CompactNoCode)}; +} + +} // namespace + +/* Checks the hand-built packet parses the way the two tests below assume, so a failure there is + read as a Receiver failure rather than a malformed fixture. */ +TEST(ExtFtiBootstrapTest, HandBuiltPacketCarriesItsOwnFti) { + auto buf = build_content_packet_with_fti(777, 5, 1000, 64, "0123456789"); + AlcPacket alc(buf.data(), buf.size()); + EXPECT_EQ(alc.tsi(), 777u); + EXPECT_EQ(alc.toi(), 5u); + EXPECT_TRUE(alc.has_fec_oti()); + EXPECT_EQ(alc.fec_oti().encoding_symbol_length, 1000u); + EXPECT_EQ(alc.fec_oti().max_source_block_length, 64u); +} + +TEST(ExtFtiBootstrapTest, ReceptionStartsFromThePacketsOwnFti) { + boost::asio::io_context io; + auto work_guard = boost::asio::make_work_guard(io); + LibFlute::Receiver receiver("0.0.0.0", "239.255.9.9", 19191, /*tsi*/ 777, io); + std::thread io_thread([&io]() { io.run(); }); + + auto buf = build_content_packet_with_fti(777, 5, 1000, 64, "0123456789"); + + int sock = socket(AF_INET, SOCK_DGRAM, 0); + ASSERT_GE(sock, 0); + sockaddr_in dst{}; + dst.sin_family = AF_INET; + dst.sin_port = htons(19191); + inet_pton(AF_INET, "239.255.9.9", &dst.sin_addr); + auto sent = sendto(sock, buf.data(), buf.size(), 0, reinterpret_cast(&dst), sizeof(dst)); + EXPECT_EQ(sent, static_cast(buf.size())); + ::close(sock); + + bool found = false; + for (int i = 0; i < 50 && !found; ++i) { + std::this_thread::sleep_for(20ms); + for (const auto& f : receiver.file_list()) { + if (f->meta().toi == 5) { + found = true; + EXPECT_EQ(f->fec_oti().encoding_symbol_length, 1000u); + EXPECT_EQ(f->fec_oti().max_source_block_length, 64u); + } + } + } + EXPECT_TRUE(found) << "no file was started for TOI 5 from the packet's own EXT_FTI"; + + receiver.stop(); + work_guard.reset(); + io.stop(); + io_thread.join(); +} + +/* The head start is only worth taking if the FDT, once it arrives, fills in the metadata on the + file already being reassembled. Replacing it would discard every symbol received so far. */ +TEST(ExtFtiBootstrapTest, ArrivingFdtMetadataIsAdoptedInPlace) { + boost::asio::io_context io; + auto work_guard = boost::asio::make_work_guard(io); + LibFlute::Receiver receiver("0.0.0.0", "239.255.9.10", 19192, /*tsi*/ 778, io); + std::thread io_thread([&io]() { io.run(); }); + + int sock = socket(AF_INET, SOCK_DGRAM, 0); + ASSERT_GE(sock, 0); + sockaddr_in dst{}; + dst.sin_family = AF_INET; + dst.sin_port = htons(19192); + inet_pton(AF_INET, "239.255.9.10", &dst.sin_addr); + + auto send_buf = [&](const std::vector& buf) { + auto sent = sendto(sock, buf.data(), buf.size(), 0, reinterpret_cast(&dst), sizeof(dst)); + EXPECT_EQ(sent, static_cast(buf.size())); + }; + + /* Two symbols declared, one delivered, so the file is still incomplete when the FDT arrives. + A completed file is replaced by the same-content-location handling before this can be seen. */ + send_buf(build_content_packet_with_fti(778, 5, 1000, 64, "0123456789", + /*declared_transfer_length*/ 2000)); + + LibFlute::File* bootstrapped = nullptr; + for (int i = 0; i < 50 && !bootstrapped; ++i) { + std::this_thread::sleep_for(20ms); + for (const auto& f : receiver.file_list()) { + if (f->meta().toi == 5) bootstrapped = f.get(); + } + } + ASSERT_NE(bootstrapped, nullptr) << "no file was started for TOI 5 from the packet's own EXT_FTI"; + EXPECT_TRUE(bootstrapped->meta().content_location.empty()); + EXPECT_FALSE(bootstrapped->complete()); + + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti); + FileDeliveryTable::FileEntry entry = make_entry(oti); + entry.toi = 5; + entry.content_location = "bootstrapped.bin"; + entry.content_length = 2000; + fdt.add(entry); + auto xml = fdt.to_string(); + FecOti fdt_oti{FecScheme::CompactNoCode, 0, xml.length(), 1400, 64, 0}; + AlcPacket fdt_packet(/*tsi*/ 778, /*toi*/ 0, fdt_oti, make_symbols(xml.c_str(), xml.length()), 1400, + fdt.instance_id()); + send_buf(std::vector(fdt_packet.data(), fdt_packet.data() + fdt_packet.size())); + + bool adopted = false; + for (int i = 0; i < 50 && !adopted; ++i) { + std::this_thread::sleep_for(20ms); + for (const auto& f : receiver.file_list()) { + if (f->meta().toi == 5 && f->meta().content_location == "bootstrapped.bin") { + adopted = true; + EXPECT_EQ(f.get(), bootstrapped) + << "TOI 5 was replaced with a new file instead of adopting the FDT metadata in place"; + } + } + } + EXPECT_TRUE(adopted) << "TOI 5 never took the content location the FDT gave it"; + + ::close(sock); + receiver.stop(); + work_guard.reset(); + io.stop(); + io_thread.join(); +} + + +/* The MBMS Download Profile fixes the TSI field at 16 bits, so a wider value cannot be signalled + under it. TS 26.346 V18.2.0 clause 7.2.7: "-The Transmission Session Identifier (TSI) field shall + be of length 16 bits (S=0, H=1, 16 bits)." Outside the profile RFC 3451 permits the wider + encoding and the widening on this branch applies. */ +TEST(ProfileTsiWidthTest, WideTsiRefusedUnderTheMbmsDownloadProfile) { + boost::asio::io_context io; + EXPECT_THROW( + LibFlute::Transmitter("239.1.3.10", 5000, /*tsi*/ 0x10000, /*mtu*/ 1400, /*rate_limit*/ 0, io, + std::nullopt, FileDeliveryTable::FDT_NS_NONE, /*active*/ false, + std::nullopt, Profile::Ts26517), + std::runtime_error); +} + +TEST(ProfileTsiWidthTest, SixteenBitTsiAcceptedUnderTheProfile) { + boost::asio::io_context io; + EXPECT_NO_THROW( + LibFlute::Transmitter("239.1.3.11", 5000, /*tsi*/ 0xFFFF, /*mtu*/ 1400, /*rate_limit*/ 0, io, + std::nullopt, FileDeliveryTable::FDT_NS_NONE, /*active*/ false, + std::nullopt, Profile::Ts26517)); +} + +TEST(ProfileTsiWidthTest, WideTsiAcceptedOutsideTheProfile) { + boost::asio::io_context io; + EXPECT_NO_THROW( + LibFlute::Transmitter("239.1.3.12", 5000, /*tsi*/ 0x10000, /*mtu*/ 1400, /*rate_limit*/ 0, io, + std::nullopt, FileDeliveryTable::FDT_NS_NONE, /*active*/ false, + std::nullopt, Profile::Unprofiled)); +} + + +/* TS 26.346 V18.2.0 clause 7.2.9: "When the FEC Encoding ID indicates the "Compact No-Code FEC + scheme", the value of this data element shall not exceed 65535, consistent with the 16-bit + constraint on the Encoding Symbol ID". Refused rather than clamped, since clamping would + repartition the object without telling the operator. */ +TEST(ProfileSourceBlockLengthTest, AboveTheCompactNoCodeCeilingIsRefused) { + auto oti = make_fec_oti(); + oti.max_source_block_length = 65536; + EXPECT_THROW(FileDeliveryTable(1, oti, FileDeliveryTable::FDT_NS_NONE, Profile::Ts26517), + std::runtime_error); + EXPECT_THROW(FileDeliveryTable(1, oti, FileDeliveryTable::FDT_NS_NONE, Profile::Ts26346), + std::runtime_error); +} + +TEST(ProfileSourceBlockLengthTest, AtTheCeilingIsAccepted) { + auto oti = make_fec_oti(); + oti.max_source_block_length = 65535; + EXPECT_NO_THROW(FileDeliveryTable(1, oti, FileDeliveryTable::FDT_NS_NONE, Profile::Ts26517)); +} + +TEST(ProfileSourceBlockLengthTest, NotAppliedOutsideThe3gppProfiles) { + auto oti = make_fec_oti(); + oti.max_source_block_length = 65536; + EXPECT_NO_THROW(FileDeliveryTable(1, oti, FileDeliveryTable::FDT_NS_NONE, Profile::Unprofiled)); +} + +/* TS 26.346 V18.2.0 annex L: "When the optional File@Expires attribute is provided, its value shall + take precedence over that of the FDT@Expires attribute." */ +TEST(EffectiveExpiryTest, FileExpiresTakesPrecedenceOverTheInstanceValue) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_NONE, Profile::Ts26517); + const auto instance_expiry = future_ntp(600); + fdt.set_expires(instance_expiry); + + auto entry = make_entry(oti); + entry.expires = instance_expiry + 1000; + EXPECT_EQ(fdt.effective_expiry(entry), instance_expiry + 1000); + + entry.expires = 0; // attribute absent + EXPECT_EQ(fdt.effective_expiry(entry), instance_expiry); +} + +/* TS 26.346 V18.2.0 clause 7.2.9: "For MBMS operation, the UE shall not use a received FDT Instance + to interpret packets received beyond the expiration time of the FDT Instance." */ +TEST(FdtExpiryTest, AnInstanceIsExpiredOnceItsExpiresHasPassed) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_NONE, Profile::Ts26517); + const auto expiry = future_ntp(600); + fdt.set_expires(expiry); + EXPECT_FALSE(fdt.expired(expiry - 1)); + EXPECT_FALSE(fdt.expired(expiry)); + EXPECT_TRUE(fdt.expired(expiry + 1)); +} + +TEST(FdtExpiryTest, AnInstanceWithNoExpiresNeverExpires) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_NONE, Profile::Ts26517); + EXPECT_FALSE(fdt.expired(0xFFFFFFFFULL)); +} + + +/* RFC 3926 clause 3.3: "A sender MUST use an expiry time in the future upon creation of an FDT + Instance relative to its Sender Current Time (SCT)." Binding under every profile. */ +TEST(FdtExpiryTest, APastExpiryIsRefused) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_NONE, Profile::Ts26517); + EXPECT_THROW(fdt.set_expires(1000), std::runtime_error); + EXPECT_THROW(fdt.set_expires(0), std::runtime_error); +} + +TEST(FdtExpiryTest, APastExpiryIsRefusedWhenUnprofiledToo) { + auto oti = make_fec_oti(); + FileDeliveryTable fdt(1, oti, FileDeliveryTable::FDT_NS_NONE, Profile::Unprofiled); + EXPECT_THROW(fdt.set_expires(1000), std::runtime_error); + EXPECT_NO_THROW(fdt.set_expires(future_ntp(60))); +} + +/* ------------------------------------------------------------------------------------------- */ +/* A packet that carries no payload, which RFC 3450 clause 4.1 provides for and RFC 3926 clause + 3.1 gives a shape to in a FLUTE session. */ + +/* RFC 3926 clause 3.1: "the exception that ALC packets sent in a FLUTE session with the Close + Session (A) flag set to 1 (signaling the end of the session) and that contain no payload + (carrying no information for any file or FDT) SHALL NOT carry the TOI" */ +TEST(DataLessClosePacket, CarriesTheCloseFlagAndNoToi) { + AlcPacket packet(/*tsi*/ 0x1234, AlcPacket::CloseSession{}); + + EXPECT_EQ(packet.size(), 12u) << "base word, CCI, TSI, and nothing else"; + EXPECT_EQ(packet.size(), packet.header_length()) << "no payload means no FEC Payload ID either"; + + const auto* bytes = reinterpret_cast(packet.data()); + EXPECT_EQ(bytes[0] >> 4, 1) << "LCT version 1"; + EXPECT_EQ((bytes[0] >> 2) & 0x03, 0) << "C=0, a 32-bit CCI"; + EXPECT_EQ((bytes[1] >> 7) & 0x01, 1) << "S=1, a 32-bit TSI"; + EXPECT_EQ((bytes[1] >> 5) & 0x03, 0) << "O=0, no TOI word"; + EXPECT_EQ((bytes[1] >> 4) & 0x01, 0) << "H=0, no half-word for either field"; + EXPECT_EQ((bytes[1] >> 1) & 0x01, 1) << "A=1, Close Session"; + EXPECT_EQ(bytes[1] & 0x01, 0) << "B=0"; + EXPECT_EQ(bytes[2], 3) << "three 32-bit words of header"; + + /* The CCI word is zero: there is no data to pace. */ + EXPECT_EQ(bytes[4], 0); EXPECT_EQ(bytes[5], 0); + EXPECT_EQ(bytes[6], 0); EXPECT_EQ(bytes[7], 0); + + /* The TSI, in the single word the encoding leaves for it. */ + const uint32_t tsi = (uint32_t(bytes[8]) << 24) | (uint32_t(bytes[9]) << 16) | + (uint32_t(bytes[10]) << 8) | uint32_t(bytes[11]); + EXPECT_EQ(tsi, 0x1234u); +} + +/* Dropping the TOI drops the half-word the two fields share, which leaves the TSI one whole word. + RFC 5651 clause 5.1: "The TSI field is 32*S + 16*H bits in length" */ +TEST(DataLessClosePacket, RefusedRatherThanTruncatedForAWiderTsi) { + EXPECT_NO_THROW(AlcPacket(0xFFFFFFFFULL, AlcPacket::CloseSession{})); + EXPECT_THROW(AlcPacket(0x100000000ULL, AlcPacket::CloseSession{}), std::runtime_error); +} + +/* The packet this library now sends must be one it can also read back, and reading it must not + invent a TOI. + RFC 3450 clause 4.1: "The total datagram length, conveyed by outer protocol headers + (e.g., the IP or UDP header), enables receivers to detect the absence of the ALC payload and FEC + Payload ID." */ +TEST(DataLessClosePacket, RoundTripsThroughTheParser) { + AlcPacket sent(/*tsi*/ 0xABCD, AlcPacket::CloseSession{}); + std::vector wire(sent.data(), sent.data() + sent.size()); + + AlcPacket received(wire.data(), wire.size()); + EXPECT_EQ(received.tsi(), 0xABCDu); + EXPECT_TRUE(received.close_session_flag()); + EXPECT_FALSE(received.close_object_flag()); + EXPECT_EQ(received.header_length(), wire.size()) + << "the whole datagram is header, so a receiver sees a zero-length payload"; +} + +/* Before this was handled, a data-less packet was taken for an FDT packet, because a header with no + TOI decodes to TOI 0, and then the payload walk subtracted a four-byte FEC Payload ID from a + zero-length payload, wrapped, and read far past the buffer. This delivers one to a live receiver + and then a real content packet, so a receiver that survived intact is the thing being checked. */ +TEST(DataLessClosePacket, DoesNotDisturbALiveReceiver) { + boost::asio::io_context io; + auto work_guard = boost::asio::make_work_guard(io); + LibFlute::Receiver receiver("0.0.0.0", "239.255.9.11", 19193, /*tsi*/ 779, io); + + std::atomic close_seen{false}; + receiver.register_close_notification_callback( + [&close_seen](bool session, bool /*object*/, uint64_t /*toi*/) { + if (session) close_seen = true; + }); + + std::thread io_thread([&io]() { io.run(); }); + + int sock = socket(AF_INET, SOCK_DGRAM, 0); + ASSERT_GE(sock, 0); + sockaddr_in dst{}; + dst.sin_family = AF_INET; + dst.sin_port = htons(19193); + inet_pton(AF_INET, "239.255.9.11", &dst.sin_addr); + + AlcPacket close_packet(779, AlcPacket::CloseSession{}); + ASSERT_EQ(sendto(sock, close_packet.data(), close_packet.size(), 0, + reinterpret_cast(&dst), sizeof(dst)), + static_cast(close_packet.size())); + + std::this_thread::sleep_for(100ms); + + auto buf = build_content_packet_with_fti(779, 7, 1000, 64, "0123456789"); + ASSERT_EQ(sendto(sock, buf.data(), buf.size(), 0, + reinterpret_cast(&dst), sizeof(dst)), + static_cast(buf.size())); + ::close(sock); + + bool found = false; + for (int i = 0; i < 50 && !found; ++i) { + std::this_thread::sleep_for(20ms); + for (const auto& f : receiver.file_list()) { + if (f->meta().toi == 7) found = true; + } + } + EXPECT_TRUE(found) << "the receiver did not go on to handle a real packet"; + EXPECT_TRUE(close_seen.load()) << "the Close Session flag was not reported"; + + for (const auto& f : receiver.file_list()) { + EXPECT_NE(f->meta().toi, 0u) + << "the data-less packet was taken for an FDT packet and started a TOI 0 object"; + } + + receiver.stop(); + work_guard.reset(); + io.stop(); + io_thread.join(); +} diff --git a/tests/test_transmitter.cpp b/tests/test_transmitter.cpp index dbb191a8..d1a1cc59 100644 --- a/tests/test_transmitter.cpp +++ b/tests/test_transmitter.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -11,6 +12,24 @@ using namespace LibFlute; +namespace { + uint16_t read_be16(const uint8_t* p) { return (static_cast(p[0]) << 8) | p[1]; } + + /* A second implementation of the same one's-complement sum Transmitter.cpp computes, so the + transmitted checksum is re-derived here rather than read back from the code under test. */ + uint16_t ones_complement_sum(const uint8_t* buffer, size_t len) { + uint32_t sum = 0; + while (len > 1) { + sum += (static_cast(buffer[0]) << 8) | buffer[1]; + len -= 2; + buffer += 2; + } + if (len > 0) sum += static_cast(buffer[0]) << 8; + while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16); + return static_cast(~sum); + } +} + // Helper to construct a Transmitter for tests static std::unique_ptr make_tx(boost::asio::io_context &io, uint32_t rate_limit = 0) { // Use a multicast address and reasonable MTU @@ -81,6 +100,88 @@ TEST(TransmitterGetterSetterTest, UdpTunnelAddressSetAndUnset) { EXPECT_FALSE(tx->udp_tunnel_address().has_value()); } +/* The tunnel path's inner IP and UDP headers are built by create_ip_hdr() and create_udp_pkt(), + both file-local to Transmitter.cpp. This drives them through a real Transmitter with an IPv6 + destination and a UDP tunnel, and parses the bytes it actually sends to a local socket standing + in for the tunnel peer. */ +TEST(TransmitterIPv6TunnelTest, BuildsCorrectInnerIPv6AndUdpHeaders) { + using namespace std::chrono_literals; + + boost::asio::io_context io; + auto work_guard = boost::asio::make_work_guard(io); + + // A real local socket standing in for the tunnel peer, so the Transmitter's tunnel-local-address + // resolution (which connect()s a throwaway socket to the tunnel endpoint to learn its own + // source address) has something real to connect to. + boost::asio::ip::udp::socket tunnel_peer(io, boost::asio::ip::udp::endpoint(boost::asio::ip::udp::v6(), 0)); + const auto tunnel_port = tunnel_peer.local_endpoint().port(); + boost::asio::ip::udp::endpoint tunnel_endpoint(boost::asio::ip::make_address("::1"), tunnel_port); + + const std::string destination = "ff3e:30:2001:db8::1234"; // RFC 3306 unicast-prefix-based multicast + Transmitter tx(destination, 5000, /*tsi*/1234, /*mtu*/1400, /*rate_limit*/0, io, tunnel_endpoint); + + std::vector received(2048); + boost::asio::ip::udp::endpoint sender_endpoint; + std::promise received_promise; + auto received_future = received_promise.get_future(); + tunnel_peer.async_receive_from(boost::asio::buffer(received), sender_endpoint, + [&](const boost::system::error_code& ec, size_t bytes) { + if (!ec) received_promise.set_value(bytes); + }); + + const std::vector payload{'i', 'p', 'v', '6', '-', 't', 'u', 'n', 'n', 'e', 'l'}; + auto file = std::make_shared("test/ipv6-tunnel.bin", payload); + tx.send(file); + + std::thread io_thread([&io]() { io.run(); }); + ASSERT_EQ(received_future.wait_for(2s), std::future_status::ready); + size_t bytes_received = received_future.get(); + ASSERT_GE(bytes_received, 40u + 8u); // IPv6 header + UDP header, at minimum + + const uint8_t* ip = received.data(); + /* The fields checked below are RFC 8200 clause 3's, in its order: version nibble, payload length, + next header, hop limit, then the two addresses. + + RFC 8200 clause 3: + "Version 4-bit Internet Protocol version number = 6." + */ + EXPECT_EQ(ip[0] >> 4, 6) << "the version nibble is not 6"; + uint16_t payload_length = read_be16(ip + 4); + EXPECT_EQ(static_cast(payload_length) + 40, bytes_received) + << "IPv6 payload length field should equal the actual UDP segment size"; + EXPECT_EQ(ip[6], 17) << "Next header should be UDP (17)"; + boost::asio::ip::address_v6::bytes_type src_bytes, dst_bytes; + std::memcpy(src_bytes.data(), ip + 8, 16); + std::memcpy(dst_bytes.data(), ip + 24, 16); + EXPECT_EQ(boost::asio::ip::make_address_v6(dst_bytes), boost::asio::ip::make_address(destination).to_v6()) + << "IPv6 destination address should match the Transmitter's configured endpoint"; + + // UDP header + payload, immediately following the 40-byte IPv6 header. + const uint8_t* udp = ip + 40; + size_t udp_length = bytes_received - 40; + EXPECT_EQ(read_be16(udp + 4), udp_length) << "UDP length field should equal the actual segment size"; + + // Re-verify the UDP checksum independently: per RFC 8200 clause 8.1, summing the IPv6 pseudo-header + // plus the UDP segment (including the transmitted checksum field itself, not zeroed) must + // yield exactly zero for a valid checksum, by the standard one's-complement self-check property. + std::vector pseudo_and_segment(40 + udp_length); + std::memcpy(pseudo_and_segment.data(), src_bytes.data(), 16); + std::memcpy(pseudo_and_segment.data() + 16, dst_bytes.data(), 16); + pseudo_and_segment[32] = 0; pseudo_and_segment[33] = 0; + pseudo_and_segment[34] = static_cast(udp_length >> 8); + pseudo_and_segment[35] = static_cast(udp_length & 0xFF); + pseudo_and_segment[36] = 0; pseudo_and_segment[37] = 0; pseudo_and_segment[38] = 0; + pseudo_and_segment[39] = 17; // next header (UDP) + std::memcpy(pseudo_and_segment.data() + 40, udp, udp_length); + EXPECT_EQ(ones_complement_sum(pseudo_and_segment.data(), pseudo_and_segment.size()), 0u) + << "IPv6 UDP pseudo-header checksum should self-verify to zero"; + + tx.deactivate(); + work_guard.reset(); + io.stop(); + io_thread.join(); +} + TEST(TransmitterLifecycleTest, DeferredDeactivationDrainsQueuedFilesAndStopsFutureSends) { using namespace std::chrono_literals;