1use std::collections::BTreeMap;
20use std::path::Path;
21
22use anyhow::{Context, Result, bail};
23use serde::{Deserialize, Serialize};
24
25use crate::identity::AgentId;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
30struct RawPeer {
31 key: String,
32}
33
34#[derive(Debug, Clone)]
36pub struct Peer {
37 pub petname: String,
38 pub id: AgentId,
39}
40
41#[derive(Debug, Clone, Default)]
44pub struct Policy {
45 peers: Vec<Peer>,
46}
47
48impl Policy {
49 pub fn load(path: &Path) -> Result<Self> {
50 let raw = std::fs::read_to_string(path)
51 .with_context(|| format!("reading peers file {}", path.display()))?;
52 Self::parse(&raw)
53 }
54
55 pub fn parse(raw: &str) -> Result<Self> {
56 let map: BTreeMap<String, RawPeer> =
57 serde_json::from_str(raw).context("peers file is not valid policy JSON")?;
58 let mut peers = Vec::with_capacity(map.len());
59 for (petname, rp) in map {
60 let id = AgentId::from_b64(&rp.key)
61 .with_context(|| format!("peer '{petname}' has an invalid key"))?;
62 peers.push(Peer { petname, id });
63 }
64 for i in 0..peers.len() {
67 for j in (i + 1)..peers.len() {
68 if peers[i].id == peers[j].id {
69 bail!(
70 "peers '{}' and '{}' share the same key",
71 peers[i].petname,
72 peers[j].petname
73 );
74 }
75 }
76 }
77 Ok(Self { peers })
78 }
79
80 pub fn peer(&self, id: AgentId) -> Option<&Peer> {
82 self.peers.iter().find(|p| p.id == id)
83 }
84
85 pub fn resolve(&self, petname: &str) -> Result<AgentId> {
87 self.peers
88 .iter()
89 .find(|p| p.petname == petname)
90 .map(|p| p.id)
91 .with_context(|| format!("unknown peer '{petname}'"))
92 }
93
94 pub fn len(&self) -> usize {
95 self.peers.len()
96 }
97
98 pub fn is_empty(&self) -> bool {
99 self.peers.is_empty()
100 }
101
102 pub fn peers(&self) -> &[Peer] {
104 &self.peers
105 }
106
107 pub fn add(&mut self, petname: &str, key_b64: &str) -> Result<()> {
112 let id = AgentId::from_b64(key_b64)
113 .with_context(|| format!("peer '{petname}' has an invalid key"))?;
114 if let Some(other) = self
115 .peers
116 .iter()
117 .find(|p| p.id == id && p.petname != petname)
118 {
119 bail!("that key is already authorized as '{}'", other.petname);
120 }
121 match self.peers.iter_mut().find(|p| p.petname == petname) {
122 Some(existing) => existing.id = id,
123 None => self.peers.push(Peer {
124 petname: petname.to_string(),
125 id,
126 }),
127 }
128 Ok(())
129 }
130
131 pub fn remove(&mut self, petname: &str) -> bool {
133 let before = self.peers.len();
134 self.peers.retain(|p| p.petname != petname);
135 self.peers.len() != before
136 }
137
138 pub fn to_json(&self) -> Result<String> {
140 let map: BTreeMap<String, RawPeer> = self
141 .peers
142 .iter()
143 .map(|p| (p.petname.clone(), RawPeer { key: p.id.to_b64() }))
144 .collect();
145 serde_json::to_string_pretty(&map).context("serializing peers")
146 }
147
148 pub fn save(&self, path: &Path) -> Result<()> {
150 let mut json = self.to_json()?;
151 json.push('\n');
152 std::fs::write(path, json).with_context(|| format!("writing {}", path.display()))?;
153 Ok(())
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::identity::AgentKey;
161
162 fn policy_with_key(k: &AgentKey) -> Policy {
163 let raw = format!(r#"{{ "alice": {{ "key": "{}" }} }}"#, k.id().to_b64());
164 Policy::parse(&raw).unwrap()
165 }
166
167 #[test]
168 fn listed_key_resolves_to_its_petname() {
169 let k = AgentKey::generate().unwrap();
170 let p = policy_with_key(&k);
171 assert_eq!(p.peer(k.id()).unwrap().petname, "alice");
172 }
173
174 #[test]
175 fn legacy_may_field_is_ignored() {
176 let k = AgentKey::generate().unwrap();
178 let raw = format!(
179 r#"{{ "alice": {{ "key": "{}", "may": "*" }} }}"#,
180 k.id().to_b64()
181 );
182 let p = Policy::parse(&raw).unwrap();
183 assert_eq!(p.peer(k.id()).unwrap().petname, "alice");
184 }
185
186 #[test]
187 fn unlisted_sender_is_denied() {
188 let k = AgentKey::generate().unwrap();
189 let p = policy_with_key(&k);
190 let stranger = AgentKey::generate().unwrap();
191 assert!(
192 p.peer(stranger.id()).is_none(),
193 "unlisted key must not resolve"
194 );
195 }
196
197 #[test]
198 fn invalid_key_fails_at_parse_time() {
199 let raw = r#"{ "alice": { "key": "not-a-real-key" } }"#;
200 assert!(Policy::parse(raw).is_err());
201 }
202
203 #[test]
204 fn duplicate_keys_are_rejected() {
205 let k = AgentKey::generate().unwrap();
206 let raw = format!(
207 r#"{{ "a": {{ "key": "{0}" }}, "b": {{ "key": "{0}" }} }}"#,
208 k.id().to_b64()
209 );
210 assert!(Policy::parse(&raw).is_err());
211 }
212
213 #[test]
214 fn resolve_maps_petname_to_key() {
215 let k = AgentKey::generate().unwrap();
216 let p = policy_with_key(&k);
217 assert_eq!(p.resolve("alice").unwrap(), k.id());
218 assert!(p.resolve("nobody").is_err());
219 }
220
221 #[test]
222 fn add_then_resolve_and_persist_round_trip() {
223 let k = AgentKey::generate().unwrap();
224 let mut p = Policy::default();
225 p.add("desktop", &k.id().to_b64()).unwrap();
226 assert_eq!(p.resolve("desktop").unwrap(), k.id());
227 let reparsed = Policy::parse(&p.to_json().unwrap()).unwrap();
228 assert_eq!(reparsed.resolve("desktop").unwrap(), k.id());
229 }
230
231 #[test]
232 fn add_same_petname_is_idempotent() {
233 let k = AgentKey::generate().unwrap();
234 let mut p = Policy::default();
235 p.add("bot", &k.id().to_b64()).unwrap();
236 p.add("bot", &k.id().to_b64()).unwrap();
237 assert_eq!(p.len(), 1, "same petname does not duplicate");
238 }
239
240 #[test]
241 fn add_rejects_key_under_a_second_petname() {
242 let k = AgentKey::generate().unwrap();
243 let mut p = Policy::default();
244 p.add("first", &k.id().to_b64()).unwrap();
245 assert!(
246 p.add("second", &k.id().to_b64()).is_err(),
247 "one key must not get two petnames"
248 );
249 }
250
251 #[test]
252 fn add_rejects_invalid_key() {
253 let mut p = Policy::default();
254 assert!(p.add("x", "not-a-key").is_err());
255 }
256
257 #[test]
258 fn remove_reports_whether_it_removed() {
259 let k = AgentKey::generate().unwrap();
260 let mut p = Policy::default();
261 p.add("gone", &k.id().to_b64()).unwrap();
262 assert!(p.remove("gone"));
263 assert!(!p.remove("gone"), "already absent");
264 assert!(p.is_empty());
265 }
266}