Skip to main content

bindizr_core/dns/
txt.rs

1use base64::Engine;
2
3const RAW_TXT_RDATA_PREFIX: &str = "bindizr:txt-rdata:v1:";
4
5#[derive(Debug, PartialEq, Eq)]
6pub enum DecodedTxtValue {
7    String(String),
8    Segments(Vec<String>),
9}
10
11pub fn encode_raw_txt_rdata(rdata: &[u8]) -> String {
12    format!(
13        "{}{}",
14        RAW_TXT_RDATA_PREFIX,
15        base64::engine::general_purpose::STANDARD.encode(rdata)
16    )
17}
18
19pub fn encode_txt_segments<'a, I>(segments: I) -> Result<String, String>
20where
21    I: IntoIterator<Item = &'a str>,
22{
23    let mut rdata = Vec::new();
24    let mut has_segments = false;
25    for segment in segments {
26        has_segments = true;
27        let bytes = segment.as_bytes();
28        if bytes.len() > 255 {
29            return Err("TXT character-string must be 255 bytes or less".to_string());
30        }
31        rdata.push(bytes.len() as u8);
32        rdata.extend_from_slice(bytes);
33    }
34    if !has_segments {
35        return Err("TXT record must contain at least one character-string".to_string());
36    }
37    Ok(encode_raw_txt_rdata(&rdata))
38}
39
40pub fn encode_txt_string(value: &str) -> String {
41    let mut rdata = Vec::new();
42    let mut chunk_start = 0usize;
43    let mut chunk_len = 0usize;
44
45    for (idx, ch) in value.char_indices() {
46        let char_len = ch.len_utf8();
47        if chunk_len + char_len > 255 {
48            rdata.push(chunk_len as u8);
49            rdata.extend_from_slice(&value.as_bytes()[chunk_start..idx]);
50            chunk_start = idx;
51            chunk_len = 0;
52        }
53        chunk_len += char_len;
54    }
55
56    rdata.push(chunk_len as u8);
57    rdata.extend_from_slice(&value.as_bytes()[chunk_start..]);
58    encode_raw_txt_rdata(&rdata)
59}
60
61pub fn decode_raw_txt_rdata(value: &str) -> Option<Vec<u8>> {
62    let encoded = value.strip_prefix(RAW_TXT_RDATA_PREFIX)?;
63    base64::engine::general_purpose::STANDARD
64        .decode(encoded)
65        .ok()
66        .filter(|rdata| is_valid_txt_rdata(rdata))
67}
68
69pub fn decode_raw_txt_value(value: &str) -> Option<DecodedTxtValue> {
70    let rdata = decode_raw_txt_rdata(value)?;
71    if rdata.is_empty() {
72        return None;
73    }
74
75    let mut pos = 0usize;
76    let mut segments = Vec::new();
77
78    while pos < rdata.len() {
79        let chunk_len = rdata[pos] as usize;
80        pos += 1;
81        let chunk = std::str::from_utf8(&rdata[pos..pos + chunk_len]).ok()?;
82        segments.push(chunk.to_string());
83        pos += chunk_len;
84    }
85
86    match segments.as_slice() {
87        [single] => Some(DecodedTxtValue::String(single.clone())),
88        _ => Some(DecodedTxtValue::Segments(segments)),
89    }
90}
91
92fn is_valid_txt_rdata(rdata: &[u8]) -> bool {
93    let mut pos = 0usize;
94    while pos < rdata.len() {
95        let chunk_len = rdata[pos] as usize;
96        pos += 1;
97        if pos + chunk_len > rdata.len() {
98            return false;
99        }
100        pos += chunk_len;
101    }
102    true
103}
104
105#[cfg(test)]
106mod tests {
107    use super::{decode_raw_txt_rdata, encode_raw_txt_rdata};
108
109    #[test]
110    fn raw_txt_rdata_round_trips() {
111        let rdata = [2, b'a', b'b', 1, b'c'];
112        let encoded = encode_raw_txt_rdata(&rdata);
113
114        assert_eq!(decode_raw_txt_rdata(&encoded), Some(rdata.to_vec()));
115    }
116
117    #[test]
118    fn txt_segments_encode_to_reversible_json_value() {
119        let encoded = super::encode_txt_segments(["a", "bc"]).unwrap();
120
121        assert_eq!(
122            super::decode_raw_txt_value(&encoded),
123            Some(super::DecodedTxtValue::Segments(vec![
124                "a".to_string(),
125                "bc".to_string()
126            ]))
127        );
128    }
129
130    #[test]
131    fn txt_segments_reject_empty_lists() {
132        assert_eq!(
133            super::encode_txt_segments(std::iter::empty()).unwrap_err(),
134            "TXT record must contain at least one character-string"
135        );
136    }
137
138    #[test]
139    fn txt_value_rejects_zero_segment_rdata() {
140        let encoded = encode_raw_txt_rdata(&[]);
141
142        assert_eq!(super::decode_raw_txt_value(&encoded), None);
143    }
144
145    #[test]
146    fn txt_segments_allow_single_empty_segment() {
147        let encoded = super::encode_txt_segments([""]).unwrap();
148
149        assert_eq!(decode_raw_txt_rdata(&encoded), Some(vec![0]));
150        assert_eq!(
151            super::decode_raw_txt_value(&encoded),
152            Some(super::DecodedTxtValue::String(String::new()))
153        );
154    }
155
156    #[test]
157    fn txt_string_auto_splits_long_values() {
158        let value = "a".repeat(300);
159        let encoded = super::encode_txt_string(&value);
160
161        assert_eq!(
162            decode_raw_txt_rdata(&encoded),
163            Some({
164                let mut rdata = Vec::new();
165                rdata.push(255);
166                rdata.extend(std::iter::repeat_n(b'a', 255));
167                rdata.push(45);
168                rdata.extend(std::iter::repeat_n(b'a', 45));
169                rdata
170            })
171        );
172        assert_eq!(
173            super::decode_raw_txt_value(&encoded),
174            Some(super::DecodedTxtValue::Segments(vec![
175                "a".repeat(255),
176                "a".repeat(45)
177            ]))
178        );
179    }
180
181    #[test]
182    fn txt_string_splits_on_utf8_boundaries() {
183        let value = format!("{}{}", "a".repeat(254), "é");
184        let encoded = super::encode_txt_string(&value);
185
186        assert_eq!(
187            super::decode_raw_txt_value(&encoded),
188            Some(super::DecodedTxtValue::Segments(vec![
189                "a".repeat(254),
190                "é".to_string()
191            ]))
192        );
193    }
194
195    #[test]
196    fn invalid_raw_txt_rdata_prefix_is_ignored() {
197        assert_eq!(decode_raw_txt_rdata("bindizr:txt-rdata:v1:A2Fi"), None);
198    }
199}