use std::time::Duration;
use rama_http_types::{HeaderName, HeaderValue};
use rama_utils::collections::NonEmptySmallVec;
use crate::util::{self, IterExt};
use crate::{Error, HeaderDecode, HeaderEncode, TypedHeader};
macro_rules! client_hint {
(
#[doc = $ch_doc:literal]
pub enum ClientHint {
$(
#[doc = $doc:literal]
$name:ident($($str:literal),*),
)+
}
) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ClientHint {
$(
#[doc = $doc]
$name,
)+
}
impl ClientHint {
#[doc = "Checks if the client hint is low entropy, meaning that it will be send by default."]
#[must_use] pub fn is_low_entropy(&self) -> bool {
matches!(self, Self::SaveData | Self::Ua | Self::Mobile | Self::Platform)
}
#[inline]
#[doc = "Attempts to convert a `HeaderName` to a `ClientHint`."]
pub fn match_header_name(name: &::rama_http_types::HeaderName) -> Option<Self> {
name.try_into().ok()
}
#[doc = "All header-name spellings this client hint answers to, as borrowed `'static` strs: the preferred (`Sec-CH-` prefixed) form first, followed by any legacy bare alias (e.g. `[\"sec-ch-ect\", \"ect\"]`). Allocation-free — the slice is statically promoted."]
#[must_use] pub fn header_name_strs(&self) -> &'static [&'static str] {
match self {
$(
Self::$name => {
const NAMES: &[&str] = &[$($str,)+];
NAMES
},
)+
}
}
#[doc = "Return an iterator of all header names for this client hint. Allocation-free."]
pub fn iter_header_names(&self) -> impl Iterator<Item = ::rama_http_types::HeaderName> {
self.header_name_strs()
.iter()
.copied()
.map(::rama_http_types::HeaderName::from_static)
}
#[doc = "Returns the preferred string representation of the client hint."]
#[must_use] pub fn as_str(&self) -> &'static str {
self.header_name_strs()[0]
}
}
rama_utils::macros::error::static_str_error! {
pub struct ClientHintParsingError;
}
impl TryFrom<&str> for ClientHint {
type Error = ClientHintParsingError;
fn try_from(name: &str) -> Result<Self, Self::Error> {
rama_utils::macros::match_ignore_ascii_case_str! {
match (name) {
$(
$($str)|+ => Ok(Self::$name),
)+
_ => Err(ClientHintParsingError),
}
}
}
}
impl TryFrom<String> for ClientHint {
type Error = ClientHintParsingError;
fn try_from(name: String) -> Result<Self, Self::Error> {
Self::try_from(name.as_str())
}
}
impl TryFrom<::rama_http_types::HeaderName> for ClientHint {
type Error = ClientHintParsingError;
fn try_from(name: ::rama_http_types::HeaderName) -> Result<Self, Self::Error> {
Self::try_from(name.as_str())
}
}
impl TryFrom<&::rama_http_types::HeaderName> for ClientHint {
type Error = ClientHintParsingError;
fn try_from(name: &::rama_http_types::HeaderName) -> Result<Self, Self::Error> {
Self::try_from(name.as_str())
}
}
impl std::str::FromStr for ClientHint {
type Err = ClientHintParsingError;
#[inline]
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(s)
}
}
impl std::fmt::Display for ClientHint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl serde::Serialize for ClientHint {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for ClientHint {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let s = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
Self::try_from(s.as_ref()).map_err(D::Error::custom)
}
}
#[doc = "Returns an iterator over all client hints."]
pub fn all_client_hints() -> impl Iterator<Item = ClientHint> {
[
$(
ClientHint::$name,
)+
].into_iter()
}
#[doc = "Returns an iterator over all client hint header name strings."]
pub fn all_client_hint_header_name_strings() -> impl Iterator<Item = &'static str> {
[
$(
$($str,)+
)+
].into_iter()
}
#[doc = "Returns an iterator over all client hint header names."]
pub fn all_client_hint_header_names() -> impl Iterator<Item = ::rama_http_types::HeaderName> {
all_client_hint_header_name_strings().map(::rama_http_types::HeaderName::from_static)
}
};
}
client_hint! {
#[doc = "Client Hints are a set of HTTP Headers and a JavaScript API that allow web browsers to send detailed information about the client device and browser to web servers. They are designed to be a successor to User-Agent, and provide a standardized way for web servers to optimize content for the client without relying on unreliable user-agent string-based detection or browser fingerprinting techniques."]
pub enum ClientHint {
Ua("sec-ch-ua"),
FullVersion("sec-ch-ua-full-version"),
FullVersionList("sec-ch-ua-full-version-list"),
Platform("sec-ch-ua-platform"),
PlatformVersion("sec-ch-ua-platform-version"),
Arch("sec-ch-ua-arch"),
Bitness("sec-ch-ua-bitness"),
Wow64("sec-ch-ua-wow64"),
Model("sec-ch-ua-model"),
Mobile("sec-ch-ua-mobile"),
FormFactor("sec-ch-ua-form-factors"),
Lang("sec-ch-lang", "lang"),
SaveData("sec-ch-save-data", "save-data"),
Width("sec-ch-width"),
ViewportWidth("sec-ch-viewport-width", "viewport-width"),
ViewportHeight("sec-ch-viewport-height"),
Dpr("sec-ch-dpr", "dpr"),
DeviceMemory("sec-ch-device-memory", "device-memory"),
Rtt("sec-ch-rtt", "rtt"),
Downlink("sec-ch-downlink", "downlink"),
Ect("sec-ch-ect", "ect"),
PrefersColorScheme("sec-ch-prefers-color-scheme"),
PrefersReducedMotion("sec-ch-prefers-reduced-motion"),
PrefersReducedTransparency("sec-ch-prefers-reduced-transparency"),
PrefersContrast("sec-ch-prefers-contrast"),
ForcedColors("sec-ch-forced-colors"),
}
}
fn encode_client_hint_names(hints: &NonEmptySmallVec<16, ClientHint>) -> Option<HeaderValue> {
let mut buf = Vec::new();
for name in hints
.iter()
.flat_map(|h| h.header_name_strs().iter().copied())
{
if !buf.is_empty() {
buf.extend_from_slice(b", ");
}
buf.extend_from_slice(name.as_bytes());
}
HeaderValue::from_bytes(&buf).ok()
}
macro_rules! client_hint_advert_header {
(
#[header(name = $name:ident)]
$(#[$m:meta])*
$type:ident
) => {
$(#[$m])*
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct $type(pub NonEmptySmallVec<16, ClientHint>);
impl $type {
#[doc = concat!("Create a `", stringify!($type), "` advertising a single [`ClientHint`].")]
#[must_use]
pub fn new(hint: ClientHint) -> Self {
Self(NonEmptySmallVec::new(hint))
}
}
impl TypedHeader for $type {
fn name() -> &'static HeaderName {
&rama_http_types::header::$name
}
}
impl HeaderDecode for $type {
fn decode<'i, I>(values: &mut I) -> Result<Self, Error>
where
I: Iterator<Item = &'i HeaderValue>,
{
util::try_decode_flat_csv_header_values_as_non_empty_smallvec(
values,
util::FlatCsvSeparator::Comma,
)
.map(Self)
.map_err(|err| {
rama_core::telemetry::tracing::debug!(
concat!("failed to decode ", stringify!($name), ": {}"),
err,
);
Error::invalid()
})
}
}
impl HeaderEncode for $type {
fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
match encode_client_hint_names(&self.0) {
Some(value) => values.extend(std::iter::once(value)),
None => rama_core::telemetry::tracing::debug!(
concat!("failed to encode ", stringify!($name), " client-hint names")
),
}
}
}
};
}
client_hint_advert_header! {
#[header(name = ACCEPT_CH)]
AcceptCh
}
client_hint_advert_header! {
#[header(name = CRITICAL_CH)]
CriticalCh
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SaveData(bool);
impl SaveData {
pub const HINT: ClientHint = ClientHint::SaveData;
#[must_use]
pub const fn new(enabled: bool) -> Self {
Self(enabled)
}
#[must_use]
pub const fn on() -> Self {
Self(true)
}
#[must_use]
pub const fn off() -> Self {
Self(false)
}
#[must_use]
pub const fn is_on(self) -> bool {
self.0
}
}
impl From<bool> for SaveData {
fn from(enabled: bool) -> Self {
Self(enabled)
}
}
impl From<SaveData> for bool {
fn from(value: SaveData) -> Self {
value.0
}
}
impl TypedHeader for SaveData {
fn name() -> &'static HeaderName {
&rama_http_types::header::SEC_CH_SAVE_DATA
}
}
impl HeaderDecode for SaveData {
fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
let value = values
.just_one()
.and_then(|value| value.to_str().ok())
.ok_or_else(Error::invalid)?;
if value.eq_ignore_ascii_case("on") {
Ok(Self(true))
} else if value.eq_ignore_ascii_case("off") {
Ok(Self(false))
} else {
Err(Error::invalid())
}
}
}
impl HeaderEncode for SaveData {
fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
let value = if self.0 {
HeaderValue::from_static("on")
} else {
HeaderValue::from_static("off")
};
values.extend(std::iter::once(value));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Ect {
Slow2g,
Type2g,
Type3g,
Type4g,
}
impl Ect {
pub const HINT: ClientHint = ClientHint::Ect;
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Slow2g => "slow-2g",
Self::Type2g => "2g",
Self::Type3g => "3g",
Self::Type4g => "4g",
}
}
}
impl std::fmt::Display for Ect {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl TypedHeader for Ect {
fn name() -> &'static HeaderName {
&rama_http_types::header::SEC_CH_ECT
}
}
impl HeaderDecode for Ect {
fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
let value = values
.just_one()
.and_then(|value| value.to_str().ok())
.ok_or_else(Error::invalid)?;
rama_utils::macros::match_ignore_ascii_case_str! {
match (value) {
"slow-2g" => Ok(Self::Slow2g),
"2g" => Ok(Self::Type2g),
"3g" => Ok(Self::Type3g),
"4g" => Ok(Self::Type4g),
_ => Err(Error::invalid()),
}
}
}
}
impl HeaderEncode for Ect {
fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
values.extend(std::iter::once(HeaderValue::from_static(self.as_str())));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Rtt(u64);
impl Rtt {
pub const HINT: ClientHint = ClientHint::Rtt;
#[must_use]
pub const fn from_millis(millis: u64) -> Self {
Self(millis)
}
#[must_use]
pub const fn as_millis(self) -> u64 {
self.0
}
#[must_use]
pub const fn as_duration(self) -> Duration {
Duration::from_millis(self.0)
}
}
impl From<Rtt> for Duration {
fn from(rtt: Rtt) -> Self {
rtt.as_duration()
}
}
impl TypedHeader for Rtt {
fn name() -> &'static HeaderName {
&rama_http_types::header::SEC_CH_RTT
}
}
impl HeaderDecode for Rtt {
fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
values
.just_one()
.and_then(|value| value.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(Self)
.ok_or_else(Error::invalid)
}
}
impl HeaderEncode for Rtt {
fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
values.extend(std::iter::once(self.0.into()));
}
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Downlink(f64);
impl Downlink {
pub const HINT: ClientHint = ClientHint::Downlink;
#[must_use]
pub const fn new(mbps: f64) -> Self {
Self(mbps)
}
#[must_use]
pub const fn as_mbps(self) -> f64 {
self.0
}
}
impl From<Downlink> for f64 {
fn from(downlink: Downlink) -> Self {
downlink.0
}
}
impl TypedHeader for Downlink {
fn name() -> &'static HeaderName {
&rama_http_types::header::SEC_CH_DOWNLINK
}
}
impl HeaderDecode for Downlink {
fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
values
.just_one()
.and_then(|value| value.to_str().ok())
.and_then(|s| s.parse::<f64>().ok())
.filter(|mbps| mbps.is_finite() && *mbps >= 0.0)
.map(Self)
.ok_or_else(Error::invalid)
}
}
impl HeaderEncode for Downlink {
fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
values.extend(std::iter::once(util::fmt(self.0)));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_hint_ua_from_str() {
let hint = ClientHint::try_from("Sec-CH-UA").unwrap();
assert_eq!(hint, ClientHint::Ua);
}
#[test]
fn test_client_hint_ua_from_str_lowercase() {
let hint = ClientHint::try_from("sec-ch-ua").unwrap();
assert_eq!(hint, ClientHint::Ua);
}
#[test]
fn test_client_hint_ua_from_str_uppercase() {
let hint = ClientHint::try_from("SEC-CH-UA").unwrap();
assert_eq!(hint, ClientHint::Ua);
}
#[test]
fn test_client_hint_ua_from_str_mixedcase() {
let hint = ClientHint::try_from("Sec-CH-UA").unwrap();
assert_eq!(hint, ClientHint::Ua);
}
#[test]
fn test_client_hint_low_entropy() {
let hints = [
"Sec-CH-UA",
"Sec-CH-UA-Mobile",
"Sec-CH-UA-Platform",
"Save-Data",
"Sec-CH-Save-Data",
];
for hint in hints {
let hint = ClientHint::try_from(hint).expect(hint);
assert!(hint.is_low_entropy());
}
}
#[test]
fn test_client_hint_high_entropy() {
let hints = [
"Sec-CH-UA-Full-Version",
"Sec-CH-UA-Full-Version-List",
"Sec-CH-UA-Platform-Version",
"Sec-CH-UA-Arch",
"Sec-CH-UA-Bitness",
"Sec-CH-UA-WoW64",
"Sec-CH-UA-Model",
"Sec-CH-UA-Form-Factors",
"Sec-CH-Width",
"Sec-CH-Viewport-Width",
"Sec-CH-Viewport-Height",
"Sec-CH-DPR",
"Sec-CH-Device-Memory",
"Sec-CH-RTT",
"Sec-CH-Downlink",
"Sec-CH-ECT",
"Sec-CH-Prefers-Color-Scheme",
"Sec-CH-Prefers-Reduced-Motion",
"Sec-CH-Prefers-Reduced-Transparency",
"Sec-CH-Prefers-Contrast",
"Sec-CH-Forced-Colors",
];
for hint in hints {
let hint = ClientHint::try_from(hint).expect(hint);
assert!(!hint.is_low_entropy());
}
}
#[test]
fn test_all_client_hint_header_name_strings_contains_some_hints() {
let strings = all_client_hint_header_name_strings().collect::<Vec<_>>();
assert!(strings.contains(&"sec-ch-ua"), "{strings:?}");
}
#[test]
fn test_all_client_hint_header_names() {
let names = all_client_hint_header_names().collect::<Vec<_>>();
let strings = all_client_hint_header_name_strings().collect::<Vec<_>>();
assert_eq!(names.len(), strings.len());
for (name, string) in names.iter().zip(strings.iter()) {
assert_eq!(name.as_str(), *string);
}
}
fn decode<T: HeaderDecode>(values: &[&str]) -> Option<T> {
use crate::HeaderMapExt;
let mut map = rama_http_types::HeaderMap::new();
for value in values {
map.append(T::name(), value.parse().unwrap());
}
map.typed_get()
}
fn encode<T: HeaderEncode>(header: T) -> String {
use crate::HeaderMapExt;
let mut map = rama_http_types::HeaderMap::new();
map.typed_insert(header);
map.get(T::name())
.expect("header set")
.to_str()
.unwrap()
.to_owned()
}
#[test]
fn test_save_data_decode() {
assert_eq!(decode::<SaveData>(&["on"]), Some(SaveData::on()));
assert_eq!(decode::<SaveData>(&["ON"]), Some(SaveData::on()));
assert_eq!(decode::<SaveData>(&["off"]), Some(SaveData::off()));
assert!(decode::<SaveData>(&["1"]).is_none());
assert!(decode::<SaveData>(&[""]).is_none());
assert!(decode::<SaveData>(&["on", "off"]).is_none());
}
#[test]
fn test_save_data_round_trip() {
assert_eq!(encode(SaveData::on()), "on");
assert_eq!(encode(SaveData::off()), "off");
assert_eq!(
decode::<SaveData>(&[encode(SaveData::on()).as_str()]),
Some(SaveData::on())
);
}
#[test]
fn test_ect_decode() {
assert_eq!(decode::<Ect>(&["slow-2g"]), Some(Ect::Slow2g));
assert_eq!(decode::<Ect>(&["2g"]), Some(Ect::Type2g));
assert_eq!(decode::<Ect>(&["3g"]), Some(Ect::Type3g));
assert_eq!(decode::<Ect>(&["4g"]), Some(Ect::Type4g));
assert_eq!(decode::<Ect>(&["SLOW-2G"]), Some(Ect::Slow2g));
assert!(decode::<Ect>(&["5g"]).is_none());
assert!(decode::<Ect>(&[""]).is_none());
}
#[test]
fn test_ect_round_trip() {
for ect in [Ect::Slow2g, Ect::Type2g, Ect::Type3g, Ect::Type4g] {
assert_eq!(decode::<Ect>(&[encode(ect).as_str()]), Some(ect));
}
}
#[test]
fn test_rtt_decode() {
assert_eq!(
decode::<Rtt>(&["100"]).map(Duration::from),
Some(Duration::from_millis(100)),
);
assert_eq!(decode::<Rtt>(&["0"]), Some(Rtt::from_millis(0)));
assert!(decode::<Rtt>(&["-25"]).is_none());
assert!(decode::<Rtt>(&["1.5"]).is_none());
assert!(decode::<Rtt>(&["fast"]).is_none());
}
#[test]
fn test_rtt_round_trip() {
assert_eq!(encode(Rtt::from_millis(125)), "125");
assert_eq!(decode::<Rtt>(&["125"]), Some(Rtt::from_millis(125)));
}
#[test]
fn test_downlink_decode() {
assert_eq!(decode::<Downlink>(&["1.5"]), Some(Downlink::new(1.5)));
assert_eq!(decode::<Downlink>(&["100"]), Some(Downlink::new(100.0)));
assert_eq!(decode::<Downlink>(&["0"]), Some(Downlink::new(0.0)));
assert!(decode::<Downlink>(&["-1"]).is_none());
assert!(decode::<Downlink>(&["inf"]).is_none());
assert!(decode::<Downlink>(&["fast"]).is_none());
}
#[test]
fn test_downlink_round_trip() {
assert_eq!(encode(Downlink::new(1.5)), "1.5");
assert_eq!(encode(Downlink::new(100.0)), "100");
assert_eq!(decode::<Downlink>(&["1.5"]), Some(Downlink::new(1.5)));
}
#[test]
fn test_accept_ch_decode() {
use rama_utils::collections::non_empty_smallvec;
assert_eq!(
decode::<AcceptCh>(&["sec-ch-ua, sec-ch-ua-platform, sec-ch-ua-mobile"]),
Some(AcceptCh(non_empty_smallvec![
ClientHint::Ua,
ClientHint::Platform,
ClientHint::Mobile; 16
])),
);
assert_eq!(
decode::<AcceptCh>(&["DPR, save-data"]),
Some(AcceptCh(non_empty_smallvec![
ClientHint::Dpr,
ClientHint::SaveData; 16
])),
);
assert_eq!(
decode::<AcceptCh>(&["sec-ch-ua", "sec-ch-ua-platform"]),
Some(AcceptCh(non_empty_smallvec![
ClientHint::Ua,
ClientHint::Platform; 16
])),
);
assert!(decode::<AcceptCh>(&["sec-ch-ua, not-a-hint"]).is_none());
assert!(decode::<AcceptCh>(&[""]).is_none());
assert!(decode::<AcceptCh>(&[]).is_none());
}
#[test]
fn test_accept_ch_round_trip() {
use rama_utils::collections::non_empty_smallvec;
let header = AcceptCh(non_empty_smallvec![
ClientHint::Ua,
ClientHint::Platform,
ClientHint::Mobile; 16
]);
assert_eq!(
encode(header.clone()),
"sec-ch-ua, sec-ch-ua-platform, sec-ch-ua-mobile",
);
assert_eq!(
decode::<AcceptCh>(&[encode(header.clone()).as_str()]),
Some(header)
);
}
#[test]
fn test_critical_ch_round_trip() {
use rama_utils::collections::non_empty_smallvec;
let header = CriticalCh(non_empty_smallvec![ClientHint::Ua, ClientHint::Platform; 16]);
assert_eq!(encode(header.clone()), "sec-ch-ua, sec-ch-ua-platform");
assert_eq!(
decode::<CriticalCh>(&[encode(header.clone()).as_str()]),
Some(header),
);
}
#[test]
fn test_header_name_strs() {
assert_eq!(ClientHint::Ect.header_name_strs(), &["sec-ch-ect", "ect"]);
assert_eq!(
ClientHint::SaveData.header_name_strs(),
&["sec-ch-save-data", "save-data"]
);
assert_eq!(ClientHint::Ua.header_name_strs(), &["sec-ch-ua"]);
for hint in all_client_hints() {
assert_eq!(hint.as_str(), hint.header_name_strs()[0]);
let names: Vec<_> = hint
.iter_header_names()
.map(|n| n.as_str().to_owned())
.collect();
assert_eq!(names, hint.header_name_strs());
}
}
#[test]
fn test_accept_ch_emits_all_aliases() {
use rama_utils::collections::non_empty_smallvec;
let header = AcceptCh(non_empty_smallvec![
ClientHint::SaveData,
ClientHint::Ect,
ClientHint::Rtt,
ClientHint::Downlink; 16
]);
assert_eq!(
encode(header),
"sec-ch-save-data, save-data, sec-ch-ect, ect, \
sec-ch-rtt, rtt, sec-ch-downlink, downlink",
);
assert_eq!(
decode::<AcceptCh>(&["save-data, ect, rtt, downlink"]),
Some(AcceptCh(non_empty_smallvec![
ClientHint::SaveData,
ClientHint::Ect,
ClientHint::Rtt,
ClientHint::Downlink; 16
])),
);
}
#[test]
fn test_critical_ch_emits_all_aliases() {
let header = CriticalCh::new(ClientHint::SaveData);
assert_eq!(encode(header), "sec-ch-save-data, save-data");
}
#[test]
fn test_typed_value_parsers_match_their_client_hint() {
assert_eq!(SaveData::HINT, ClientHint::SaveData);
assert_eq!(Ect::HINT, ClientHint::Ect);
assert_eq!(Rtt::HINT, ClientHint::Rtt);
assert_eq!(Downlink::HINT, ClientHint::Downlink);
assert_eq!(SaveData::name().as_str(), SaveData::HINT.as_str());
assert_eq!(Ect::name().as_str(), Ect::HINT.as_str());
assert_eq!(Rtt::name().as_str(), Rtt::HINT.as_str());
assert_eq!(Downlink::name().as_str(), Downlink::HINT.as_str());
}
}