Skip to content

Commit

Permalink
support non-UTF-8 value for TXT properties (#95)
Browse files Browse the repository at this point in the history
* check for proper characters in values: no `=`.
* And support "no value", i.e. boolean keys.
  • Loading branch information
keepsimple1 authored Mar 8, 2023
1 parent a07f550 commit 439b0f3
Show file tree
Hide file tree
Showing 2 changed files with 159 additions and 42 deletions.
182 changes: 145 additions & 37 deletions src/service_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,17 +160,28 @@ impl ServiceInfo {

/// Returns a property for a given `key`, where `key` is
/// case insensitive.
///
/// Returns `None` if `key` does not exist.
pub fn get_property(&self, key: &str) -> Option<&TxtProperty> {
self.txt_properties.get(key)
}

/// Returns a property value string for a given `key`, where `key` is
/// Returns a property value for a given `key`, where `key` is
/// case insensitive.
#[inline]
pub fn get_property_val(&self, key: &str) -> Option<&str> {
///
/// Returns `None` if `key` does not exist.
pub fn get_property_val(&self, key: &str) -> Option<Option<&[u8]>> {
self.txt_properties.get_property_val(key)
}

/// Returns a property value string for a given `key`, where `key` is
/// case insensitive.
///
/// Returns `None` if `key` does not exist.
pub fn get_property_val_str(&self, key: &str) -> Option<&str> {
self.txt_properties.get_property_val_str(key)
}

/// Returns the service's hostname.
#[inline]
pub fn get_hostname(&self) -> &str {
Expand Down Expand Up @@ -374,11 +385,23 @@ impl TxtProperties {
.find(|&prop| prop.key.to_lowercase() == key)
}

/// Returns a property value string for a given `key`, where `key` is
/// Returns a property value for a given `key`, where `key` is
/// case insensitive.
pub fn get_property_val(&self, key: &str) -> Option<&str> {
///
/// Returns `None` if `key` does not exist.
/// Returns `Some(Option<&u8>)` for its value.
pub fn get_property_val(&self, key: &str) -> Option<Option<&[u8]>> {
self.get(key).map(|x| x.val())
}

/// Returns a property value string for a given `key`, where `key` is
/// case insensitive.
///
/// Returns `None` if `key` does not exist.
/// Returns `Some("")` if its value is `None` or is empty.
pub fn get_property_val_str(&self, key: &str) -> Option<&str> {
self.get(key).map(|x| x.val_str())
}
}

/// Represents a property in a TXT record.
Expand All @@ -388,8 +411,9 @@ pub struct TxtProperty {
key: String,

/// RFC 6763 says values are bytes, not necessarily UTF-8.
/// For now we define `val` as UTF-8 for ergnomics benefits.
val: String,
/// It is also possible that there is no value, in which case
/// the key is a boolean key.
val: Option<Vec<u8>>,
}

impl TxtProperty {
Expand All @@ -398,9 +422,19 @@ impl TxtProperty {
&self.key
}

/// Returns the value of a property.
pub fn val(&self) -> &str {
&self.val
/// Returns the value of a property, which could be `None`.
///
/// To obtain a `&str` of the value, use `val_str()` instead.
pub fn val(&self) -> Option<&[u8]> {
self.val.as_deref()
}

/// Returns the value of a property as str.
pub fn val_str(&self) -> &str {
match &self.val {
Some(v) => std::str::from_utf8(&v[..]).unwrap_or_default(),
None => "",
}
}
}

Expand All @@ -413,7 +447,30 @@ where
fn from(prop: &(K, V)) -> Self {
TxtProperty {
key: prop.0.to_string(),
val: prop.1.to_string(),
val: Some(prop.1.to_string().into_bytes()),
}
}
}

impl<K, V> From<(K, V)> for TxtProperty
where
K: ToString,
V: AsRef<[u8]>,
{
fn from(prop: (K, V)) -> Self {
TxtProperty {
key: prop.0.to_string(),
val: Some(prop.1.as_ref().into()),
}
}
}

/// Support a property that has no value.
impl From<&str> for TxtProperty {
fn from(key: &str) -> Self {
TxtProperty {
key: key.to_string(),
val: None,
}
}
}
Expand All @@ -427,7 +484,10 @@ impl IntoTxtProperties for HashMap<String, String> {
fn into_txt_properties(mut self) -> TxtProperties {
let properties = self
.drain()
.map(|(key, val)| TxtProperty { key, val })
.map(|(key, val)| TxtProperty {
key,
val: Some(val.into_bytes()),
})
.collect();
TxtProperties { properties }
}
Expand Down Expand Up @@ -476,9 +536,16 @@ where
fn encode_txt<'a>(properties: impl Iterator<Item = &'a TxtProperty>) -> Vec<u8> {
let mut bytes = Vec::new();
for prop in properties {
let s = format!("{}={}", prop.key, prop.val);
let mut s = prop.key.clone().into_bytes();
if let Some(v) = &prop.val {
s.extend(b"=");
s.extend(v);
}

// TXT uses (Length,Value) format for each property,
// i.e. the first byte is the length.
bytes.push(s.len().try_into().unwrap());
bytes.extend_from_slice(s.as_bytes());
bytes.extend(s);
}
if bytes.is_empty() {
bytes.push(0);
Expand All @@ -496,20 +563,26 @@ fn decode_txt(txt: &[u8]) -> Vec<TxtProperty> {
break; // reached the end
}
offset += 1; // move over the length byte
match String::from_utf8(txt[offset..offset + length].to_vec()) {
Ok(kv_string) => match kv_string.find('=') {
Some(idx) => {
let k = &kv_string[..idx];
let v = &kv_string[idx + 1..];
properties.push(TxtProperty {
key: k.to_string(),
val: v.to_string(),
});
}
None => error!("cannot find = sign inside {}", &kv_string),
},
Err(e) => error!("failed to convert to String from key/value pair: {}", e),

let kv_bytes = &txt[offset..offset + length];

// split key and val using the first `=`
let (k, v) = match kv_bytes.iter().position(|&x| x == b'=') {
Some(idx) => (kv_bytes[..idx].to_vec(), Some(kv_bytes[idx + 1..].to_vec())),
None => (kv_bytes.to_vec(), None),
};

// Make sure the key can be stored in UTF-8.
match String::from_utf8(k) {
Ok(k_string) => {
properties.push(TxtProperty {
key: k_string,
val: v,
});
}
Err(e) => error!("failed to convert to String from key: {}", e),
}

offset += length;
}

Expand Down Expand Up @@ -541,26 +614,61 @@ mod tests {
#[test]
fn test_txt_encode_decode() {
let properties = vec![
TxtProperty {
key: "key1".to_string(),
val: "value1".to_string(),
},
TxtProperty {
key: "key2".to_string(),
val: "value2".to_string(),
},
TxtProperty::from(&("key1", "value1")),
TxtProperty::from(&("key2", "value2")),
];

// test encode
let property_count = properties.len();
let encoded = encode_txt(properties.iter());
assert_eq!(
encoded.len(),
"key1=".len() + "value1".len() + "key2=".len() + "value2".len() + 2
"key1=value1".len() + "key2=value2".len() + property_count
);
assert_eq!(encoded[0] as usize, "key1=".len() + "value1".len());
assert_eq!(encoded[0] as usize, "key1=value1".len());

// test decode
let decoded = decode_txt(&encoded);
assert!(&properties[..] == &decoded[..]);

// test empty value
let properties = vec![TxtProperty::from(&("key3", ""))];
let property_count = properties.len();
let encoded = encode_txt(properties.iter());
assert_eq!(encoded.len(), "key3=".len() + property_count);

let decoded = decode_txt(&encoded);
assert_eq!(properties, decoded);

// test non-string value
let binary_val: Vec<u8> = vec![123, 234, 0];
let binary_len = binary_val.len();
let properties = vec![TxtProperty::from(("key4", binary_val))];
let property_count = properties.len();
let encoded = encode_txt(properties.iter());
assert_eq!(encoded.len(), "key4=".len() + binary_len + property_count);

let decoded = decode_txt(&encoded);
assert_eq!(properties, decoded);

// test value that contains '='
let properties = vec![TxtProperty::from(("key5", "val=5"))];
let property_count = properties.len();
let encoded = encode_txt(properties.iter());
assert_eq!(
encoded.len(),
"key5=".len() + "val=5".len() + property_count
);

let decoded = decode_txt(&encoded);
assert_eq!(properties, decoded);

// test a property that has no value.
let properties = vec![TxtProperty::from("key6")];
let property_count = properties.len();
let encoded = encode_txt(properties.iter());
assert_eq!(encoded.len(), "key6".len() + property_count);
let decoded = decode_txt(&encoded);
assert_eq!(properties, decoded);
}
}
19 changes: 14 additions & 5 deletions tests/mdns_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,12 @@ fn integration_success() {
assert_eq!(properties.len(), 3);
assert!(info.get_property("property_1").is_some());
assert!(info.get_property("property_2").is_some());
assert_eq!(info.get_property_val("property_1"), Some("test"));
assert_eq!(info.get_property_val("property_2"), Some("1"));
assert_eq!(info.get_property_val_str("property_1"), Some("test"));
assert_eq!(info.get_property_val_str("property_2"), Some("1"));
assert_eq!(
info.get_property_val("property_1").unwrap(),
Some("test".as_bytes())
);

let host_ttl = info.get_host_ttl();
assert_eq!(host_ttl, 120); // default value.
Expand Down Expand Up @@ -294,7 +298,8 @@ fn service_txt_properties_case_insensitive() {
// Verify `get_property()` method is case insensitive and returns
// the first property with the same key.
let prop_cap_case = my_service.get_property("prop_CAP_CASE").unwrap();
assert_eq!(prop_cap_case.val(), "one");
assert_eq!(prop_cap_case.val_str(), "one");
assert_eq!(prop_cap_case.val(), Some("one".as_bytes()));

// Verify the original property name is kept.
let prop_mixed = my_service.get_property("prop_cap_lower").unwrap();
Expand Down Expand Up @@ -336,12 +341,16 @@ fn test_into_txt_properties() {
// Verify (&str, String) tuple is supported.
let properties = vec![("key1", String::from("val1"))];
let txt_props = properties.into_txt_properties();
assert_eq!(txt_props.get_property_val("key1").unwrap(), "val1");
assert_eq!(txt_props.get_property_val_str("key1").unwrap(), "val1");
assert_eq!(
txt_props.get_property_val("key1").unwrap(),
Some("val1".as_bytes())
);

// Verify (String, String) tuple is supported.
let properties = vec![(String::from("key2"), String::from("val2"))];
let txt_props = properties.into_txt_properties();
assert_eq!(txt_props.get_property_val("key2").unwrap(), "val2");
assert_eq!(txt_props.get_property_val_str("key2").unwrap(), "val2");
}

#[test]
Expand Down

0 comments on commit 439b0f3

Please sign in to comment.