1use crate::net::atp::protocol::PeerId;
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11use std::fs;
12use std::io;
13use std::path::Path;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16pub const PEER_DIRECTORY_SCHEMA_V1: &str = "asupersync.atp.peer_directory.v1";
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct PeerDirectory {
22 pub schema_version: String,
24 #[serde(with = "peer_map_hex")]
26 pub peers: BTreeMap<PeerId, PeerRecord>,
27 pub groups: BTreeMap<String, GroupRecord>,
29 pub audit_log: Vec<DirectoryAuditRecord>,
31 next_sequence: u64,
32}
33
34impl Default for PeerDirectory {
35 fn default() -> Self {
36 Self::new()
37 }
38}
39
40impl PeerDirectory {
41 #[must_use]
43 pub fn new() -> Self {
44 Self {
45 schema_version: PEER_DIRECTORY_SCHEMA_V1.to_string(),
46 peers: BTreeMap::new(),
47 groups: BTreeMap::new(),
48 audit_log: Vec::new(),
49 next_sequence: 1,
50 }
51 }
52
53 pub fn load_json(path: impl AsRef<Path>) -> Result<Self, DirectoryIoError> {
55 let bytes = fs::read(path.as_ref()).map_err(DirectoryIoError::Read)?;
56 let mut directory: Self =
57 serde_json::from_slice(&bytes).map_err(DirectoryIoError::Decode)?;
58 directory.repair_sequence();
59 Ok(directory)
60 }
61
62 pub fn save_json(&self, path: impl AsRef<Path>) -> Result<(), DirectoryIoError> {
64 let bytes = serde_json::to_vec_pretty(self).map_err(DirectoryIoError::Encode)?;
65 fs::write(path.as_ref(), bytes).map_err(DirectoryIoError::Write)
66 }
67
68 pub fn upsert_peer(&mut self, peer: PeerRecord, actor: Option<PeerId>) {
70 let target = DirectorySubject::Peer(peer.peer_id);
71 let operation = if self.peers.contains_key(&peer.peer_id) {
72 DirectoryOperation::PeerUpdated
73 } else {
74 DirectoryOperation::PeerAdded
75 };
76 let summary = format!("peer {}", peer.display_name);
77 self.peers.insert(peer.peer_id, peer);
78 self.audit(actor, operation, target, summary);
79 }
80
81 pub fn rename_peer(
83 &mut self,
84 subject: DirectorySubject,
85 display_name: impl Into<String>,
86 actor: Option<PeerId>,
87 ) -> Result<(), DirectoryError> {
88 let peer_id = self.subject_peer_id(&subject)?;
89 let display_name = normalize_name(display_name.into())?;
90 let peer = self
91 .peers
92 .get_mut(&peer_id)
93 .ok_or(DirectoryError::PeerNotFound(peer_id))?;
94 if peer.display_name != display_name {
95 peer.aliases.insert(peer.display_name.clone());
96 peer.display_name.clone_from(&display_name);
97 }
98 self.audit(
99 actor,
100 DirectoryOperation::PeerRenamed,
101 DirectorySubject::Peer(peer_id),
102 format!("peer renamed to {display_name}"),
103 );
104 Ok(())
105 }
106
107 pub fn upsert_device(
109 &mut self,
110 peer_id: PeerId,
111 device: DeviceRecord,
112 actor: Option<PeerId>,
113 ) -> Result<(), DirectoryError> {
114 if device.peer_id != peer_id {
115 return Err(DirectoryError::DevicePeerMismatch {
116 expected: peer_id,
117 actual: device.peer_id,
118 });
119 }
120 let peer = self
121 .peers
122 .get_mut(&peer_id)
123 .ok_or(DirectoryError::PeerNotFound(peer_id))?;
124 let device_id = device.device_id.clone();
125 peer.devices.insert(device_id.clone(), device);
126 peer.last_seen_micros = now_micros();
127 self.audit(
128 actor,
129 DirectoryOperation::DeviceUpdated,
130 DirectorySubject::Device { peer_id, device_id },
131 "device updated".to_string(),
132 );
133 Ok(())
134 }
135
136 pub fn rename_device(
138 &mut self,
139 peer_id: PeerId,
140 device_id: &str,
141 device_name: impl Into<String>,
142 actor: Option<PeerId>,
143 ) -> Result<(), DirectoryError> {
144 let device_name = normalize_name(device_name.into())?;
145 let peer = self
146 .peers
147 .get_mut(&peer_id)
148 .ok_or(DirectoryError::PeerNotFound(peer_id))?;
149 let device =
150 peer.devices
151 .get_mut(device_id)
152 .ok_or_else(|| DirectoryError::DeviceNotFound {
153 peer_id,
154 device_id: device_id.to_string(),
155 })?;
156 if device.device_name != device_name {
157 device.aliases.insert(device.device_name.clone());
158 device.device_name.clone_from(&device_name);
159 }
160 self.audit(
161 actor,
162 DirectoryOperation::DeviceRenamed,
163 DirectorySubject::Device {
164 peer_id,
165 device_id: device_id.to_string(),
166 },
167 format!("device renamed to {device_name}"),
168 );
169 Ok(())
170 }
171
172 pub fn revoke_peer(
174 &mut self,
175 subject: DirectorySubject,
176 reason: impl Into<String>,
177 actor: Option<PeerId>,
178 ) -> Result<(), DirectoryError> {
179 let peer_id = self.subject_peer_id(&subject)?;
180 let peer = self
181 .peers
182 .get_mut(&peer_id)
183 .ok_or(DirectoryError::PeerNotFound(peer_id))?;
184 peer.revoked = true;
185 let reason = reason.into();
186 peer.trust_notes.push(reason.clone());
187 for device in peer.devices.values_mut() {
188 device.revoked = true;
189 }
190 self.audit(
191 actor,
192 DirectoryOperation::PeerRevoked,
193 DirectorySubject::Peer(peer_id),
194 reason,
195 );
196 Ok(())
197 }
198
199 pub fn upsert_group(&mut self, group: GroupRecord, actor: Option<PeerId>) {
201 let group_name = group.name.clone();
202 let operation = if self.groups.contains_key(&group_name) {
203 DirectoryOperation::GroupUpdated
204 } else {
205 DirectoryOperation::GroupAdded
206 };
207 self.groups.insert(group_name.clone(), group);
208 self.audit(
209 actor,
210 operation,
211 DirectorySubject::Group(group_name.clone()),
212 format!("group {group_name}"),
213 );
214 }
215
216 pub fn add_group_member(
218 &mut self,
219 group_name: &str,
220 member: DirectorySubject,
221 actor: Option<PeerId>,
222 ) -> Result<(), DirectoryError> {
223 self.validate_subject_exists(&member)?;
224 let group = self
225 .groups
226 .get_mut(group_name)
227 .ok_or_else(|| DirectoryError::GroupNotFound(group_name.to_string()))?;
228 group.members.insert(member.clone());
229 self.audit(
230 actor,
231 DirectoryOperation::GroupMemberAdded,
232 DirectorySubject::Group(group_name.to_string()),
233 format!("member {}", member.display_label()),
234 );
235 Ok(())
236 }
237
238 pub fn attach_grant(
240 &mut self,
241 subject: DirectorySubject,
242 grant: DirectoryGrant,
243 actor: Option<PeerId>,
244 ) -> Result<(), DirectoryError> {
245 self.validate_subject_exists(&subject)?;
246 match &subject {
247 DirectorySubject::Peer(peer_id) => {
248 self.peers
249 .get_mut(peer_id)
250 .ok_or(DirectoryError::PeerNotFound(*peer_id))?
251 .grants
252 .push(grant.clone());
253 }
254 DirectorySubject::Device { peer_id, device_id } => {
255 self.peers
256 .get_mut(peer_id)
257 .ok_or(DirectoryError::PeerNotFound(*peer_id))?
258 .devices
259 .get_mut(device_id)
260 .ok_or_else(|| DirectoryError::DeviceNotFound {
261 peer_id: *peer_id,
262 device_id: device_id.clone(),
263 })?
264 .grants
265 .push(grant.clone());
266 }
267 DirectorySubject::Group(group) => {
268 self.groups
269 .get_mut(group)
270 .ok_or_else(|| DirectoryError::GroupNotFound(group.clone()))?
271 .grants
272 .push(grant.clone());
273 }
274 DirectorySubject::Relay(relay) => {
275 return Err(DirectoryError::UnsupportedSubject(relay.clone()));
276 }
277 }
278 self.audit(
279 actor,
280 DirectoryOperation::GrantAttached,
281 subject,
282 grant.grant_id,
283 );
284 Ok(())
285 }
286
287 pub fn resolve_group_grants(
289 &self,
290 group_name: &str,
291 ) -> Result<Vec<ResolvedDirectoryGrant>, DirectoryError> {
292 let group = self
293 .groups
294 .get(group_name)
295 .ok_or_else(|| DirectoryError::GroupNotFound(group_name.to_string()))?;
296 if group.revoked {
297 return Err(DirectoryError::RevokedSubject(DirectorySubject::Group(
298 group_name.to_string(),
299 )));
300 }
301
302 let mut resolved = Vec::new();
303 for grant in &group.grants {
304 resolved.push(ResolvedDirectoryGrant {
305 source: DirectorySubject::Group(group.name.clone()),
306 subject: DirectorySubject::Group(group.name.clone()),
307 grant: grant.clone(),
308 });
309 }
310 for member in &group.members {
311 self.collect_member_grants(member, &mut resolved)?;
312 }
313 Ok(resolved)
314 }
315
316 pub fn resolve_name(&self, query: &str) -> Result<DirectorySubject, DirectoryError> {
318 let query = normalize_lookup(query);
319 let matches = self.matching_subjects(&query);
320 match matches.as_slice() {
321 [] => Err(DirectoryError::NameNotFound(query)),
322 [subject] => Ok(subject.clone()),
323 _ => Err(DirectoryError::AmbiguousName {
324 query,
325 matches: matches
326 .into_iter()
327 .map(|subject| subject.display_label())
328 .collect(),
329 }),
330 }
331 }
332
333 pub fn inspect(
335 &self,
336 subject: &DirectorySubject,
337 ) -> Result<DirectoryEntryView, DirectoryError> {
338 match subject {
339 DirectorySubject::Peer(peer_id) => {
340 let peer = self
341 .peers
342 .get(peer_id)
343 .ok_or(DirectoryError::PeerNotFound(*peer_id))?;
344 Ok(DirectoryEntryView::Peer(peer.clone()))
345 }
346 DirectorySubject::Device { peer_id, device_id } => {
347 let device = self
348 .peers
349 .get(peer_id)
350 .ok_or(DirectoryError::PeerNotFound(*peer_id))?
351 .devices
352 .get(device_id)
353 .ok_or_else(|| DirectoryError::DeviceNotFound {
354 peer_id: *peer_id,
355 device_id: device_id.clone(),
356 })?;
357 Ok(DirectoryEntryView::Device(device.clone()))
358 }
359 DirectorySubject::Group(name) => {
360 let group = self
361 .groups
362 .get(name)
363 .ok_or_else(|| DirectoryError::GroupNotFound(name.clone()))?;
364 Ok(DirectoryEntryView::Group(group.clone()))
365 }
366 DirectorySubject::Relay(relay) => {
367 Err(DirectoryError::UnsupportedSubject(relay.clone()))
368 }
369 }
370 }
371
372 #[must_use]
374 pub fn list_entries(&self) -> DirectoryList {
375 let peers = self
376 .peers
377 .values()
378 .filter(|peer| !peer.revoked)
379 .map(|peer| DirectoryPeerSummary {
380 peer_id: peer.peer_id,
381 display_name: peer.display_name.clone(),
382 groups: peer.groups.iter().cloned().collect(),
383 device_count: peer
384 .devices
385 .values()
386 .filter(|device| !device.revoked)
387 .count(),
388 grant_count: peer.grants.len(),
389 last_seen_micros: peer.last_seen_micros,
390 })
391 .collect();
392 let groups = self
393 .groups
394 .values()
395 .filter(|group| !group.revoked)
396 .map(|group| DirectoryGroupSummary {
397 name: group.name.clone(),
398 display_name: group.display_name.clone(),
399 member_count: group.members.len(),
400 grant_count: group.grants.len(),
401 })
402 .collect();
403 DirectoryList { peers, groups }
404 }
405
406 #[must_use]
408 pub fn stale_path_hints(&self, now_micros: u64) -> Vec<StalePathHint> {
409 let mut stale = Vec::new();
410 for peer in self.peers.values() {
411 if peer.revoked {
412 continue;
413 }
414 for hint in &peer.path_hints {
415 if hint.is_stale(now_micros) {
416 stale.push(StalePathHint {
417 subject: DirectorySubject::Peer(peer.peer_id),
418 hint: hint.clone(),
419 });
420 }
421 }
422 for device in peer.devices.values() {
423 if device.revoked {
424 continue;
425 }
426 for hint in &device.path_hints {
427 if hint.is_stale(now_micros) {
428 stale.push(StalePathHint {
429 subject: DirectorySubject::Device {
430 peer_id: peer.peer_id,
431 device_id: device.device_id.clone(),
432 },
433 hint: hint.clone(),
434 });
435 }
436 }
437 }
438 }
439 stale
440 }
441
442 fn subject_peer_id(&self, subject: &DirectorySubject) -> Result<PeerId, DirectoryError> {
443 match subject {
444 DirectorySubject::Peer(peer_id) => Ok(*peer_id),
445 DirectorySubject::Device { peer_id, .. } => Ok(*peer_id),
446 DirectorySubject::Group(group) => {
447 Err(DirectoryError::UnsupportedSubject(group.clone()))
448 }
449 DirectorySubject::Relay(relay) => {
450 Err(DirectoryError::UnsupportedSubject(relay.clone()))
451 }
452 }
453 }
454
455 fn validate_subject_exists(&self, subject: &DirectorySubject) -> Result<(), DirectoryError> {
456 match subject {
457 DirectorySubject::Peer(peer_id) => {
458 let peer = self
459 .peers
460 .get(peer_id)
461 .ok_or(DirectoryError::PeerNotFound(*peer_id))?;
462 if peer.revoked {
463 Err(DirectoryError::RevokedSubject(subject.clone()))
464 } else {
465 Ok(())
466 }
467 }
468 DirectorySubject::Device { peer_id, device_id } => {
469 let peer = self
470 .peers
471 .get(peer_id)
472 .ok_or(DirectoryError::PeerNotFound(*peer_id))?;
473 let device =
474 peer.devices
475 .get(device_id)
476 .ok_or_else(|| DirectoryError::DeviceNotFound {
477 peer_id: *peer_id,
478 device_id: device_id.clone(),
479 })?;
480 if peer.revoked || device.revoked {
481 Err(DirectoryError::RevokedSubject(subject.clone()))
482 } else {
483 Ok(())
484 }
485 }
486 DirectorySubject::Group(group) => {
487 let group = self
488 .groups
489 .get(group)
490 .ok_or_else(|| DirectoryError::GroupNotFound(group.clone()))?;
491 if group.revoked {
492 Err(DirectoryError::RevokedSubject(subject.clone()))
493 } else {
494 Ok(())
495 }
496 }
497 DirectorySubject::Relay(_) => Ok(()),
498 }
499 }
500
501 fn collect_member_grants(
502 &self,
503 subject: &DirectorySubject,
504 resolved: &mut Vec<ResolvedDirectoryGrant>,
505 ) -> Result<(), DirectoryError> {
506 match subject {
507 DirectorySubject::Peer(peer_id) => {
508 let peer = self
509 .peers
510 .get(peer_id)
511 .ok_or(DirectoryError::PeerNotFound(*peer_id))?;
512 if peer.revoked {
513 return Ok(());
514 }
515 for grant in &peer.grants {
516 resolved.push(ResolvedDirectoryGrant {
517 source: DirectorySubject::Peer(*peer_id),
518 subject: DirectorySubject::Peer(*peer_id),
519 grant: grant.clone(),
520 });
521 }
522 }
523 DirectorySubject::Device { peer_id, device_id } => {
524 let peer = self
525 .peers
526 .get(peer_id)
527 .ok_or(DirectoryError::PeerNotFound(*peer_id))?;
528 if peer.revoked {
529 return Ok(());
530 }
531 if let Some(device) = peer.devices.get(device_id) {
532 if !device.revoked {
533 for grant in &device.grants {
534 resolved.push(ResolvedDirectoryGrant {
535 source: DirectorySubject::Device {
536 peer_id: *peer_id,
537 device_id: device_id.clone(),
538 },
539 subject: DirectorySubject::Device {
540 peer_id: *peer_id,
541 device_id: device_id.clone(),
542 },
543 grant: grant.clone(),
544 });
545 }
546 }
547 }
548 }
549 DirectorySubject::Group(group_name) => {
550 for grant in self.resolve_group_grants(group_name)? {
551 resolved.push(grant);
552 }
553 }
554 DirectorySubject::Relay(_) => {}
555 }
556 Ok(())
557 }
558
559 fn matching_subjects(&self, query: &str) -> Vec<DirectorySubject> {
560 let mut matches = Vec::new();
561 for peer in self.peers.values() {
562 if peer.revoked {
563 continue;
564 }
565 if peer.matches_name(query) {
566 matches.push(DirectorySubject::Peer(peer.peer_id));
567 }
568 for device in peer.devices.values() {
569 if !device.revoked && device.matches_name(query) {
570 matches.push(DirectorySubject::Device {
571 peer_id: peer.peer_id,
572 device_id: device.device_id.clone(),
573 });
574 }
575 }
576 }
577 for group in self.groups.values() {
578 if !group.revoked && group.matches_name(query) {
579 matches.push(DirectorySubject::Group(group.name.clone()));
580 }
581 }
582 matches.sort();
583 matches.dedup();
584 matches
585 }
586
587 fn audit(
588 &mut self,
589 actor: Option<PeerId>,
590 operation: DirectoryOperation,
591 target: DirectorySubject,
592 summary: String,
593 ) {
594 let sequence = self.next_sequence;
595 self.next_sequence = self.next_sequence.saturating_add(1);
596 self.audit_log.push(DirectoryAuditRecord {
597 sequence,
598 actor,
599 operation,
600 target,
601 timestamp_micros: now_micros(),
602 summary,
603 });
604 }
605
606 fn repair_sequence(&mut self) {
607 self.next_sequence = self
608 .audit_log
609 .iter()
610 .map(|record| record.sequence)
611 .max()
612 .unwrap_or(0)
613 .saturating_add(1);
614 }
615}
616
617#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
619pub struct PeerRecord {
620 pub peer_id: PeerId,
622 pub display_name: String,
624 pub aliases: BTreeSet<String>,
626 pub groups: BTreeSet<String>,
628 pub devices: BTreeMap<String, DeviceRecord>,
630 pub grants: Vec<DirectoryGrant>,
632 pub path_hints: Vec<PathHint>,
634 pub last_seen_micros: u64,
636 pub trust_notes: Vec<String>,
638 pub revoked: bool,
640}
641
642impl PeerRecord {
643 pub fn new(peer_id: PeerId, display_name: impl Into<String>) -> Result<Self, DirectoryError> {
645 Ok(Self {
646 peer_id,
647 display_name: normalize_name(display_name.into())?,
648 aliases: BTreeSet::new(),
649 groups: BTreeSet::new(),
650 devices: BTreeMap::new(),
651 grants: Vec::new(),
652 path_hints: Vec::new(),
653 last_seen_micros: now_micros(),
654 trust_notes: Vec::new(),
655 revoked: false,
656 })
657 }
658
659 fn matches_name(&self, query: &str) -> bool {
660 normalize_lookup(&self.display_name) == query
661 || self
662 .aliases
663 .iter()
664 .any(|alias| normalize_lookup(alias) == query)
665 }
666}
667
668#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
670pub struct DeviceRecord {
671 pub device_id: String,
673 pub peer_id: PeerId,
675 pub device_name: String,
677 pub aliases: BTreeSet<String>,
679 pub grants: Vec<DirectoryGrant>,
681 pub path_hints: Vec<PathHint>,
683 pub last_seen_micros: u64,
685 pub trust_scopes: BTreeSet<TrustScope>,
687 pub revoked: bool,
689}
690
691impl DeviceRecord {
692 pub fn new(
694 peer_id: PeerId,
695 device_id: impl Into<String>,
696 device_name: impl Into<String>,
697 ) -> Result<Self, DirectoryError> {
698 Ok(Self {
699 device_id: normalize_name(device_id.into())?,
700 peer_id,
701 device_name: normalize_name(device_name.into())?,
702 aliases: BTreeSet::new(),
703 grants: Vec::new(),
704 path_hints: Vec::new(),
705 last_seen_micros: now_micros(),
706 trust_scopes: BTreeSet::new(),
707 revoked: false,
708 })
709 }
710
711 fn matches_name(&self, query: &str) -> bool {
712 normalize_lookup(&self.device_name) == query
713 || normalize_lookup(&self.device_id) == query
714 || self
715 .aliases
716 .iter()
717 .any(|alias| normalize_lookup(alias) == query)
718 }
719}
720
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
723pub struct GroupRecord {
724 pub name: String,
726 pub display_name: String,
728 pub members: BTreeSet<DirectorySubject>,
730 pub grants: Vec<DirectoryGrant>,
732 pub trust_notes: Vec<String>,
734 pub revoked: bool,
736}
737
738impl GroupRecord {
739 pub fn new(
741 name: impl Into<String>,
742 display_name: impl Into<String>,
743 ) -> Result<Self, DirectoryError> {
744 Ok(Self {
745 name: normalize_name(name.into())?,
746 display_name: normalize_name(display_name.into())?,
747 members: BTreeSet::new(),
748 grants: Vec::new(),
749 trust_notes: Vec::new(),
750 revoked: false,
751 })
752 }
753
754 fn matches_name(&self, query: &str) -> bool {
755 normalize_lookup(&self.name) == query || normalize_lookup(&self.display_name) == query
756 }
757}
758
759#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
761pub enum DirectorySubject {
762 Peer(PeerId),
764 Device {
766 peer_id: PeerId,
768 device_id: String,
770 },
771 Group(String),
773 Relay(String),
775}
776
777impl DirectorySubject {
778 #[must_use]
780 pub fn display_label(&self) -> String {
781 match self {
782 Self::Peer(peer_id) => format!("peer:{}", peer_id.redacted()),
783 Self::Device { peer_id, device_id } => {
784 format!("device:{}:{device_id}", peer_id.redacted())
785 }
786 Self::Group(group) => format!("group:{group}"),
787 Self::Relay(relay) => format!("relay:{relay}"),
788 }
789 }
790}
791
792#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
794pub enum TrustScope {
795 Personal,
797 Team(String),
799 Device(String),
801 Relay(String),
803 Custom(String),
805}
806
807#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
809pub struct DirectoryGrant {
810 pub grant_id: String,
812 pub trust_scope: TrustScope,
814 pub actions: BTreeSet<String>,
816 pub constraints: BTreeMap<String, String>,
818 pub revoked: bool,
820}
821
822impl DirectoryGrant {
823 pub fn new<I, S>(
825 grant_id: impl Into<String>,
826 trust_scope: TrustScope,
827 actions: I,
828 ) -> Result<Self, DirectoryError>
829 where
830 I: IntoIterator<Item = S>,
831 S: Into<String>,
832 {
833 let grant_id = normalize_name(grant_id.into())?;
834 Ok(Self {
835 grant_id,
836 trust_scope,
837 actions: actions.into_iter().map(Into::into).collect(),
838 constraints: BTreeMap::new(),
839 revoked: false,
840 })
841 }
842}
843
844#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
846pub struct ResolvedDirectoryGrant {
847 pub source: DirectorySubject,
849 pub subject: DirectorySubject,
851 pub grant: DirectoryGrant,
853}
854
855#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
857pub struct PathHint {
858 pub kind: String,
860 pub endpoint: String,
862 pub last_seen_micros: u64,
864 pub expires_at_micros: u64,
866 pub trust_scope: Option<TrustScope>,
868}
869
870impl PathHint {
871 #[inline]
873 #[must_use]
874 pub const fn is_stale(&self, now_micros: u64) -> bool {
875 now_micros >= self.expires_at_micros
876 }
877}
878
879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
881pub struct StalePathHint {
882 pub subject: DirectorySubject,
884 pub hint: PathHint,
886}
887
888#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
890pub enum DirectoryOperation {
891 PeerAdded,
893 PeerUpdated,
895 PeerRenamed,
897 PeerRevoked,
899 DeviceUpdated,
901 DeviceRenamed,
903 GroupAdded,
905 GroupUpdated,
907 GroupMemberAdded,
909 GrantAttached,
911}
912
913#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
915pub struct DirectoryAuditRecord {
916 pub sequence: u64,
918 pub actor: Option<PeerId>,
920 pub operation: DirectoryOperation,
922 pub target: DirectorySubject,
924 pub timestamp_micros: u64,
926 pub summary: String,
928}
929
930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
932pub struct DirectoryList {
933 pub peers: Vec<DirectoryPeerSummary>,
935 pub groups: Vec<DirectoryGroupSummary>,
937}
938
939#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
941pub struct DirectoryPeerSummary {
942 pub peer_id: PeerId,
944 pub display_name: String,
946 pub groups: Vec<String>,
948 pub device_count: usize,
950 pub grant_count: usize,
952 pub last_seen_micros: u64,
954}
955
956#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
958pub struct DirectoryGroupSummary {
959 pub name: String,
961 pub display_name: String,
963 pub member_count: usize,
965 pub grant_count: usize,
967}
968
969#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
971#[serde(tag = "kind", content = "entry")]
972pub enum DirectoryEntryView {
973 Peer(PeerRecord),
975 Device(DeviceRecord),
977 Group(GroupRecord),
979}
980
981#[derive(Debug, thiserror::Error, PartialEq, Eq)]
983pub enum DirectoryError {
984 #[error("directory name is empty")]
986 EmptyName,
987 #[error("peer not found: {0:?}")]
989 PeerNotFound(PeerId),
990 #[error("device not found: {peer_id:?}/{device_id}")]
992 DeviceNotFound {
993 peer_id: PeerId,
995 device_id: String,
997 },
998 #[error("group not found: {0}")]
1000 GroupNotFound(String),
1001 #[error("device peer mismatch: expected {expected:?}, actual {actual:?}")]
1003 DevicePeerMismatch {
1004 expected: PeerId,
1006 actual: PeerId,
1008 },
1009 #[error("name not found: {0}")]
1011 NameNotFound(String),
1012 #[error("ambiguous name {query}: {matches:?}")]
1014 AmbiguousName {
1015 query: String,
1017 matches: Vec<String>,
1019 },
1020 #[error("subject is revoked: {0:?}")]
1022 RevokedSubject(DirectorySubject),
1023 #[error("unsupported directory subject: {0}")]
1025 UnsupportedSubject(String),
1026 #[error("invalid peer id hex")]
1028 InvalidPeerIdHex,
1029}
1030
1031#[derive(Debug, thiserror::Error)]
1033pub enum DirectoryIoError {
1034 #[error("failed to read directory: {0}")]
1036 Read(io::Error),
1037 #[error("failed to decode directory: {0}")]
1039 Decode(serde_json::Error),
1040 #[error("failed to encode directory: {0}")]
1042 Encode(serde_json::Error),
1043 #[error("failed to write directory: {0}")]
1045 Write(io::Error),
1046}
1047
1048pub fn peer_id_from_hex(hex_text: &str) -> Result<PeerId, DirectoryError> {
1050 let bytes = hex::decode(hex_text).map_err(|_| DirectoryError::InvalidPeerIdHex)?; let bytes: [u8; 32] = bytes
1052 .try_into()
1053 .map_err(|_| DirectoryError::InvalidPeerIdHex)?;
1054 Ok(PeerId::new(bytes))
1055}
1056
1057#[must_use]
1059pub fn peer_id_to_hex(peer_id: PeerId) -> String {
1060 hex::encode(peer_id.as_bytes())
1061}
1062
1063fn normalize_name(name: String) -> Result<String, DirectoryError> {
1064 let name = name.trim().to_string();
1065 if name.is_empty() {
1066 Err(DirectoryError::EmptyName)
1067 } else {
1068 Ok(name)
1069 }
1070}
1071
1072fn normalize_lookup(name: &str) -> String {
1073 name.trim().to_ascii_lowercase()
1074}
1075
1076fn now_micros() -> u64 {
1077 let micros = SystemTime::now()
1078 .duration_since(UNIX_EPOCH)
1079 .unwrap_or_default()
1080 .as_micros();
1081 u64::try_from(micros).unwrap_or(u64::MAX)
1082}
1083
1084mod peer_map_hex {
1085 use super::{PeerId, PeerRecord, peer_id_from_hex, peer_id_to_hex};
1086 use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
1087 use std::collections::BTreeMap;
1088
1089 pub fn serialize<S>(
1090 peers: &BTreeMap<PeerId, PeerRecord>,
1091 serializer: S,
1092 ) -> Result<S::Ok, S::Error>
1093 where
1094 S: Serializer,
1095 {
1096 let peers_by_hex: BTreeMap<String, &PeerRecord> = peers
1097 .iter()
1098 .map(|(peer_id, record)| (peer_id_to_hex(*peer_id), record))
1099 .collect();
1100 peers_by_hex.serialize(serializer)
1101 }
1102
1103 pub fn deserialize<'de, D>(deserializer: D) -> Result<BTreeMap<PeerId, PeerRecord>, D::Error>
1104 where
1105 D: Deserializer<'de>,
1106 {
1107 let peers_by_hex = BTreeMap::<String, PeerRecord>::deserialize(deserializer)?;
1108 let mut peers = BTreeMap::new();
1109 for (peer_id_hex, record) in peers_by_hex {
1110 let peer_id = peer_id_from_hex(&peer_id_hex).map_err(D::Error::custom)?;
1111 if record.peer_id != peer_id {
1112 return Err(D::Error::custom(format!(
1113 "peer map key does not match peer record id: {peer_id_hex}"
1114 )));
1115 }
1116 peers.insert(peer_id, record);
1117 }
1118 Ok(peers)
1119 }
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125 use tempfile::tempdir;
1126
1127 fn peer(label: &str) -> PeerId {
1128 PeerId::from_label(label)
1129 }
1130
1131 fn directory_with_alice() -> PeerDirectory {
1132 let mut directory = PeerDirectory::new();
1133 let alice_id = peer("alice");
1134 let mut alice = PeerRecord::new(alice_id, "Alice").expect("peer");
1135 alice.aliases.insert("alice@example".to_string());
1136 alice.path_hints.push(PathHint {
1137 kind: "lan".to_string(),
1138 endpoint: "192.168.1.10:4433".to_string(),
1139 last_seen_micros: 10,
1140 expires_at_micros: 20,
1141 trust_scope: Some(TrustScope::Personal),
1142 });
1143 directory.upsert_peer(alice, None);
1144 directory
1145 .upsert_device(
1146 alice_id,
1147 DeviceRecord::new(alice_id, "laptop", "gpu-box").expect("device"),
1148 None,
1149 )
1150 .expect("device");
1151 directory
1152 }
1153
1154 #[test]
1155 fn resolves_unique_peer_and_requires_disambiguation_for_ambiguous_names() {
1156 let mut directory = directory_with_alice();
1157 let bob_id = peer("bob");
1158 let bob = PeerRecord::new(bob_id, "gpu-box").expect("peer");
1159 directory.upsert_peer(bob, None);
1160
1161 assert_eq!(
1162 directory.resolve_name("alice").expect("alice"),
1163 DirectorySubject::Peer(peer("alice"))
1164 );
1165 let err = directory.resolve_name("gpu-box").expect_err("ambiguous");
1166 assert!(matches!(err, DirectoryError::AmbiguousName { .. }));
1167 }
1168
1169 #[test]
1170 fn renamed_device_keeps_old_name_as_alias() {
1171 let mut directory = directory_with_alice();
1172 let alice_id = peer("alice");
1173 directory
1174 .rename_device(alice_id, "laptop", "workstation", None)
1175 .expect("rename");
1176
1177 let subject = directory.resolve_name("gpu-box").expect("old alias");
1178 assert_eq!(
1179 subject,
1180 DirectorySubject::Device {
1181 peer_id: alice_id,
1182 device_id: "laptop".to_string()
1183 }
1184 );
1185 let view = directory.inspect(&subject).expect("inspect"); let DirectoryEntryView::Device(device) = view else {
1187 panic!("expected device"); };
1189 assert_eq!(device.device_name, "workstation");
1190 }
1191
1192 #[test]
1193 fn group_grants_preserve_constraints_for_members() {
1194 let mut directory = directory_with_alice();
1195 let alice_id = peer("alice");
1196 let mut grant = DirectoryGrant::new(
1197 "grant-team-read",
1198 TrustScope::Team("eng".to_string()),
1199 ["read"],
1200 )
1201 .expect("grant");
1202 grant
1203 .constraints
1204 .insert("max_bytes".to_string(), "1048576".to_string());
1205 directory.upsert_group(GroupRecord::new("eng", "Engineering").expect("group"), None);
1206 directory
1207 .add_group_member("eng", DirectorySubject::Peer(alice_id), None)
1208 .expect("member");
1209 directory
1210 .attach_grant(
1211 DirectorySubject::Group("eng".to_string()),
1212 grant.clone(),
1213 None,
1214 )
1215 .expect("grant");
1216
1217 let resolved = directory.resolve_group_grants("eng").expect("resolve");
1218 assert_eq!(resolved.len(), 1);
1219 assert_eq!(resolved[0].grant.constraints, grant.constraints);
1220 assert_eq!(
1221 resolved[0].grant.trust_scope,
1222 TrustScope::Team("eng".to_string())
1223 );
1224 }
1225
1226 #[test]
1227 fn revoked_peer_no_longer_resolves_and_is_audited() {
1228 let mut directory = directory_with_alice();
1229 directory
1230 .revoke_peer(DirectorySubject::Peer(peer("alice")), "lost key", None)
1231 .expect("revoke");
1232
1233 assert!(matches!(
1234 directory.resolve_name("alice"),
1235 Err(DirectoryError::NameNotFound(_))
1236 ));
1237 assert!(
1238 directory
1239 .audit_log
1240 .iter()
1241 .any(|record| record.operation == DirectoryOperation::PeerRevoked)
1242 );
1243 }
1244
1245 #[test]
1246 fn stale_path_hints_are_reported() {
1247 let directory = directory_with_alice();
1248 let stale = directory.stale_path_hints(21);
1249 assert_eq!(stale.len(), 1);
1250 assert_eq!(stale[0].hint.endpoint, "192.168.1.10:4433");
1251 }
1252
1253 #[test]
1254 fn directory_round_trips_as_json_with_audit_log() {
1255 let directory = directory_with_alice();
1256 let temp = tempdir().expect("tempdir");
1257 let path = temp.path().join("peers.json");
1258 directory.save_json(&path).expect("save");
1259 let loaded = PeerDirectory::load_json(&path).expect("load");
1260
1261 assert_eq!(loaded.schema_version, PEER_DIRECTORY_SCHEMA_V1);
1262 assert_eq!(loaded.peers.len(), 1);
1263 assert!(!loaded.audit_log.is_empty());
1264 }
1265}