1use crate::dm::DmCapabilities;
23use crate::identity::{AgentId, MachineId};
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26use std::sync::Mutex;
27use std::time::{Duration, Instant};
28
29pub const DM_CAPABILITY_TOPIC: &str = "x0x/caps/v1";
32
33const ADVERT_SIGN_DOMAIN: &[u8] = b"x0x-caps-v1";
35
36pub const ADVERT_PUBLISH_INTERVAL_SECS: u64 = 300;
38
39pub const ADVERT_CACHE_TTL_SECS: u64 = 900;
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct CapabilityAdvert {
52 pub protocol_version: u16,
54
55 pub agent_id: [u8; 32],
57
58 pub machine_id: [u8; 32],
62
63 pub created_at_unix_ms: u64,
65
66 pub capabilities: DmCapabilities,
68
69 pub signature: Vec<u8>,
71}
72
73impl CapabilityAdvert {
74 pub fn signed_bytes(&self) -> Result<Vec<u8>, postcard::Error> {
77 let caps_bytes = postcard::to_stdvec(&self.capabilities)?;
78 let mut out =
79 Vec::with_capacity(ADVERT_SIGN_DOMAIN.len() + 2 + 32 + 32 + 8 + caps_bytes.len());
80 out.extend_from_slice(ADVERT_SIGN_DOMAIN);
81 out.extend_from_slice(&self.protocol_version.to_be_bytes());
82 out.extend_from_slice(&self.agent_id);
83 out.extend_from_slice(&self.machine_id);
84 out.extend_from_slice(&self.created_at_unix_ms.to_be_bytes());
85 out.extend_from_slice(&caps_bytes);
86 Ok(out)
87 }
88}
89
90pub struct CapabilityStore {
96 inner: Mutex<HashMap<[u8; 32], CachedAdvert>>,
97 ttl: Duration,
98}
99
100struct CachedAdvert {
101 capabilities: DmCapabilities,
102 _machine_id: [u8; 32],
103 seen_at: Instant,
104}
105
106impl Default for CapabilityStore {
107 fn default() -> Self {
108 Self::new()
109 }
110}
111
112impl CapabilityStore {
113 #[must_use]
115 pub fn new() -> Self {
116 Self {
117 inner: Mutex::new(HashMap::new()),
118 ttl: Duration::from_secs(ADVERT_CACHE_TTL_SECS),
119 }
120 }
121
122 #[must_use]
124 pub fn with_ttl(ttl: Duration) -> Self {
125 Self {
126 inner: Mutex::new(HashMap::new()),
127 ttl,
128 }
129 }
130
131 pub fn lookup(&self, agent_id: &AgentId) -> Option<DmCapabilities> {
133 let Ok(mut inner) = self.inner.lock() else {
134 return None;
135 };
136 let now = Instant::now();
137 let entry = inner.get(agent_id.as_bytes())?;
138 if now.duration_since(entry.seen_at) > self.ttl {
139 inner.remove(agent_id.as_bytes());
140 return None;
141 }
142 Some(entry.capabilities.clone())
143 }
144
145 pub fn insert(&self, agent_id: AgentId, machine_id: MachineId, capabilities: DmCapabilities) {
147 let Ok(mut inner) = self.inner.lock() else {
148 return;
149 };
150 inner.insert(
151 *agent_id.as_bytes(),
152 CachedAdvert {
153 capabilities,
154 _machine_id: *machine_id.as_bytes(),
155 seen_at: Instant::now(),
156 },
157 );
158 }
159
160 pub fn len(&self) -> usize {
162 self.inner.lock().map(|g| g.len()).unwrap_or_default()
163 }
164
165 pub fn is_empty(&self) -> bool {
167 self.len() == 0
168 }
169}
170
171#[must_use]
174pub fn now_unix_ms() -> u64 {
175 std::time::SystemTime::now()
176 .duration_since(std::time::UNIX_EPOCH)
177 .map(|d| d.as_millis() as u64)
178 .unwrap_or_default()
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn capability_store_insert_and_lookup() {
187 let store = CapabilityStore::new();
188 let agent_id = AgentId([1u8; 32]);
189 let machine_id = MachineId([2u8; 32]);
190 let caps = DmCapabilities::v1_gossip_ready(vec![0u8; 1184]);
191 assert!(store.lookup(&agent_id).is_none());
192 store.insert(agent_id, machine_id, caps.clone());
193 let got = store.lookup(&agent_id).expect("hit");
194 assert_eq!(got.max_protocol_version, caps.max_protocol_version);
195 assert_eq!(got.gossip_inbox, caps.gossip_inbox);
196 }
197
198 #[test]
199 fn capability_store_expires_on_ttl() {
200 let store = CapabilityStore::with_ttl(Duration::from_millis(50));
201 let agent_id = AgentId([3u8; 32]);
202 let machine_id = MachineId([4u8; 32]);
203 store.insert(
204 agent_id,
205 machine_id,
206 DmCapabilities::v1_gossip_ready(vec![0u8; 1184]),
207 );
208 assert!(store.lookup(&agent_id).is_some());
209 std::thread::sleep(Duration::from_millis(100));
210 assert!(store.lookup(&agent_id).is_none());
211 }
212
213 #[test]
214 fn advert_signed_bytes_deterministic() {
215 let advert = CapabilityAdvert {
216 protocol_version: 1,
217 agent_id: [7u8; 32],
218 machine_id: [8u8; 32],
219 created_at_unix_ms: 1_234_567_890_000,
220 capabilities: DmCapabilities::v1_gossip_ready(vec![0u8; 1184]),
221 signature: vec![0u8; 64],
222 };
223 let a = advert.signed_bytes().expect("signed bytes");
224 let b = advert.signed_bytes().expect("signed bytes 2");
225 assert_eq!(a, b);
226 assert!(a.starts_with(ADVERT_SIGN_DOMAIN));
227 }
228}