diff --git a/CMakeLists.txt b/CMakeLists.txt index 7f3d200a..2256348c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -166,6 +166,7 @@ add_library(moqx_core STATIC src/stats/QuicStatsCollector.cpp src/stats/EventBaseStatsCollector.cpp src/UpstreamProvider.cpp + src/SafeTrackName.cpp src/relay/AuthFilters.cpp src/relay/TopNFilter.cpp src/relay/PropertyRanking.cpp diff --git a/src/SafeTrackName.cpp b/src/SafeTrackName.cpp new file mode 100644 index 00000000..6b40b551 --- /dev/null +++ b/src/SafeTrackName.cpp @@ -0,0 +1,152 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "SafeTrackName.h" + +#include +#include + +namespace openmoq::moqx { + +namespace { + +constexpr char kHexDigits[] = "0123456789abcdef"; + +bool passesThrough(unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; +} + +// from_chars neither skips whitespace nor accepts a sign for unsigned types, +// so requiring it to consume both digits is the whole validation. +std::optional decodeHexPair(std::string_view pair) { + uint32_t value = 0; + auto [ptr, ec] = std::from_chars(pair.data(), pair.data() + pair.size(), value, 16); + if (ec != std::errc{} || ptr != pair.data() + pair.size()) { + return std::nullopt; + } + return static_cast(value); +} + +// Unencoded length, including the '-' between tuples. A lower bound on the +// result: any byte outside the pass-through set expands to three. +size_t rawSize(const moxygen::TrackNamespace& ns) { + size_t size = ns.trackNamespace.empty() ? 0 : ns.trackNamespace.size() - 1; + for (const auto& tuple : ns.trackNamespace) { + size += tuple.size(); + } + return size; +} + +void appendSafe(std::string& out, std::string_view bytes) { + for (char ch : bytes) { + auto c = static_cast(ch); + if (passesThrough(c)) { + out += ch; + continue; + } + out += '.'; + out += kHexDigits[c >> 4]; + out += kHexDigits[c & 0x0f]; + } +} + +} // namespace + +std::string safeName(std::string_view bytes) { + std::string out; + out.reserve(bytes.size()); + appendSafe(out, bytes); + return out; +} + +namespace { + +void appendSafe(std::string& out, const moxygen::TrackNamespace& ns) { + bool first = true; + for (const auto& tuple : ns.trackNamespace) { + if (!first) { + out += '-'; + } + first = false; + appendSafe(out, tuple); + } +} + +} // namespace + +std::string safeName(const moxygen::TrackNamespace& ns) { + std::string out; + out.reserve(rawSize(ns)); + appendSafe(out, ns); + return out; +} + +std::string safeName(const moxygen::FullTrackName& ftn) { + std::string out; + out.reserve(rawSize(ftn.trackNamespace) + 2 + ftn.trackName.size()); + appendSafe(out, ftn.trackNamespace); + out += "--"; + appendSafe(out, ftn.trackName); + return out; +} + +std::optional parseSafeBytes(std::string_view text) { + std::string out; + out.reserve(text.size()); + for (size_t i = 0; i < text.size(); ++i) { + auto c = static_cast(text[i]); + if (passesThrough(c)) { + out += text[i]; + continue; + } + if (c != '.' || i + 2 >= text.size()) { + return std::nullopt; + } + auto byte = decodeHexPair(text.substr(i + 1, 2)); + if (!byte) { + return std::nullopt; + } + out += *byte; + i += 2; + } + return out; +} + +std::optional parseSafeNamespace(std::string_view text) { + std::vector tuples; + if (!text.empty()) { + size_t start = 0; + while (start <= text.size()) { + auto end = text.find('-', start); + auto piece = text.substr(start, end == std::string_view::npos ? end : end - start); + auto decoded = parseSafeBytes(piece); + if (!decoded) { + return std::nullopt; + } + tuples.push_back(std::move(*decoded)); + if (end == std::string_view::npos) { + break; + } + start = end + 1; + } + } + return moxygen::TrackNamespace(std::move(tuples)); +} + +std::optional parseSafeFullTrackName(std::string_view text) { + auto split = text.rfind("--"); + if (split == std::string_view::npos) { + return std::nullopt; + } + auto ns = parseSafeNamespace(text.substr(0, split)); + auto track = parseSafeBytes(text.substr(split + 2)); + if (!ns || !track) { + return std::nullopt; + } + return moxygen::FullTrackName{std::move(*ns), std::move(*track)}; +} + +} // namespace openmoq::moqx diff --git a/src/SafeTrackName.h b/src/SafeTrackName.h new file mode 100644 index 00000000..c8c18cc8 --- /dev/null +++ b/src/SafeTrackName.h @@ -0,0 +1,38 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include + +#include + +namespace openmoq::moqx { + +// Renders names in the form RECOMMENDED by moq-transport, "Representing +// Namespace and Track Names". +// +// The encoding is injective, so two names never collapse onto one rendering — +// which matters wherever the result keys something (metric labels, filenames). +std::string safeName(std::string_view bytes); + +std::string safeName(const moxygen::TrackNamespace& ns); + +std::string safeName(const moxygen::FullTrackName& ftn); + +// Inverses of the above; nullopt when the input is not in the safe form. +// Hex digits are accepted in either case, though safeName only emits lower. +std::optional parseSafeBytes(std::string_view text); + +std::optional parseSafeNamespace(std::string_view text); + +// Splits at the last "--": an encoded track name never contains '-', so that +// separator is unambiguous even when the namespace ends in an empty tuple. +std::optional parseSafeFullTrackName(std::string_view text); + +} // namespace openmoq::moqx diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index ff4461b0..40b5f174 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -241,6 +241,18 @@ target_link_libraries(moqx_cbor_reader_test PRIVATE ) gtest_discover_tests(moqx_cbor_reader_test) +add_executable(moqx_safe_track_name_test + SafeTrackNameTest.cpp +) +target_include_directories(moqx_safe_track_name_test PRIVATE + ${PROJECT_SOURCE_DIR}/src +) +target_link_libraries(moqx_safe_track_name_test PRIVATE + moqx_core + GTest::gtest_main +) +gtest_discover_tests(moqx_safe_track_name_test) + add_executable(moqx_relay_context_test MoqxRelayContextTest.cpp ) diff --git a/test/SafeTrackNameTest.cpp b/test/SafeTrackNameTest.cpp new file mode 100644 index 00000000..724a2be5 --- /dev/null +++ b/test/SafeTrackNameTest.cpp @@ -0,0 +1,147 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "SafeTrackName.h" + +#include + +namespace openmoq::moqx { + +TEST(SafeTrackNameTest, PassesThroughUnreservedBytes) { + EXPECT_EQ(safeName("abcXYZ019_"), "abcXYZ019_"); +} + +TEST(SafeTrackNameTest, EncodesEverythingElseAsLowerHex) { + EXPECT_EQ(safeName("a.b"), "a.2eb"); + EXPECT_EQ(safeName("a/b"), "a.2fb"); + EXPECT_EQ(safeName(" "), ".20"); + EXPECT_EQ(safeName("\n"), ".0a"); + EXPECT_EQ(safeName("\xff"), ".ff"); + EXPECT_EQ(safeName(std::string_view("\0", 1)), ".00"); +} + +// The separators are only unambiguous because '-' does not pass through. +TEST(SafeTrackNameTest, EncodesHyphen) { + EXPECT_EQ(safeName("a-b"), "a.2db"); +} + +TEST(SafeTrackNameTest, JoinsNamespaceTuplesWithHyphen) { + moxygen::TrackNamespace ns{{"conf", "room1", "layer0"}}; + EXPECT_EQ(safeName(ns), "conf-room1-layer0"); +} + +TEST(SafeTrackNameTest, EmptyNamespaceAndTuples) { + EXPECT_EQ(safeName(moxygen::TrackNamespace{}), ""); + moxygen::TrackNamespace ns{{"", ""}}; + EXPECT_EQ(safeName(ns), "-"); +} + +TEST(SafeTrackNameTest, SeparatesTrackNameWithDoubleHyphen) { + moxygen::FullTrackName ftn{moxygen::TrackNamespace{{"conf", "room1"}}, "video"}; + EXPECT_EQ(safeName(ftn), "conf-room1--video"); +} + +TEST(SafeTrackNameTest, RendersBinaryNamesReadablyWhereItCan) { + moxygen::FullTrackName ftn{ + moxygen::TrackNamespace{{"conf.example.com", std::string("bin\xff\x01", 5)}}, + "video-1" + }; + EXPECT_EQ(safeName(ftn), "conf.2eexample.2ecom-bin.ff.01--video.2d1"); +} + +// Names that differ only outside the pass-through set must not collapse onto +// one rendering; metric labels key on the result. +TEST(SafeTrackNameTest, DistinctNamesStayDistinct) { + EXPECT_NE(safeName("\xc3\xa9"), safeName("\xc3\xa8")); + EXPECT_NE(safeName("a-b"), safeName("a_b")); + + moxygen::TrackNamespace twoTuples{{"a", "b"}}; + moxygen::TrackNamespace oneTuple{{"a-b"}}; + EXPECT_NE(safeName(twoTuples), safeName(oneTuple)); +} + +TEST(SafeTrackNameTest, ParsesBytesBackToOriginal) { + EXPECT_EQ(parseSafeBytes("abcXYZ019_"), "abcXYZ019_"); + EXPECT_EQ(parseSafeBytes("a.2eb"), "a.b"); + EXPECT_EQ(parseSafeBytes(".ff"), "\xff"); + EXPECT_EQ(parseSafeBytes(""), ""); + EXPECT_EQ(parseSafeBytes(std::string_view(".00")), std::string_view("\0", 1)); +} + +TEST(SafeTrackNameTest, ParsesEitherHexCase) { + EXPECT_EQ(parseSafeBytes(".FF"), "\xff"); + EXPECT_EQ(parseSafeBytes(".aB"), "\xab"); +} + +TEST(SafeTrackNameTest, RejectsMalformedBytes) { + EXPECT_FALSE(parseSafeBytes("a.")); + EXPECT_FALSE(parseSafeBytes("a.2")); + EXPECT_FALSE(parseSafeBytes(".zz")); + EXPECT_FALSE(parseSafeBytes("a b")); + // Separators are not part of a component's alphabet. + EXPECT_FALSE(parseSafeBytes("a-b")); + // Neither a sign nor leading space may stand in for a hex digit. + EXPECT_FALSE(parseSafeBytes(".-1")); + EXPECT_FALSE(parseSafeBytes(". 1")); + EXPECT_FALSE(parseSafeBytes(".0x")); +} + +TEST(SafeTrackNameTest, ParsesNamespaceTuples) { + auto ns = parseSafeNamespace("conf-room1-layer0"); + ASSERT_TRUE(ns); + EXPECT_EQ(ns->trackNamespace, std::vector({"conf", "room1", "layer0"})); + + auto empty = parseSafeNamespace(""); + ASSERT_TRUE(empty); + EXPECT_TRUE(empty->trackNamespace.empty()); + + auto emptyTuples = parseSafeNamespace("-"); + ASSERT_TRUE(emptyTuples); + EXPECT_EQ(emptyTuples->trackNamespace, std::vector({"", ""})); +} + +TEST(SafeTrackNameTest, ParsesFullTrackName) { + auto ftn = parseSafeFullTrackName("conf-room1--video"); + ASSERT_TRUE(ftn); + EXPECT_EQ(ftn->trackNamespace.trackNamespace, std::vector({"conf", "room1"})); + EXPECT_EQ(ftn->trackName, "video"); +} + +TEST(SafeTrackNameTest, RejectsFullTrackNameWithoutSeparator) { + EXPECT_FALSE(parseSafeFullTrackName("conf-room1")); + EXPECT_FALSE(parseSafeFullTrackName("")); +} + +// The last "--" wins, so a namespace ending in an empty tuple still splits +// where the encoder put the separator. +TEST(SafeTrackNameTest, ParsesEmptyTuplesAroundSeparator) { + auto trailing = parseSafeFullTrackName("a---v"); + ASSERT_TRUE(trailing); + EXPECT_EQ(trailing->trackNamespace.trackNamespace, std::vector({"a", ""})); + EXPECT_EQ(trailing->trackName, "v"); + + auto emptyTrack = parseSafeFullTrackName("a--"); + ASSERT_TRUE(emptyTrack); + EXPECT_EQ(emptyTrack->trackNamespace.trackNamespace, std::vector({"a"})); + EXPECT_EQ(emptyTrack->trackName, ""); +} + +TEST(SafeTrackNameTest, RoundTripsAwkwardNames) { + const std::vector names{ + {moxygen::TrackNamespace{{"conf.example.com", "room 1"}}, "video-1"}, + {moxygen::TrackNamespace{{"a", ""}}, ""}, + {moxygen::TrackNamespace{{std::string("\x00\xff-", 3)}}, std::string("\n\t", 2)}, + {moxygen::TrackNamespace{}, "solo"}, + }; + for (const auto& ftn : names) { + auto parsed = parseSafeFullTrackName(safeName(ftn)); + ASSERT_TRUE(parsed) << safeName(ftn); + EXPECT_EQ(parsed->trackNamespace.trackNamespace, ftn.trackNamespace.trackNamespace); + EXPECT_EQ(parsed->trackName, ftn.trackName); + } +} + +} // namespace openmoq::moqx