Skip to main content

macula_rust/record/
node_record.rs

1//! Node records: a node's statement of the realms it serves, its
2//! capabilities and where it is, signed by the node and stored under its
3//! node_id. Coordinates travel as text with at most 6 decimals and no
4//! trailing zeros, stable across stacks where float encodings are not.
5
6use 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/// A node record's optional fields: `station_id` is `None` for the node
15/// itself; empty text and `None` coordinates are left out; `kind` is
16/// "station" or "daemon"; `peers` are kept sorted and once each; `ttl_ms` is
17/// 0 for the default, 48 hours.
18#[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
34/// An unsigned node record about `node_id`, which signs it. A coordinate out
35/// of its range, or NaN, is refused.
36pub 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/// A node record's payload, as macula_record reads it. A field left out, or
85/// of another kind, is zero, empty or `None`.
86#[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
105/// Reads a node record's payload.
106pub 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
133/// A coordinate as macula renders one: 6 decimals, trailing zeros cut,
134/// keeping one digit after the point.
135fn 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
145/// A coordinate's text as macula_record's parse_geo/2 reads it: at most 32
146/// bytes, an optional leading minus, digits, then optionally a dot and
147/// digits, within `bound` of zero.
148fn 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}