1use std::collections::BTreeMap;
20use std::fmt;
21use std::path::Path;
22
23use anyhow::{Context, Result, bail};
24use serde::{Deserialize, Serialize};
25
26use crate::identity::AgentId;
27use crate::state::atomic_write;
28
29#[derive(Debug)]
30pub struct PeerConflict(String);
31
32impl fmt::Display for PeerConflict {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 f.write_str(&self.0)
35 }
36}
37impl std::error::Error for PeerConflict {}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
42struct RawPeer {
43 key: String,
44}
45
46#[derive(Debug, Clone)]
48pub struct Peer {
49 pub petname: String,
50 pub id: AgentId,
51}
52
53#[derive(Debug, Clone, Default)]
56pub struct Policy {
57 peers: Vec<Peer>,
58}
59
60impl Policy {
61 pub fn load(path: &Path) -> Result<Self> {
62 let raw = std::fs::read_to_string(path)
63 .with_context(|| format!("reading peers file {}", path.display()))?;
64 Self::parse(&raw)
65 }
66
67 pub fn parse(raw: &str) -> Result<Self> {
68 let map: BTreeMap<String, RawPeer> =
69 serde_json::from_str(raw).context("peers file is not valid policy JSON")?;
70 let mut peers = Vec::with_capacity(map.len());
71 for (petname, rp) in map {
72 let id = AgentId::from_b64(&rp.key)
73 .with_context(|| format!("peer '{petname}' has an invalid key"))?;
74 peers.push(Peer { petname, id });
75 }
76 for i in 0..peers.len() {
79 for j in (i + 1)..peers.len() {
80 if peers[i].id == peers[j].id {
81 bail!(
82 "peers '{}' and '{}' share the same key",
83 peers[i].petname,
84 peers[j].petname
85 );
86 }
87 }
88 }
89 Ok(Self { peers })
90 }
91
92 pub fn peer(&self, id: AgentId) -> Option<&Peer> {
94 self.peers.iter().find(|p| p.id == id)
95 }
96
97 pub fn resolve(&self, petname: &str) -> Result<AgentId> {
99 self.peers
100 .iter()
101 .find(|p| p.petname == petname)
102 .map(|p| p.id)
103 .with_context(|| format!("unknown peer '{petname}'"))
104 }
105
106 pub fn len(&self) -> usize {
107 self.peers.len()
108 }
109
110 pub fn is_empty(&self) -> bool {
111 self.peers.is_empty()
112 }
113
114 pub fn peers(&self) -> &[Peer] {
116 &self.peers
117 }
118
119 pub fn add(&mut self, petname: &str, key_b64: &str) -> Result<()> {
124 let id = AgentId::from_b64(key_b64)
125 .with_context(|| format!("peer '{petname}' has an invalid key"))?;
126 if let Some(other) = self
127 .peers
128 .iter()
129 .find(|p| p.id == id && p.petname != petname)
130 {
131 return Err(PeerConflict(format!(
132 "that key is already authorized as '{}'",
133 other.petname
134 ))
135 .into());
136 }
137 match self.peers.iter().find(|p| p.petname == petname) {
138 Some(existing) if existing.id == id => {}
142 Some(existing) => return Err(PeerConflict(format!(
143 "petname '{petname}' is already authorized under a different key ({}); remove it first to reassign", existing.id.to_b64()
144 )).into()),
145 None => self.peers.push(Peer {
146 petname: petname.to_string(),
147 id,
148 }),
149 }
150 Ok(())
151 }
152
153 pub fn remove(&mut self, petname: &str) -> bool {
155 let before = self.peers.len();
156 self.peers.retain(|p| p.petname != petname);
157 self.peers.len() != before
158 }
159
160 pub fn to_json(&self) -> Result<String> {
162 let map: BTreeMap<String, RawPeer> = self
163 .peers
164 .iter()
165 .map(|p| (p.petname.clone(), RawPeer { key: p.id.to_b64() }))
166 .collect();
167 serde_json::to_string_pretty(&map).context("serializing peers")
168 }
169
170 pub fn save(&self, path: &Path) -> Result<()> {
172 let mut json = self.to_json()?;
173 json.push('\n');
174 atomic_write(path, json.as_bytes())?;
175 Ok(())
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::identity::AgentKey;
183
184 fn policy_with_key(k: &AgentKey) -> Policy {
185 let raw = format!(r#"{{ "alice": {{ "key": "{}" }} }}"#, k.id().to_b64());
186 Policy::parse(&raw).unwrap()
187 }
188
189 #[test]
190 fn listed_key_resolves_to_its_petname() {
191 let k = AgentKey::generate().unwrap();
192 let p = policy_with_key(&k);
193 assert_eq!(p.peer(k.id()).unwrap().petname, "alice");
194 }
195
196 #[test]
197 fn legacy_may_field_is_ignored() {
198 let k = AgentKey::generate().unwrap();
200 let raw = format!(
201 r#"{{ "alice": {{ "key": "{}", "may": "*" }} }}"#,
202 k.id().to_b64()
203 );
204 let p = Policy::parse(&raw).unwrap();
205 assert_eq!(p.peer(k.id()).unwrap().petname, "alice");
206 }
207
208 #[test]
209 fn unlisted_sender_is_denied() {
210 let k = AgentKey::generate().unwrap();
211 let p = policy_with_key(&k);
212 let stranger = AgentKey::generate().unwrap();
213 assert!(
214 p.peer(stranger.id()).is_none(),
215 "unlisted key must not resolve"
216 );
217 }
218
219 #[test]
220 fn invalid_key_fails_at_parse_time() {
221 let raw = r#"{ "alice": { "key": "not-a-real-key" } }"#;
222 assert!(Policy::parse(raw).is_err());
223 }
224
225 #[test]
226 fn duplicate_keys_are_rejected() {
227 let k = AgentKey::generate().unwrap();
228 let raw = format!(
229 r#"{{ "a": {{ "key": "{0}" }}, "b": {{ "key": "{0}" }} }}"#,
230 k.id().to_b64()
231 );
232 assert!(Policy::parse(&raw).is_err());
233 }
234
235 #[test]
236 fn resolve_maps_petname_to_key() {
237 let k = AgentKey::generate().unwrap();
238 let p = policy_with_key(&k);
239 assert_eq!(p.resolve("alice").unwrap(), k.id());
240 assert!(p.resolve("nobody").is_err());
241 }
242
243 #[test]
244 fn add_then_resolve_and_persist_round_trip() {
245 let k = AgentKey::generate().unwrap();
246 let mut p = Policy::default();
247 p.add("desktop", &k.id().to_b64()).unwrap();
248 assert_eq!(p.resolve("desktop").unwrap(), k.id());
249 let reparsed = Policy::parse(&p.to_json().unwrap()).unwrap();
250 assert_eq!(reparsed.resolve("desktop").unwrap(), k.id());
251 }
252
253 #[test]
254 fn add_same_petname_is_idempotent() {
255 let k = AgentKey::generate().unwrap();
256 let mut p = Policy::default();
257 p.add("bot", &k.id().to_b64()).unwrap();
258 p.add("bot", &k.id().to_b64()).unwrap();
259 assert_eq!(p.len(), 1, "same petname does not duplicate");
260 }
261
262 #[test]
263 fn add_refuses_to_rekey_an_existing_petname() {
264 let old = AgentKey::generate().unwrap();
266 let attacker = AgentKey::generate().unwrap();
267 let mut p = Policy::default();
268 p.add("laptop", &old.id().to_b64()).unwrap();
269 assert!(
270 p.add("laptop", &attacker.id().to_b64()).is_err(),
271 "an existing petname must not be silently rekeyed"
272 );
273 assert_eq!(
274 p.resolve("laptop").unwrap(),
275 old.id(),
276 "the original binding is preserved"
277 );
278 }
279
280 #[test]
281 fn add_rejects_key_under_a_second_petname() {
282 let k = AgentKey::generate().unwrap();
283 let mut p = Policy::default();
284 p.add("first", &k.id().to_b64()).unwrap();
285 assert!(
286 p.add("second", &k.id().to_b64()).is_err(),
287 "one key must not get two petnames"
288 );
289 }
290
291 #[test]
292 fn add_rejects_invalid_key() {
293 let mut p = Policy::default();
294 assert!(p.add("x", "not-a-key").is_err());
295 }
296
297 #[test]
298 fn remove_reports_whether_it_removed() {
299 let k = AgentKey::generate().unwrap();
300 let mut p = Policy::default();
301 p.add("gone", &k.id().to_b64()).unwrap();
302 assert!(p.remove("gone"));
303 assert!(!p.remove("gone"), "already absent");
304 assert!(p.is_empty());
305 }
306}