1use crate::cbor::Value;
7
8use super::{entry, id_field, malformed, text_field, unsigned, Record, RecordError, RecordType};
9
10const MAX_GEO_TEXT_BYTES: usize = 32;
11const LAT_BOUND: f64 = 90.0;
12const LNG_BOUND: f64 = 180.0;
13
14#[derive(Debug, Clone, Default, PartialEq)]
19pub struct NodeRecordOptions {
20 pub station_id: Option<[u8; 32]>,
21 pub caps_hint: String,
22 pub display_name: String,
23 pub hostname: String,
24 pub endpoint: String,
25 pub city: String,
26 pub country: String,
27 pub lat: Option<f64>,
28 pub lng: Option<f64>,
29 pub kind: String,
30 pub peers: Vec<[u8; 32]>,
31 pub ttl_ms: u64,
32}
33
34pub fn new_node_record(
37 node_id: &[u8; 32],
38 realms: &[[u8; 32]],
39 capabilities: u64,
40 opts: &NodeRecordOptions,
41) -> Result<Record, RecordError> {
42 let mut entries = vec![
43 entry("node_id", Value::Bytes(node_id.to_vec())),
44 entry(
45 "station_id",
46 Value::Bytes(opts.station_id.unwrap_or(*node_id).to_vec()),
47 ),
48 entry("realms", id_list(realms)),
49 entry("capabilities", Value::Int(i128::from(capabilities))),
50 ];
51 for (name, value) in [
52 ("caps_hint", &opts.caps_hint),
53 ("display_name", &opts.display_name),
54 ("hostname", &opts.hostname),
55 ("endpoint", &opts.endpoint),
56 ("city", &opts.city),
57 ("country", &opts.country),
58 ("kind", &opts.kind),
59 ] {
60 if !value.is_empty() {
61 entries.push(entry(name, Value::text(value.clone())));
62 }
63 }
64 for (name, value, bound) in [("lat", opts.lat, LAT_BOUND), ("lng", opts.lng, LNG_BOUND)] {
65 let Some(v) = value else { continue };
66 if v.is_nan() || v.abs() > bound {
67 return Err(RecordError::InvalidCoordinate(format!("{name} {v}")));
68 }
69 entries.push(entry(name, Value::text(geo_text(v))));
70 }
71 let mut peers = opts.peers.clone();
72 peers.sort_unstable();
73 peers.dedup();
74 if !peers.is_empty() {
75 entries.push(entry("peers", id_list(&peers)));
76 }
77 Ok(unsigned(
78 RecordType::NODE_RECORD,
79 Value::Map(entries),
80 opts.ttl_ms,
81 ))
82}
83
84#[derive(Debug, Clone, Default, PartialEq)]
87pub struct NodeRecord {
88 pub node_id: [u8; 32],
89 pub station_id: [u8; 32],
90 pub realms: Vec<[u8; 32]>,
91 pub capabilities: u64,
92 pub kind: String,
93 pub hostname: String,
94 pub endpoint: String,
95 pub city: String,
96 pub country: String,
97 pub lat: Option<f64>,
98 pub lng: Option<f64>,
99 pub display_name: String,
100 pub caps_hint: String,
101 pub peers: Vec<[u8; 32]>,
102 pub version: String,
103}
104
105pub fn read_node_record(r: &Record) -> Result<NodeRecord, RecordError> {
107 if r.record_type != RecordType::NODE_RECORD {
108 return Err(malformed("not a node record"));
109 }
110 let p = &r.payload;
111 Ok(NodeRecord {
112 node_id: id_field(p, "node_id"),
113 station_id: id_field(p, "station_id"),
114 realms: read_ids(p.get("realms")),
115 capabilities: match p.get("capabilities") {
116 Some(Value::Int(n)) if *n >= 0 => u64::try_from(*n).unwrap_or(0),
117 _ => 0,
118 },
119 kind: text_field(p, "kind"),
120 hostname: text_field(p, "hostname"),
121 endpoint: text_field(p, "endpoint"),
122 city: text_field(p, "city"),
123 country: text_field(p, "country"),
124 lat: parse_geo(&text_field(p, "lat"), LAT_BOUND),
125 lng: parse_geo(&text_field(p, "lng"), LNG_BOUND),
126 display_name: text_field(p, "display_name"),
127 caps_hint: text_field(p, "caps_hint"),
128 peers: read_ids(p.get("peers")),
129 version: text_field(p, "version"),
130 })
131}
132
133fn geo_text(v: f64) -> String {
136 let s = format!("{v:.6}");
137 let s = s.trim_end_matches('0');
138 if s.ends_with('.') {
139 format!("{s}0")
140 } else {
141 s.to_string()
142 }
143}
144
145fn parse_geo(s: &str, bound: f64) -> Option<f64> {
149 if s.len() > MAX_GEO_TEXT_BYTES {
150 return None;
151 }
152 let unsigned = s.strip_prefix('-').unwrap_or(s);
153 let (whole, fraction) = match unsigned.split_once('.') {
154 Some((w, f)) => (w, Some(f)),
155 None => (unsigned, None),
156 };
157 let digits = |d: &str| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit());
158 if !digits(whole) || fraction.is_some_and(|f| !digits(f)) {
159 return None;
160 }
161 let v: f64 = s.parse().ok()?;
162 if v.abs() > bound {
163 return None;
164 }
165 Some(if v == 0.0 && fraction.is_none() {
166 0.0
167 } else {
168 v
169 })
170}
171
172fn id_list(ids: &[[u8; 32]]) -> Value {
173 Value::List(ids.iter().map(|id| Value::Bytes(id.to_vec())).collect())
174}
175
176fn read_ids(v: Option<&Value>) -> Vec<[u8; 32]> {
177 match v {
178 Some(Value::List(items)) => items
179 .iter()
180 .filter_map(|item| match item {
181 Value::Bytes(b) => b.as_slice().try_into().ok(),
182 _ => None,
183 })
184 .collect(),
185 _ => Vec::new(),
186 }
187}