1use std::collections::HashMap;
19
20use serde::{Deserialize, Serialize};
21
22use crate::posture::{Posture, PostureLevel};
23use crate::types::ServiceRecord;
24
25pub const TXT_FP: &str = "fp";
29
30pub const TXT_POSTURE: &str = "posture";
33
34pub const TXT_EXPIRES: &str = "expires";
38
39pub const TXT_CN: &str = "cn";
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct Peer {
51 pub record: ServiceRecord,
53 pub posture: Posture,
55 pub fp: Option<String>,
57 pub cn: Option<String>,
60 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
62}
63
64impl Peer {
65 pub fn from_record(record: ServiceRecord) -> Self {
72 let fp = non_empty(record.txt.get(TXT_FP));
73 let cn = non_empty(record.txt.get(TXT_CN));
74 let expires_at = record
75 .txt
76 .get(TXT_EXPIRES)
77 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
78 .map(|dt| dt.with_timezone(&chrono::Utc));
79 let posture = parse_posture(&record.txt, fp.is_some());
80 Self {
81 record,
82 posture,
83 fp,
84 cn,
85 expires_at,
86 }
87 }
88
89 pub fn level(&self) -> PostureLevel {
91 self.posture.level()
92 }
93
94 pub fn is_secure(&self) -> bool {
96 self.posture.is_secure()
97 }
98
99 pub fn addr(&self) -> Option<(String, u16)> {
102 let host = self
103 .record
104 .ip
105 .clone()
106 .or_else(|| self.record.host.clone())?;
107 let port = self.record.port?;
108 Some((host, port))
109 }
110
111 pub fn expires_in(&self, now: chrono::DateTime<chrono::Utc>) -> Option<chrono::Duration> {
117 self.expires_at.map(|exp| exp - now)
118 }
119}
120
121pub fn stamp(
129 txt: &mut HashMap<String, String>,
130 posture: Posture,
131 ca_fp: Option<&str>,
132 expires_at: Option<chrono::DateTime<chrono::Utc>>,
133) {
134 txt.insert(
135 TXT_POSTURE.to_string(),
136 posture.level().as_wire().to_string(),
137 );
138 if let Some(fp) = ca_fp.filter(|f| !f.is_empty()) {
139 txt.insert(TXT_FP.to_string(), fp.to_string());
140 }
141 if let Some(exp) = expires_at {
142 txt.insert(TXT_EXPIRES.to_string(), exp.to_rfc3339());
143 }
144}
145
146fn non_empty(v: Option<&String>) -> Option<String> {
148 v.filter(|s| !s.is_empty()).cloned()
149}
150
151fn parse_posture(txt: &HashMap<String, String>, has_fp: bool) -> Posture {
154 if let Some(level) = txt
155 .get(TXT_POSTURE)
156 .and_then(|s| PostureLevel::from_wire(s))
157 {
158 return level.to_posture();
159 }
160 if has_fp {
161 Posture::new(true, false)
162 } else {
163 Posture::OPEN
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 fn record_with(txt: &[(&str, &str)]) -> ServiceRecord {
172 ServiceRecord {
173 name: "peer-01".to_string(),
174 service_type: "_http._tcp".to_string(),
175 host: Some("peer-01.local".to_string()),
176 ip: Some("192.168.1.10".to_string()),
177 port: Some(8443),
178 txt: txt
179 .iter()
180 .map(|(k, v)| (k.to_string(), v.to_string()))
181 .collect(),
182 }
183 }
184
185 #[test]
186 fn open_when_no_trust_hints() {
187 let p = Peer::from_record(record_with(&[]));
188 assert_eq!(p.posture, Posture::OPEN);
189 assert_eq!(p.level(), PostureLevel::Open);
190 assert!(!p.is_secure());
191 assert!(p.fp.is_none());
192 assert!(p.expires_at.is_none());
193 }
194
195 #[test]
196 fn fp_without_posture_infers_authenticated() {
197 let p = Peer::from_record(record_with(&[("fp", "ABC123")]));
198 assert_eq!(p.level(), PostureLevel::Authenticated);
199 assert!(p.is_secure());
200 assert_eq!(p.fp.as_deref(), Some("ABC123"));
201 }
202
203 #[test]
204 fn explicit_posture_wins_over_fp_inference() {
205 let p = Peer::from_record(record_with(&[("fp", "ABC123"), ("posture", "open")]));
207 assert_eq!(p.level(), PostureLevel::Open);
208 assert_eq!(p.fp.as_deref(), Some("ABC123"));
210 }
211
212 #[test]
213 fn confidential_posture_parsed() {
214 let p = Peer::from_record(record_with(&[("posture", "confidential")]));
215 assert_eq!(p.level(), PostureLevel::Confidential);
216 assert_eq!(p.posture, Posture::new(true, true));
217 }
218
219 #[test]
220 fn unknown_posture_token_falls_back_to_inference() {
221 let p = Peer::from_record(record_with(&[("posture", "supersecure")]));
223 assert_eq!(p.level(), PostureLevel::Open);
224 }
225
226 #[test]
227 fn blank_fp_is_treated_as_absent() {
228 let p = Peer::from_record(record_with(&[("fp", "")]));
229 assert!(p.fp.is_none());
230 assert_eq!(p.level(), PostureLevel::Open);
231 }
232
233 #[test]
234 fn expires_parsed_and_remaining_computed() {
235 let exp = "2030-01-01T00:00:00Z";
236 let p = Peer::from_record(record_with(&[
237 ("posture", "authenticated"),
238 ("expires", exp),
239 ]));
240 assert!(p.expires_at.is_some());
241 let now = chrono::DateTime::parse_from_rfc3339("2029-01-01T00:00:00Z")
242 .unwrap()
243 .with_timezone(&chrono::Utc);
244 let remaining = p.expires_in(now).unwrap();
245 assert!(remaining.num_days() >= 364 && remaining.num_days() <= 366);
246 }
247
248 #[test]
249 fn expired_identity_reports_negative_remaining() {
250 let p = Peer::from_record(record_with(&[("expires", "2020-01-01T00:00:00Z")]));
251 let now = chrono::DateTime::parse_from_rfc3339("2021-01-01T00:00:00Z")
252 .unwrap()
253 .with_timezone(&chrono::Utc);
254 assert!(p.expires_in(now).unwrap() < chrono::Duration::zero());
255 }
256
257 #[test]
258 fn malformed_expires_is_ignored() {
259 let p = Peer::from_record(record_with(&[("expires", "not-a-timestamp")]));
260 assert!(p.expires_at.is_none());
261 }
262
263 #[test]
264 fn cn_parsed_when_present() {
265 let p = Peer::from_record(record_with(&[("cn", "peer-01")]));
266 assert_eq!(p.cn.as_deref(), Some("peer-01"));
267 }
268
269 #[test]
270 fn addr_prefers_ip_then_falls_back_to_host() {
271 let p = Peer::from_record(record_with(&[]));
272 assert_eq!(p.addr(), Some(("192.168.1.10".to_string(), 8443)));
273
274 let mut rec = record_with(&[]);
275 rec.ip = None;
276 let p = Peer::from_record(rec);
277 assert_eq!(p.addr(), Some(("peer-01.local".to_string(), 8443)));
278 }
279
280 #[test]
281 fn addr_none_without_port() {
282 let mut rec = record_with(&[]);
283 rec.port = None;
284 let p = Peer::from_record(rec);
285 assert_eq!(p.addr(), None);
286 }
287
288 #[test]
289 fn stamp_then_parse_round_trips() {
290 let mut txt = HashMap::new();
291 let exp = chrono::DateTime::parse_from_rfc3339("2031-06-01T12:00:00Z")
292 .unwrap()
293 .with_timezone(&chrono::Utc);
294 stamp(
295 &mut txt,
296 Posture::new(true, false),
297 Some("FP-XYZ"),
298 Some(exp),
299 );
300
301 let rec = ServiceRecord {
302 name: "n".into(),
303 service_type: "_http._tcp".into(),
304 host: None,
305 ip: Some("10.0.0.1".into()),
306 port: Some(443),
307 txt,
308 };
309 let p = Peer::from_record(rec);
310 assert_eq!(p.level(), PostureLevel::Authenticated);
311 assert_eq!(p.fp.as_deref(), Some("FP-XYZ"));
312 assert_eq!(p.expires_at, Some(exp));
313 }
314
315 #[test]
316 fn stamp_open_writes_posture_only() {
317 let mut txt = HashMap::new();
318 stamp(&mut txt, Posture::OPEN, None, None);
319 assert_eq!(txt.get(TXT_POSTURE).map(String::as_str), Some("open"));
320 assert!(!txt.contains_key(TXT_FP));
321 assert!(!txt.contains_key(TXT_EXPIRES));
322 }
323
324 #[test]
325 fn stamp_skips_empty_fp() {
326 let mut txt = HashMap::new();
327 stamp(&mut txt, Posture::new(true, false), Some(""), None);
328 assert!(!txt.contains_key(TXT_FP));
329 }
330
331 #[test]
332 fn peer_serde_round_trips() {
333 let p = Peer::from_record(record_with(&[("fp", "ABC"), ("posture", "authenticated")]));
334 let json = serde_json::to_string(&p).unwrap();
335 let back: Peer = serde_json::from_str(&json).unwrap();
336 assert_eq!(p, back);
337 }
338}