Skip to content
File

Blob: src/rust/api/dns.rs

rust443 lines
1// Copyright (c) 2026 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5use jsg_macros::jsg_method;
6use jsg_macros::jsg_resource;
7use jsg_macros::jsg_struct;
8use thiserror::Error;
9 
10#[derive(Debug, Error)]
11pub enum DnsParserError {
12 #[error("Invalid hex string: {0}")]
13 InvalidHexString(String),
14 #[error("ParseInt error: {0}")]
15 ParseIntError(#[from] std::num::ParseIntError),
16 #[error("Invalid DNS response: {0}")]
17 InvalidDnsResponse(String),
18 #[error("unknown dns parser error")]
19 Unknown,
20}
21 
22impl From<DnsParserError> for jsg::Error {
23 fn from(val: DnsParserError) -> Self {
24 match val {
25 DnsParserError::InvalidHexString(msg) | DnsParserError::InvalidDnsResponse(msg) => {
26 Self::new_error(&msg)
27 }
28 DnsParserError::ParseIntError(msg) => Self::new_range_error(msg.to_string()),
29 DnsParserError::Unknown => Self::new_error("Unknown dns parser error"),
30 }
31 }
32}
33 
34/// CAA record representation
35#[jsg_struct]
36#[derive(Debug)]
37pub struct CaaRecord {
38 pub critical: u8,
39 pub field: String,
40 pub value: String,
41}
42 
43/// NAPTR record representation
44#[jsg_struct]
45#[derive(Debug)]
46pub struct NaptrRecord {
47 pub flags: String,
48 pub service: String,
49 pub regexp: String,
50 pub replacement: String,
51 pub order: u32,
52 pub preference: u32,
53}
54 
55/// Given a vector of strings, converts each slice to UTF-8 from HEX.
56///
57/// # Errors
58/// `DnsParserError::InvalidHexString`
59/// `DnsParserError::ParseIntError`
60pub fn decode_hex(input: &[&str]) -> Result<Vec<String>, DnsParserError> {
61 let mut v = Vec::with_capacity(input.len());
62 
63 for slice in input {
64 let num = u16::from_str_radix(slice, 16)?;
65 let ch = String::from_utf16(&[num])
66 .map_err(|_| DnsParserError::InvalidHexString("Invalid UTF-16 sequence".to_owned()))?;
67 v.push(ch);
68 }
69 
70 Ok(v)
71}
72 
73/// Replacement values needs to be parsed accordingly.
74///
75/// It has a similar characteristic to CAA and NAPTR records whereas
76/// first character contains the length of the input, and the second character
77/// is the starting index of the substring. We need to continue parsing until there
78/// are no input left, and later join them using "."
79///
80/// It is important that the returning value doesn't end with dot (".") character.
81///
82/// # Errors
83/// `DnsParserError::InvalidHexString`
84/// `DnsParserError::ParseIntError`
85pub fn parse_replacement(input: &[&str]) -> jsg::Result<String, DnsParserError> {
86 if input.is_empty() {
87 return Ok(String::new());
88 }
89 
90 let mut output: Vec<String> = vec![];
91 let mut length_index = 0;
92 let mut offset_index = 1;
93 
94 // Iterate through each character to parse different frames.
95 // Each frame starts with the length of the remaining frame.
96 while length_index < input.len() {
97 let length = usize::from_str_radix(input[length_index], 16)?;
98 if length + offset_index > input.len() {
99 return Err(DnsParserError::InvalidDnsResponse(
100 "replacement data too short for declared frame length".to_owned(),
101 ));
102 }
103 let subset = input[offset_index..length + offset_index].to_vec();
104 let decoded = decode_hex(&subset)?.join("");
105 
106 // We omit the trailing "." from replacements.
107 // Cloudflare DNS returns "_sip._udp.sip2sip.info." whereas Node.js removes trailing dot
108 if !decoded.is_empty() {
109 output.push(decoded);
110 }
111 
112 length_index += subset.len() + 1;
113 offset_index = length_index + 1;
114 }
115 
116 Ok(output.join("."))
117}
118 
119#[jsg_resource]
120pub struct DnsUtil;
121 
122#[jsg_resource]
123impl DnsUtil {
124 pub fn new() -> jsg::Rc<Self> {
125 jsg::Rc::new(Self {})
126 }
127 
128 /// Parses an unknown RR format returned from Cloudflare DNS.
129 /// Specification is available at
130 /// `<https://datatracker.ietf.org/doc/html/rfc3597>`
131 ///
132 /// The format of the record is as follows:
133 /// \# <length-in-bytes> <bytes-in-hex>
134 /// \\# 15 00 05 69 73 73 75 65 70 6b 69 2e 67 6f 6f 67
135 /// | | | |
136 /// | | | - Starting point of the actual data
137 /// | | - Length of the field.
138 /// | - Number representation of "`is_critical`"
139 /// - Length of the data
140 ///
141 /// Note: Field can be "issuewild", "issue" or "iodef".
142 ///
143 /// ```
144 /// let record = parse_caa_record("\\# 15 00 05 69 73 73 75 65 70 6b 69 2e 67 6f 6f 67");
145 /// assert_eq!(record.critical, false);
146 /// assert_eq!(record.field, "issue")
147 /// assert_eq!(record.value, "pki.goog")
148 /// ```
149 /// # Errors
150 /// `DnsParserError::InvalidHexString`
151 /// `DnsParserError::ParseIntError`
152 #[jsg_method]
153 pub fn parse_caa_record(&self, record: String) -> Result<CaaRecord, DnsParserError> {
154 // Let's remove "\\#" and the length of data from the beginning of the record
155 let parts: Vec<_> = record.split_ascii_whitespace().collect();
156 if parts.len() < 3 {
157 return Err(DnsParserError::InvalidDnsResponse(
158 "CAA record too short: expected at least 3 fields".to_owned(),
159 ));
160 }
161 let data = parts[2..].to_vec();
162 if data.len() < 2 {
163 return Err(DnsParserError::InvalidDnsResponse(
164 "CAA record data too short: expected critical and prefix length fields".to_owned(),
165 ));
166 }
167 let critical = data[0].parse::<u8>()?;
168 let prefix_length = data[1].parse::<usize>()?;
169 
170 if data.len() < 2 + prefix_length {
171 return Err(DnsParserError::InvalidDnsResponse(format!(
172 "CAA record data too short for prefix_length {prefix_length}"
173 )));
174 }
175 let field = decode_hex(&data[2..prefix_length + 2])?.join("");
176 let value = decode_hex(&data[(prefix_length + 2)..])?.join("");
177 
178 // Field can be "issuewild", "issue" or "iodef"
179 if field != "issuewild" && field != "issue" && field != "iodef" {
180 return Err(DnsParserError::InvalidDnsResponse(format!(
181 "Received unknown field '{field}'"
182 )));
183 }
184 
185 Ok(CaaRecord {
186 critical,
187 field,
188 value,
189 })
190 }
191 
192 /// Parses an unknown RR format returned from Cloudflare DNS.
193 /// Specification is available at
194 /// `<https://datatracker.ietf.org/doc/html/rfc3597>`
195 ///
196 /// The format of the record is as follows:
197 /// \# 37 15 b3 08 ae 01 73 0a 6d 79 2d 73 65 72 76 69 63 65 06 72 65 67 65 78 70 0b 72 65 70 6c 61 63 65 6d 65 6e 74 00
198 /// |--| |--| | | | |--------------------------| | |--------------| | |--------------------------------|
199 /// | | | | | | | | | - Replacement
200 /// | | | | | | | | - Length of first part of the replacement
201 /// | | | | | | | - Regexp
202 /// | | | | | | - Regexp length
203 /// | | | | | - Service
204 /// | | | | - Length of service
205 /// | | | - Flag
206 /// | | - Length of flags
207 /// | - Preference
208 /// - Order
209 ///
210 /// ```
211 /// let record = parse_naptr_record("\\# 37 15 b3 08 ae 01 73 0a 6d 79 2d 73 65 72 76 69 63 65 06 72 65 67 65 78 70 0b 72 65 70 6c 61 63 65 6d 65 6e 74 00");
212 /// assert_eq!(record.flags, "s");
213 /// assert_eq!(record.service, "my-service");
214 /// assert_eq!(record.regexp, "regexp");
215 /// assert_eq!(record.replacement, "replacement");
216 /// assert_eq!(record.order, 5555);
217 /// assert_eq!(record.preference, 2222);
218 /// ```
219 ///
220 /// # Errors
221 /// `DnsParserError::InvalidHexString`
222 /// `DnsParserError::ParseIntError`
223 #[jsg_method]
224 pub fn parse_naptr_record(&self, record: String) -> jsg::Result<NaptrRecord, DnsParserError> {
225 let parts: Vec<_> = record.split_ascii_whitespace().collect();
226 if parts.len() < 2 {
227 return Err(DnsParserError::InvalidDnsResponse(
228 "NAPTR record too short".to_owned(),
229 ));
230 }
231 let data = parts[1..].to_vec();
232 
233 // Need at least: length(1) + order(2) + preference(2) + flag_length(1) = 6 fields
234 if data.len() < 6 {
235 return Err(DnsParserError::InvalidDnsResponse(
236 "NAPTR record data too short: expected at least 6 fields".to_owned(),
237 ));
238 }
239 
240 let order_str = data[1..3].to_vec();
241 let order = u32::from_str_radix(&order_str.join(""), 16)?;
242 let preference_str = data[3..5].to_vec();
243 let preference = u32::from_str_radix(&preference_str.join(""), 16)?;
244 
245 let flag_length = usize::from_str_radix(data[5], 16)?;
246 let flag_offset = 6;
247 if data.len() < flag_offset + flag_length + 1 {
248 return Err(DnsParserError::InvalidDnsResponse(
249 "NAPTR record too short for flags field".to_owned(),
250 ));
251 }
252 let flags = decode_hex(&data[flag_offset..flag_length + flag_offset])?.join("");
253 
254 let service_length = usize::from_str_radix(data[flag_offset + flag_length], 16)?;
255 let service_offset = flag_offset + flag_length + 1;
256 if data.len() < service_offset + service_length + 1 {
257 return Err(DnsParserError::InvalidDnsResponse(
258 "NAPTR record too short for service field".to_owned(),
259 ));
260 }
261 let service = decode_hex(&data[service_offset..service_length + service_offset])?.join("");
262 
263 let regexp_length = usize::from_str_radix(data[service_offset + service_length], 16)?;
264 let regexp_offset = service_offset + service_length + 1;
265 if data.len() < regexp_offset + regexp_length {
266 return Err(DnsParserError::InvalidDnsResponse(
267 "NAPTR record too short for regexp field".to_owned(),
268 ));
269 }
270 let regexp = decode_hex(&data[regexp_offset..regexp_length + regexp_offset])?.join("");
271 
272 let replacement = parse_replacement(&data[regexp_offset + regexp_length..])?;
273 
274 Ok(NaptrRecord {
275 flags,
276 service,
277 regexp,
278 replacement,
279 order,
280 preference,
281 })
282 }
283}
284 
285#[cfg(test)]
286mod tests {
287 use super::*;
288 
289 #[test]
290 fn test_decode() {
291 let input = vec!["69", "73", "73", "75", "65"];
292 assert_eq!(decode_hex(&input).unwrap().join(""), "issue");
293 
294 let empty_input: Vec<&str> = vec![];
295 assert!(decode_hex(&empty_input).unwrap().is_empty());
296 }
297 
298 #[test]
299 fn test_decode_hex_invalid() {
300 let input = vec!["ZZ"];
301 let result = decode_hex(&input);
302 assert!(result.is_err());
303 }
304 
305 #[test]
306 fn test_parse_replacement_empty() {
307 let input: Vec<&str> = vec![];
308 assert_eq!(parse_replacement(&input).unwrap(), "");
309 
310 let multiple_parts_input = vec!["03", "73", "69", "70", "04", "74", "65", "73", "74", "00"];
311 assert_eq!(
312 parse_replacement(&multiple_parts_input).unwrap(),
313 "sip.test"
314 );
315 }
316 
317 #[test]
318 fn test_parse_caa_record_issue() {
319 let dns_util = DnsUtil {};
320 let record = dns_util
321 .parse_caa_record("\\# 15 00 05 69 73 73 75 65 70 6b 69 2e 67 6f 6f 67".to_owned())
322 .unwrap();
323 
324 assert_eq!(record.critical, 0);
325 assert_eq!(record.field, "issue");
326 assert_eq!(record.value, "pki.goog");
327 }
328 
329 #[test]
330 fn test_parse_caa_record_issuewild() {
331 let dns_util = DnsUtil {};
332 let record = dns_util
333 .parse_caa_record(
334 "\\# 21 00 09 69 73 73 75 65 77 69 6c 64 6c 65 74 73 65 6e 63 72 79 70 74"
335 .to_owned(),
336 )
337 .unwrap();
338 
339 assert_eq!(record.critical, 0);
340 assert_eq!(record.field, "issuewild");
341 assert_eq!(record.value, "letsencrypt");
342 }
343 
344 #[test]
345 fn test_parse_caa_record_invalid_field() {
346 let dns_util = DnsUtil {};
347 let result = dns_util.parse_caa_record(
348 "\\# 15 00 05 69 6e 76 61 6c 69 64 70 6b 69 2e 67 6f 6f 67".to_owned(),
349 );
350 
351 assert!(result.is_err());
352 }
353 
354 #[test]
355 fn test_parse_naptr_record() {
356 let dns_util = DnsUtil {};
357 let record = dns_util
358 .parse_naptr_record("\\# 37 15 b3 08 ae 01 73 0a 6d 79 2d 73 65 72 76 69 63 65 06 72 65 67 65 78 70 0b 72 65 70 6c 61 63 65 6d 65 6e 74 00".to_owned())
359 .unwrap();
360 
361 assert_eq!(record.flags, "s");
362 assert_eq!(record.service, "my-service");
363 assert_eq!(record.regexp, "regexp");
364 assert_eq!(record.replacement, "replacement");
365 assert_eq!(record.order, 5555);
366 assert_eq!(record.preference, 2222);
367 }
368 
369 // =========================================================================
370 // Malformed input tests — these previously caused panics (index out of bounds)
371 // which would abort the process via CXX. They must return Err, not panic.
372 // =========================================================================
373 
374 #[test]
375 fn test_parse_caa_record_empty_string() {
376 let dns_util = DnsUtil {};
377 assert!(dns_util.parse_caa_record(String::new()).is_err());
378 }
379 
380 #[test]
381 fn test_parse_caa_record_single_token() {
382 let dns_util = DnsUtil {};
383 assert!(dns_util.parse_caa_record("\\#".to_owned()).is_err());
384 }
385 
386 #[test]
387 fn test_parse_caa_record_two_tokens() {
388 let dns_util = DnsUtil {};
389 assert!(dns_util.parse_caa_record("\\# 15".to_owned()).is_err());
390 }
391 
392 #[test]
393 fn test_parse_caa_record_data_too_short_for_prefix() {
394 let dns_util = DnsUtil {};
395 // critical=00, prefix_length=FF (255) but no data follows
396 assert!(
397 dns_util
398 .parse_caa_record("\\# 02 00 FF".to_owned())
399 .is_err()
400 );
401 }
402 
403 #[test]
404 fn test_parse_naptr_record_empty_string() {
405 let dns_util = DnsUtil {};
406 assert!(dns_util.parse_naptr_record(String::new()).is_err());
407 }
408 
409 #[test]
410 fn test_parse_naptr_record_single_token() {
411 let dns_util = DnsUtil {};
412 assert!(dns_util.parse_naptr_record("\\#".to_owned()).is_err());
413 }
414 
415 #[test]
416 fn test_parse_naptr_record_too_few_fields() {
417 let dns_util = DnsUtil {};
418 assert!(
419 dns_util
420 .parse_naptr_record("\\# 37 15 b3".to_owned())
421 .is_err()
422 );
423 }
424 
425 #[test]
426 fn test_parse_replacement_length_exceeds_input() {
427 // First element says frame is FF (255) bytes but only 2 bytes follow
428 let input = vec!["FF", "73", "69"];
429 assert!(parse_replacement(&input).is_err());
430 }
431 
432 #[test]
433 fn test_parse_naptr_record_truncated_at_flags() {
434 let dns_util = DnsUtil {};
435 // Has order+preference+flag_length but no flag data
436 assert!(
437 dns_util
438 .parse_naptr_record("\\# 06 15 b3 08 ae 05".to_owned())
439 .is_err()
440 );
441 }
442}