Skip to main content

asupersync/atp/directory/
mod.rs

1//! Local-first ATP peer directory.
2//!
3//! The directory maps durable peer identities to human names, device names,
4//! groups, grants, trust scopes, and path hints. It deliberately treats names
5//! as convenience labels: ambiguous labels never resolve implicitly, and every
6//! mutating operation records an audit entry.
7
8use 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
16/// Stable schema marker for exported peer directories.
17pub const PEER_DIRECTORY_SCHEMA_V1: &str = "asupersync.atp.peer_directory.v1";
18
19/// Local-first peer directory.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct PeerDirectory {
22    /// Export/import schema version.
23    pub schema_version: String,
24    /// Peers indexed by cryptographic id.
25    #[serde(with = "peer_map_hex")]
26    pub peers: BTreeMap<PeerId, PeerRecord>,
27    /// Teams and local groups.
28    pub groups: BTreeMap<String, GroupRecord>,
29    /// Auditable change log.
30    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    /// Create an empty directory.
42    #[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    /// Load a directory JSON document.
54    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    /// Save a directory JSON document.
63    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    /// Insert or replace a peer record.
69    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    /// Rename a peer display name and keep the old name as an alias.
82    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    /// Add or replace a device under a peer.
108    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    /// Rename a device under a peer.
137    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    /// Revoke a peer and all of its devices for future name resolution.
173    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    /// Insert or replace a group.
200    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    /// Add one peer/device/group subject to a group.
217    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    /// Attach a grant to a peer, device, or group without dropping constraints.
239    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    /// Resolve grants applying to a group and its members.
288    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    /// Resolve a human label to exactly one active subject.
317    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    /// Inspect one explicit or name-resolved subject.
334    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    /// List active peers and groups for CLI output.
373    #[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    /// Return stale path hints for operational diagnostics.
407    #[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/// One person, relay, or durable peer identity.
618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
619pub struct PeerRecord {
620    /// Durable cryptographic peer id.
621    pub peer_id: PeerId,
622    /// Human display name.
623    pub display_name: String,
624    /// Optional aliases.
625    pub aliases: BTreeSet<String>,
626    /// Groups this peer belongs to.
627    pub groups: BTreeSet<String>,
628    /// Devices owned by this peer.
629    pub devices: BTreeMap<String, DeviceRecord>,
630    /// Direct grants attached to this peer.
631    pub grants: Vec<DirectoryGrant>,
632    /// Path hints for this peer.
633    pub path_hints: Vec<PathHint>,
634    /// Last observed activity timestamp.
635    pub last_seen_micros: u64,
636    /// Operator trust notes.
637    pub trust_notes: Vec<String>,
638    /// Revoked peers do not resolve by name.
639    pub revoked: bool,
640}
641
642impl PeerRecord {
643    /// Construct a peer record.
644    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/// One named device under a peer.
669#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
670pub struct DeviceRecord {
671    /// Stable local device id.
672    pub device_id: String,
673    /// Owning peer.
674    pub peer_id: PeerId,
675    /// Human device name.
676    pub device_name: String,
677    /// Optional aliases.
678    pub aliases: BTreeSet<String>,
679    /// Direct grants attached to this device.
680    pub grants: Vec<DirectoryGrant>,
681    /// Device-specific path hints.
682    pub path_hints: Vec<PathHint>,
683    /// Last seen timestamp.
684    pub last_seen_micros: u64,
685    /// Trust scopes accepted for this device.
686    pub trust_scopes: BTreeSet<TrustScope>,
687    /// Revoked devices do not resolve by name.
688    pub revoked: bool,
689}
690
691impl DeviceRecord {
692    /// Construct a device record.
693    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/// Team or group of peers/devices.
722#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
723pub struct GroupRecord {
724    /// Stable group name.
725    pub name: String,
726    /// Human display name.
727    pub display_name: String,
728    /// Members can be peers, devices, or nested groups.
729    pub members: BTreeSet<DirectorySubject>,
730    /// Grants attached to the group.
731    pub grants: Vec<DirectoryGrant>,
732    /// Operator notes.
733    pub trust_notes: Vec<String>,
734    /// Revoked groups do not resolve by name.
735    pub revoked: bool,
736}
737
738impl GroupRecord {
739    /// Construct a group record.
740    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/// Directory subject address.
760#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
761pub enum DirectorySubject {
762    /// Peer by durable id.
763    Peer(PeerId),
764    /// Device by owning peer and stable device id.
765    Device {
766        /// Owning peer.
767        peer_id: PeerId,
768        /// Stable device id.
769        device_id: String,
770    },
771    /// Group or team name.
772    Group(String),
773    /// Relay name.
774    Relay(String),
775}
776
777impl DirectorySubject {
778    /// Human-safe label for diagnostics.
779    #[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/// Trust scope carried by a peer, device, or grant.
793#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
794pub enum TrustScope {
795    /// Personal peer-to-peer transfer scope.
796    Personal,
797    /// Team/group scope.
798    Team(String),
799    /// Device-specific scope.
800    Device(String),
801    /// Relay or rendezvous scope.
802    Relay(String),
803    /// Custom local scope.
804    Custom(String),
805}
806
807/// Directory grant with explicit constraints preserved.
808#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
809pub struct DirectoryGrant {
810    /// Stable grant id.
811    pub grant_id: String,
812    /// Scope this grant applies to.
813    pub trust_scope: TrustScope,
814    /// Allowed action labels.
815    pub actions: BTreeSet<String>,
816    /// Capability constraints serialized as local-first key/value metadata.
817    pub constraints: BTreeMap<String, String>,
818    /// Revoked grants are retained for auditability.
819    pub revoked: bool,
820}
821
822impl DirectoryGrant {
823    /// Construct a grant.
824    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/// Resolved grant plus its source.
845#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
846pub struct ResolvedDirectoryGrant {
847    /// Subject the grant came from.
848    pub source: DirectorySubject,
849    /// Subject the grant applies to.
850    pub subject: DirectorySubject,
851    /// Grant data with constraints preserved.
852    pub grant: DirectoryGrant,
853}
854
855/// Directory path hint.
856#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
857pub struct PathHint {
858    /// Route kind, for example `lan`, `relay`, or `tailscale`.
859    pub kind: String,
860    /// Endpoint or rendezvous hint.
861    pub endpoint: String,
862    /// When this hint was last seen.
863    pub last_seen_micros: u64,
864    /// Hint expiry time.
865    pub expires_at_micros: u64,
866    /// Optional trust scope expected by this route.
867    pub trust_scope: Option<TrustScope>,
868}
869
870impl PathHint {
871    /// Return true if this hint is stale at `now_micros`.
872    #[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/// Stale path hint diagnostic.
880#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
881pub struct StalePathHint {
882    /// Subject owning the stale hint.
883    pub subject: DirectorySubject,
884    /// Stale hint.
885    pub hint: PathHint,
886}
887
888/// Directory audit operation.
889#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
890pub enum DirectoryOperation {
891    /// Peer added.
892    PeerAdded,
893    /// Peer updated.
894    PeerUpdated,
895    /// Peer renamed.
896    PeerRenamed,
897    /// Peer revoked.
898    PeerRevoked,
899    /// Device updated.
900    DeviceUpdated,
901    /// Device renamed.
902    DeviceRenamed,
903    /// Group added.
904    GroupAdded,
905    /// Group updated.
906    GroupUpdated,
907    /// Group member added.
908    GroupMemberAdded,
909    /// Grant attached.
910    GrantAttached,
911}
912
913/// One auditable directory mutation.
914#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
915pub struct DirectoryAuditRecord {
916    /// Monotonic local sequence.
917    pub sequence: u64,
918    /// Actor peer if known.
919    pub actor: Option<PeerId>,
920    /// Operation performed.
921    pub operation: DirectoryOperation,
922    /// Target subject.
923    pub target: DirectorySubject,
924    /// Wall-clock timestamp in microseconds since epoch.
925    pub timestamp_micros: u64,
926    /// Human-readable summary without secret material.
927    pub summary: String,
928}
929
930/// CLI list payload.
931#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
932pub struct DirectoryList {
933    /// Active peers.
934    pub peers: Vec<DirectoryPeerSummary>,
935    /// Active groups.
936    pub groups: Vec<DirectoryGroupSummary>,
937}
938
939/// Peer list row.
940#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
941pub struct DirectoryPeerSummary {
942    /// Peer id.
943    pub peer_id: PeerId,
944    /// Display name.
945    pub display_name: String,
946    /// Groups.
947    pub groups: Vec<String>,
948    /// Active device count.
949    pub device_count: usize,
950    /// Direct grant count.
951    pub grant_count: usize,
952    /// Last seen timestamp.
953    pub last_seen_micros: u64,
954}
955
956/// Group list row.
957#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
958pub struct DirectoryGroupSummary {
959    /// Group name.
960    pub name: String,
961    /// Display name.
962    pub display_name: String,
963    /// Member count.
964    pub member_count: usize,
965    /// Grant count.
966    pub grant_count: usize,
967}
968
969/// Inspect payload.
970#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
971#[serde(tag = "kind", content = "entry")]
972pub enum DirectoryEntryView {
973    /// Peer view.
974    Peer(PeerRecord),
975    /// Device view.
976    Device(DeviceRecord),
977    /// Group view.
978    Group(GroupRecord),
979}
980
981/// Directory model errors.
982#[derive(Debug, thiserror::Error, PartialEq, Eq)]
983pub enum DirectoryError {
984    /// Name was blank.
985    #[error("directory name is empty")]
986    EmptyName,
987    /// Peer not found.
988    #[error("peer not found: {0:?}")]
989    PeerNotFound(PeerId),
990    /// Device not found.
991    #[error("device not found: {peer_id:?}/{device_id}")]
992    DeviceNotFound {
993        /// Owning peer.
994        peer_id: PeerId,
995        /// Device id.
996        device_id: String,
997    },
998    /// Group not found.
999    #[error("group not found: {0}")]
1000    GroupNotFound(String),
1001    /// Device owned by a different peer.
1002    #[error("device peer mismatch: expected {expected:?}, actual {actual:?}")]
1003    DevicePeerMismatch {
1004        /// Expected peer.
1005        expected: PeerId,
1006        /// Actual peer.
1007        actual: PeerId,
1008    },
1009    /// Human name matched no active subject.
1010    #[error("name not found: {0}")]
1011    NameNotFound(String),
1012    /// Human name matched multiple active subjects.
1013    #[error("ambiguous name {query}: {matches:?}")]
1014    AmbiguousName {
1015        /// Queried name.
1016        query: String,
1017        /// Matching subjects.
1018        matches: Vec<String>,
1019    },
1020    /// Subject is revoked.
1021    #[error("subject is revoked: {0:?}")]
1022    RevokedSubject(DirectorySubject),
1023    /// Subject type is not supported for this operation.
1024    #[error("unsupported directory subject: {0}")]
1025    UnsupportedSubject(String),
1026    /// Peer id hex is invalid.
1027    #[error("invalid peer id hex")]
1028    InvalidPeerIdHex,
1029}
1030
1031/// Directory persistence errors.
1032#[derive(Debug, thiserror::Error)]
1033pub enum DirectoryIoError {
1034    /// Read failed.
1035    #[error("failed to read directory: {0}")]
1036    Read(io::Error),
1037    /// Decode failed.
1038    #[error("failed to decode directory: {0}")]
1039    Decode(serde_json::Error),
1040    /// Encode failed.
1041    #[error("failed to encode directory: {0}")]
1042    Encode(serde_json::Error),
1043    /// Write failed.
1044    #[error("failed to write directory: {0}")]
1045    Write(io::Error),
1046}
1047
1048/// Parse a full 32-byte peer id hex string.
1049pub fn peer_id_from_hex(hex_text: &str) -> Result<PeerId, DirectoryError> {
1050    let bytes = hex::decode(hex_text).map_err(|_| DirectoryError::InvalidPeerIdHex)?; // ubs:ignore - hex decoding, not JWT decoding
1051    let bytes: [u8; 32] = bytes
1052        .try_into()
1053        .map_err(|_| DirectoryError::InvalidPeerIdHex)?;
1054    Ok(PeerId::new(bytes))
1055}
1056
1057/// Return lowercase peer id hex.
1058#[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"); // ubs:ignore - test oracle
1186        let DirectoryEntryView::Device(device) = view else {
1187            panic!("expected device"); // ubs:ignore - test oracle
1188        };
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}