bindizr-core 0.1.0-beta.4

Core models, configuration, DNS record types, and logging utilities for bindizr
Documentation
use base64::Engine;

const RAW_TXT_RDATA_PREFIX: &str = "bindizr:txt-rdata:v1:";

#[derive(Debug, PartialEq, Eq)]
pub enum DecodedTxtValue {
    String(String),
    Segments(Vec<String>),
}

pub fn encode_raw_txt_rdata(rdata: &[u8]) -> String {
    format!(
        "{}{}",
        RAW_TXT_RDATA_PREFIX,
        base64::engine::general_purpose::STANDARD.encode(rdata)
    )
}

pub fn encode_txt_segments<'a, I>(segments: I) -> Result<String, String>
where
    I: IntoIterator<Item = &'a str>,
{
    let mut rdata = Vec::new();
    let mut has_segments = false;
    for segment in segments {
        has_segments = true;
        let bytes = segment.as_bytes();
        if bytes.len() > 255 {
            return Err("TXT character-string must be 255 bytes or less".to_string());
        }
        rdata.push(bytes.len() as u8);
        rdata.extend_from_slice(bytes);
    }
    if !has_segments {
        return Err("TXT record must contain at least one character-string".to_string());
    }
    Ok(encode_raw_txt_rdata(&rdata))
}

pub fn encode_txt_string(value: &str) -> String {
    let mut rdata = Vec::new();
    let mut chunk_start = 0usize;
    let mut chunk_len = 0usize;

    for (idx, ch) in value.char_indices() {
        let char_len = ch.len_utf8();
        if chunk_len + char_len > 255 {
            rdata.push(chunk_len as u8);
            rdata.extend_from_slice(&value.as_bytes()[chunk_start..idx]);
            chunk_start = idx;
            chunk_len = 0;
        }
        chunk_len += char_len;
    }

    rdata.push(chunk_len as u8);
    rdata.extend_from_slice(&value.as_bytes()[chunk_start..]);
    encode_raw_txt_rdata(&rdata)
}

pub fn decode_raw_txt_rdata(value: &str) -> Option<Vec<u8>> {
    let encoded = value.strip_prefix(RAW_TXT_RDATA_PREFIX)?;
    base64::engine::general_purpose::STANDARD
        .decode(encoded)
        .ok()
        .filter(|rdata| is_valid_txt_rdata(rdata))
}

pub fn decode_raw_txt_value(value: &str) -> Option<DecodedTxtValue> {
    let rdata = decode_raw_txt_rdata(value)?;
    if rdata.is_empty() {
        return None;
    }

    let mut pos = 0usize;
    let mut segments = Vec::new();

    while pos < rdata.len() {
        let chunk_len = rdata[pos] as usize;
        pos += 1;
        let chunk = std::str::from_utf8(&rdata[pos..pos + chunk_len]).ok()?;
        segments.push(chunk.to_string());
        pos += chunk_len;
    }

    match segments.as_slice() {
        [single] => Some(DecodedTxtValue::String(single.clone())),
        _ => Some(DecodedTxtValue::Segments(segments)),
    }
}

fn is_valid_txt_rdata(rdata: &[u8]) -> bool {
    let mut pos = 0usize;
    while pos < rdata.len() {
        let chunk_len = rdata[pos] as usize;
        pos += 1;
        if pos + chunk_len > rdata.len() {
            return false;
        }
        pos += chunk_len;
    }
    true
}

#[cfg(test)]
mod tests {
    use super::{decode_raw_txt_rdata, encode_raw_txt_rdata};

    #[test]
    fn raw_txt_rdata_round_trips() {
        let rdata = [2, b'a', b'b', 1, b'c'];
        let encoded = encode_raw_txt_rdata(&rdata);

        assert_eq!(decode_raw_txt_rdata(&encoded), Some(rdata.to_vec()));
    }

    #[test]
    fn txt_segments_encode_to_reversible_json_value() {
        let encoded = super::encode_txt_segments(["a", "bc"]).unwrap();

        assert_eq!(
            super::decode_raw_txt_value(&encoded),
            Some(super::DecodedTxtValue::Segments(vec![
                "a".to_string(),
                "bc".to_string()
            ]))
        );
    }

    #[test]
    fn txt_segments_reject_empty_lists() {
        assert_eq!(
            super::encode_txt_segments(std::iter::empty()).unwrap_err(),
            "TXT record must contain at least one character-string"
        );
    }

    #[test]
    fn txt_value_rejects_zero_segment_rdata() {
        let encoded = encode_raw_txt_rdata(&[]);

        assert_eq!(super::decode_raw_txt_value(&encoded), None);
    }

    #[test]
    fn txt_segments_allow_single_empty_segment() {
        let encoded = super::encode_txt_segments([""]).unwrap();

        assert_eq!(decode_raw_txt_rdata(&encoded), Some(vec![0]));
        assert_eq!(
            super::decode_raw_txt_value(&encoded),
            Some(super::DecodedTxtValue::String(String::new()))
        );
    }

    #[test]
    fn txt_string_auto_splits_long_values() {
        let value = "a".repeat(300);
        let encoded = super::encode_txt_string(&value);

        assert_eq!(
            decode_raw_txt_rdata(&encoded),
            Some({
                let mut rdata = Vec::new();
                rdata.push(255);
                rdata.extend(std::iter::repeat_n(b'a', 255));
                rdata.push(45);
                rdata.extend(std::iter::repeat_n(b'a', 45));
                rdata
            })
        );
        assert_eq!(
            super::decode_raw_txt_value(&encoded),
            Some(super::DecodedTxtValue::Segments(vec![
                "a".repeat(255),
                "a".repeat(45)
            ]))
        );
    }

    #[test]
    fn txt_string_splits_on_utf8_boundaries() {
        let value = format!("{}{}", "a".repeat(254), "é");
        let encoded = super::encode_txt_string(&value);

        assert_eq!(
            super::decode_raw_txt_value(&encoded),
            Some(super::DecodedTxtValue::Segments(vec![
                "a".repeat(254),
                "é".to_string()
            ]))
        );
    }

    #[test]
    fn invalid_raw_txt_rdata_prefix_is_ignored() {
        assert_eq!(decode_raw_txt_rdata("bindizr:txt-rdata:v1:A2Fi"), None);
    }
}