Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
152 changes: 152 additions & 0 deletions src/SafeTrackName.cpp
Original file line number Diff line number Diff line change
@@ -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 <charconv>
#include <vector>

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<char> 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<char>(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<unsigned char>(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<std::string> 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<unsigned char>(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<moxygen::TrackNamespace> parseSafeNamespace(std::string_view text) {
std::vector<std::string> 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<moxygen::FullTrackName> 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
38 changes: 38 additions & 0 deletions src/SafeTrackName.h
Original file line number Diff line number Diff line change
@@ -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 <optional>
#include <string>
#include <string_view>

#include <moxygen/MoQTypes.h>

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<std::string> parseSafeBytes(std::string_view text);

std::optional<moxygen::TrackNamespace> 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<moxygen::FullTrackName> parseSafeFullTrackName(std::string_view text);

} // namespace openmoq::moqx
12 changes: 12 additions & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
147 changes: 147 additions & 0 deletions test/SafeTrackNameTest.cpp
Original file line number Diff line number Diff line change
@@ -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 <gtest/gtest.h>

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<std::string>({"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<std::string>({"", ""}));
}

TEST(SafeTrackNameTest, ParsesFullTrackName) {
auto ftn = parseSafeFullTrackName("conf-room1--video");
ASSERT_TRUE(ftn);
EXPECT_EQ(ftn->trackNamespace.trackNamespace, std::vector<std::string>({"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<std::string>({"a", ""}));
EXPECT_EQ(trailing->trackName, "v");

auto emptyTrack = parseSafeFullTrackName("a--");
ASSERT_TRUE(emptyTrack);
EXPECT_EQ(emptyTrack->trackNamespace.trackNamespace, std::vector<std::string>({"a"}));
EXPECT_EQ(emptyTrack->trackName, "");
}

TEST(SafeTrackNameTest, RoundTripsAwkwardNames) {
const std::vector<moxygen::FullTrackName> 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
Loading