1use anyhow::{bail, ensure, Context, Result};
3use serde_json::{json, Map, Value};
4use std::net::IpAddr;
5
6pub const MAX_BODY_BYTES: usize = 1024 * 1024;
7pub const MAX_JSON_NODES: usize = 50_000;
8pub const MAX_JSON_DEPTH: usize = 32;
9pub const MAX_VALUE_BYTES: usize = 255;
10pub const MAX_SEARCH_RESULTS: usize = 1000;
11pub const CONFORMANCE: &str = "rdap_level_0";
12pub const MEDIA_TYPE: &str = "application/rdap+json";
13pub const DEFAULT_BASE_PATH: &str = "/";
15
16#[derive(Clone, Debug, PartialEq, Eq)]
18pub enum Query {
19 Lookup {
20 kind: Lookup,
21 value: String,
22 },
23 Search {
24 kind: Search,
25 parameter: String,
26 value: String,
27 },
28 Help,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum Lookup {
33 Domain,
34 Nameserver,
35 Ip,
36 Autnum,
37 Entity,
38}
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum Search {
41 Domains,
42 Nameservers,
43 Entities,
44}
45
46impl Lookup {
47 pub fn path(self) -> &'static str {
48 match self {
49 Self::Domain => "domain",
50 Self::Nameserver => "nameserver",
51 Self::Ip => "ip",
52 Self::Autnum => "autnum",
53 Self::Entity => "entity",
54 }
55 }
56 pub fn class(self) -> &'static str {
58 match self {
59 Self::Domain => "domain",
60 Self::Nameserver => "nameserver",
61 Self::Ip => "ip network",
62 Self::Autnum => "autnum",
63 Self::Entity => "entity",
64 }
65 }
66 pub fn from_name(name: &str) -> Option<Self> {
67 Some(match name {
68 "domain" => Self::Domain,
69 "nameserver" => Self::Nameserver,
70 "ip" => Self::Ip,
71 "autnum" => Self::Autnum,
72 "entity" => Self::Entity,
73 _ => return None,
74 })
75 }
76}
77
78impl Search {
79 pub fn path(self) -> &'static str {
80 match self {
81 Self::Domains => "domains",
82 Self::Nameservers => "nameservers",
83 Self::Entities => "entities",
84 }
85 }
86 pub fn class(self) -> &'static str {
87 match self {
88 Self::Domains => "domain",
89 Self::Nameservers => "nameserver",
90 Self::Entities => "entity",
91 }
92 }
93 pub fn member(self) -> &'static str {
95 match self {
96 Self::Domains => "domainSearchResults",
97 Self::Nameservers => "nameserverSearchResults",
98 Self::Entities => "entitySearchResults",
99 }
100 }
101 pub fn parameters(self) -> &'static [&'static str] {
102 match self {
103 Self::Domains => &["name", "nsLdhName", "nsIp"],
104 Self::Nameservers => &["name", "ip"],
105 Self::Entities => &["fn", "handle"],
106 }
107 }
108 pub fn from_name(name: &str) -> Option<Self> {
109 Some(match name {
110 "domains" => Self::Domains,
111 "nameservers" => Self::Nameservers,
112 "entities" => Self::Entities,
113 _ => return None,
114 })
115 }
116}
117
118fn percent_decode(text: &str) -> Result<String> {
119 let bytes = text.as_bytes();
120 let mut out = Vec::with_capacity(bytes.len());
121 let mut i = 0;
122 while i < bytes.len() {
123 if bytes[i] == b'%' {
124 let hex = text
125 .get(i + 1..i + 3)
126 .context("truncated percent-encoding")?;
127 out.push(u8::from_str_radix(hex, 16).context("invalid percent-encoding")?);
128 i += 3;
129 } else if bytes[i] == b'+' {
130 out.push(b'+');
131 i += 1;
132 } else {
133 out.push(bytes[i]);
134 i += 1;
135 }
136 }
137 String::from_utf8(out).context("query value is not UTF-8")
138}
139
140pub fn percent_encode(text: &str) -> String {
141 let mut out = String::new();
142 for b in text.bytes() {
143 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'*' | b':') {
144 out.push(b as char);
145 } else {
146 out.push_str(&format!("%{b:02X}"));
147 }
148 }
149 out
150}
151
152fn plain(value: &str) -> Result<()> {
153 ensure!(
154 !value.is_empty() && value.len() <= MAX_VALUE_BYTES,
155 "query value must be 1..=255 bytes"
156 );
157 ensure!(
158 !value.chars().any(|c| c.is_control()),
159 "query value contains a control character"
160 );
161 Ok(())
162}
163
164fn name(value: &str, wildcard: bool) -> Result<String> {
167 plain(value)?;
168 let v = value.strip_suffix('.').unwrap_or(value).to_lowercase();
169 ensure!(v.len() <= 253 && !v.is_empty(), "name is too long or empty");
170 for (i, label) in v.split('.').enumerate() {
171 let star = wildcard && label.contains('*');
172 ensure!(
173 !label.is_empty() && label.len() <= 63,
174 "empty or over-long label"
175 );
176 ensure!(
177 label
178 .chars()
179 .all(|c| c.is_alphanumeric() || c == '-' || (star && c == '*')),
180 "label '{label}' has a character outside letters, digits and hyphen"
181 );
182 ensure!(
183 !star || i == 0 || label == "*",
184 "a wildcard may only stand in the first label"
185 );
186 }
187 Ok(v)
188}
189
190fn ip(value: &str) -> Result<String> {
191 plain(value)?;
192 let (addr, len) = match value.split_once('/') {
193 Some((a, l)) => (
194 a,
195 Some(l.parse::<u8>().context("prefix length must be a number")?),
196 ),
197 None => (value, None),
198 };
199 let parsed: IpAddr = addr.parse().context("not an IP address")?;
200 let max = if parsed.is_ipv4() { 32 } else { 128 };
201 Ok(match len {
202 Some(l) => {
203 ensure!(l <= max, "prefix length exceeds {max}");
204 format!("{parsed}/{l}")
205 }
206 None => parsed.to_string(),
207 })
208}
209
210pub fn parse(path: &str, query: Option<&str>) -> Result<Query> {
212 let path = path.trim_start_matches('/');
213 let segments: Vec<&str> = path.split('/').collect();
214 let query_pairs = || -> Result<Vec<(String, String)>> {
215 let mut pairs = Vec::new();
216 for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
217 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
218 pairs.push((percent_decode(k)?, percent_decode(v)?));
219 ensure!(pairs.len() <= 8, "too many query parameters");
220 }
221 Ok(pairs)
222 };
223 match segments.as_slice() {
224 ["help"] => Ok(Query::Help),
225 [kind, rest @ ..] if Lookup::from_name(kind).is_some() && !rest.is_empty() => {
226 let kind = Lookup::from_name(kind).expect("checked");
227 let raw = match (kind, rest) {
228 (Lookup::Ip, [a, l]) => format!("{}/{}", percent_decode(a)?, percent_decode(l)?),
229 (_, [v]) => percent_decode(v)?,
230 _ => bail!("too many path segments"),
231 };
232 let value = match kind {
233 Lookup::Domain | Lookup::Nameserver => name(&raw, false)?,
234 Lookup::Ip => ip(&raw)?,
235 Lookup::Autnum => raw
236 .parse::<u32>()
237 .context("autnum must be a 32-bit number")?
238 .to_string(),
239 Lookup::Entity => {
240 plain(&raw)?;
241 raw
242 }
243 };
244 Ok(Query::Lookup { kind, value })
245 }
246 [kind] if Search::from_name(kind).is_some() => {
247 let kind = Search::from_name(kind).expect("checked");
248 let pairs = query_pairs()?;
249 ensure!(pairs.len() == 1, "a search takes exactly one parameter");
250 let (parameter, raw) = pairs.into_iter().next().expect("one");
251 ensure!(
252 kind.parameters().contains(¶meter.as_str()),
253 "{} searches take {:?}",
254 kind.path(),
255 kind.parameters()
256 );
257 let value = match parameter.as_str() {
258 "name" | "nsLdhName" => name(&raw, true)?,
259 "nsIp" | "ip" => ip(&raw)?,
260 _ => {
261 plain(&raw)?;
262 raw
263 }
264 };
265 Ok(Query::Search {
266 kind,
267 parameter,
268 value,
269 })
270 }
271 _ => bail!("not an RFC 9082 query path"),
272 }
273}
274
275impl Query {
276 pub fn to_event(&self) -> Value {
278 match self {
279 Query::Lookup { kind, value } => json!({"query_type": kind.path(), "value": value}),
280 Query::Search {
281 kind,
282 parameter,
283 value,
284 } => json!({"query_type": kind.path(), "search_parameter": parameter, "value": value}),
285 Query::Help => json!({"query_type": "help"}),
286 }
287 }
288 pub fn path(&self) -> String {
290 match self {
291 Query::Lookup {
292 kind: Lookup::Ip,
293 value,
294 } => format!("ip/{value}"),
295 Query::Lookup { kind, value } => format!("{}/{}", kind.path(), percent_encode(value)),
296 Query::Search {
297 kind,
298 parameter,
299 value,
300 } => format!("{}?{}={}", kind.path(), parameter, percent_encode(value)),
301 Query::Help => "help".into(),
302 }
303 }
304 pub fn from_action(v: &Value) -> Result<Self> {
306 let kind = v["query_type"].as_str().context("query_type required")?;
307 if kind == "help" {
308 return Ok(Query::Help);
309 }
310 let value = v["value"].as_str().context("value required")?;
311 if let Some(lookup) = Lookup::from_name(kind) {
312 let path = if lookup == Lookup::Ip {
313 format!("ip/{value}")
314 } else {
315 format!("{kind}/{}", percent_encode(value))
316 };
317 return parse(&path, None);
318 }
319 let search =
320 Search::from_name(kind).with_context(|| format!("unknown query_type '{kind}'"))?;
321 let parameter = v["search_parameter"]
322 .as_str()
323 .unwrap_or(search.parameters()[0]);
324 parse(
325 search.path(),
326 Some(&format!("{parameter}={}", percent_encode(value))),
327 )
328 }
329}
330
331pub fn json_ok(value: &Value) -> bool {
332 crate::utils::json_budget::within_budget(value, MAX_BODY_BYTES, MAX_JSON_NODES, MAX_JSON_DEPTH)
333}
334
335fn conformance(obj: &mut Map<String, Value>) -> Result<()> {
336 let mut list: Vec<Value> = match obj.remove("rdapConformance") {
337 None => Vec::new(),
338 Some(Value::Array(a)) => a,
339 Some(_) => bail!("rdapConformance must be an array of strings"),
340 };
341 ensure!(
342 list.iter().all(Value::is_string) && list.len() <= 64,
343 "rdapConformance must be an array of strings"
344 );
345 if !list.iter().any(|v| v == CONFORMANCE) {
346 list.insert(0, json!(CONFORMANCE));
347 }
348 obj.insert("rdapConformance".into(), Value::Array(list));
349 Ok(())
350}
351
352fn class_is(obj: &Value, expected: &str) -> Result<()> {
353 let got = obj.get("objectClassName").and_then(Value::as_str);
354 ensure!(
355 got == Some(expected),
356 "objectClassName must be \"{expected}\", not {got:?}"
357 );
358 Ok(())
359}
360
361#[derive(Debug, PartialEq)]
363pub enum Answer {
364 Body { status: u16, body: Value },
365 Redirect(String),
366}
367
368pub fn error_body(code: u16, title: &str, description: &[String]) -> Value {
369 let mut body = json!({"rdapConformance": [CONFORMANCE], "errorCode": code, "title": title});
370 if !description.is_empty() {
371 body["description"] = json!(description);
372 }
373 body
374}
375
376pub fn answer(query: &Query, action: &Value) -> Result<Answer> {
378 let obj = action.as_object().context("response must be an object")?;
379 for k in obj.keys() {
380 ensure!(
381 matches!(
382 k.as_str(),
383 "type" | "object" | "results" | "not_found" | "error" | "redirect"
384 ),
385 "unknown response field '{k}'"
386 );
387 }
388 let present: Vec<&str> = ["object", "results", "not_found", "error", "redirect"]
389 .into_iter()
390 .filter(|k| obj.get(*k).is_some_and(|v| !v.is_null()))
391 .collect();
392 ensure!(
393 present.len() == 1,
394 "supply exactly one of object, results, not_found, error, redirect"
395 );
396 match present[0] {
397 "not_found" => {
398 ensure!(obj["not_found"] == true, "not_found must be true");
399 Ok(Answer::Body {
400 status: 404,
401 body: error_body(404, "Not Found", &[]),
402 })
403 }
404 "error" => {
405 let e = obj["error"]
406 .as_object()
407 .context("error must be an object")?;
408 let code = e
409 .get("code")
410 .and_then(Value::as_u64)
411 .context("error.code required")?;
412 ensure!(
413 matches!(code, 400 | 403 | 404 | 422 | 429 | 500 | 501 | 503),
414 "error.code must be 400, 403, 404, 422, 429, 500, 501 or 503"
415 );
416 let title = e.get("title").and_then(Value::as_str).unwrap_or("Error");
417 ensure!(title.len() <= 256, "error.title too long");
418 let description: Vec<String> = match e.get("description") {
419 None => Vec::new(),
420 Some(Value::Array(a)) => a
421 .iter()
422 .map(|v| {
423 v.as_str()
424 .map(str::to_owned)
425 .context("description entries must be strings")
426 })
427 .collect::<Result<_>>()?,
428 Some(Value::String(s)) => vec![s.clone()],
429 _ => bail!("error.description must be a string or array of strings"),
430 };
431 ensure!(
432 description.len() <= 16 && description.iter().all(|d| d.len() <= 1024),
433 "error.description bound"
434 );
435 Ok(Answer::Body {
436 status: code as u16,
437 body: error_body(code as u16, title, &description),
438 })
439 }
440 "redirect" => {
441 let url = obj["redirect"]
442 .as_str()
443 .context("redirect must be a URL string")?;
444 ensure!(
445 url.len() <= 2048 && (url.starts_with("https://") || url.starts_with("http://")),
446 "redirect must be an http(s) URL"
447 );
448 ensure!(
449 !url.chars().any(|c| c.is_whitespace() || c.is_control()),
450 "redirect URL has whitespace or controls"
451 );
452 ensure!(
453 matches!(query, Query::Lookup { .. }),
454 "only lookups are redirected"
455 );
456 Ok(Answer::Redirect(url.to_owned()))
457 }
458 "object" => {
459 let mut object = obj["object"].clone();
460 ensure!(json_ok(&object), "object exceeds the RDAP response bounds");
461 let map = object
462 .as_object_mut()
463 .context("object must be a JSON object")?;
464 match query {
465 Query::Lookup { kind, .. } => class_is(&Value::Object(map.clone()), kind.class())?,
466 Query::Help => ensure!(
467 map.get("notices").is_some_and(Value::is_array),
468 "a help answer carries notices"
469 ),
470 Query::Search { .. } => bail!("a search is answered with results, not object"),
471 }
472 conformance(map)?;
473 Ok(Answer::Body {
474 status: 200,
475 body: object,
476 })
477 }
478 _ => {
479 let Query::Search { kind, .. } = query else {
480 bail!("results answer searches only")
481 };
482 let results = obj["results"]
483 .as_array()
484 .context("results must be an array")?;
485 ensure!(
486 results.len() <= MAX_SEARCH_RESULTS,
487 "too many search results"
488 );
489 for r in results {
490 class_is(r, kind.class())?;
491 }
492 let mut body = Map::new();
493 body.insert(kind.member().into(), Value::Array(results.clone()));
494 conformance(&mut body)?;
495 let body = Value::Object(body);
496 ensure!(json_ok(&body), "results exceed the RDAP response bounds");
497 Ok(Answer::Body { status: 200, body })
498 }
499 }
500}
501
502pub fn check_response(query: &Query, body: &Value) -> Result<()> {
504 ensure!(json_ok(body), "response exceeds the RDAP bounds");
505 let obj = body
506 .as_object()
507 .context("RDAP response must be a JSON object")?;
508 let conf = obj
509 .get("rdapConformance")
510 .and_then(Value::as_array)
511 .context("RDAP response lacks rdapConformance")?;
512 ensure!(
513 conf.iter().all(Value::is_string),
514 "rdapConformance must hold strings"
515 );
516 match query {
517 Query::Lookup { kind, .. } => class_is(body, kind.class()),
518 Query::Search { kind, .. } => {
519 let results = obj
520 .get(kind.member())
521 .and_then(Value::as_array)
522 .with_context(|| format!("search response lacks {}", kind.member()))?;
523 for r in results {
524 class_is(r, kind.class())?;
525 }
526 Ok(())
527 }
528 Query::Help => Ok(()),
529 }
530}