1use crate::agent_card::{self, AgentCard};
31use anyhow::Result;
32use serde_json::{Value, json};
33use std::collections::BTreeMap;
34use std::path::Path;
35use time::OffsetDateTime;
36use time::format_description::well_known::Rfc3339;
37
38const FILE: &str = "blocklist.json";
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct BlockEntry {
43 pub at: String,
44 pub note: Option<String>,
45}
46
47#[derive(Debug, Clone, Default)]
49pub struct Blocklist {
50 blocked: BTreeMap<String, BlockEntry>,
51}
52
53impl Blocklist {
54 pub fn load() -> Self {
58 match crate::config::config_dir() {
59 Ok(dir) => Self::load_path(&dir.join(FILE)),
60 Err(_) => Self::default(),
61 }
62 }
63
64 pub fn load_path(path: &Path) -> Self {
66 let Ok(bytes) = std::fs::read(path) else {
67 return Self::default();
68 };
69 let Ok(json) = serde_json::from_slice::<Value>(&bytes) else {
70 eprintln!(
71 "wire: blocklist at {path:?} is malformed JSON — treating as empty \
72 (no peers blocked). Fix or remove the file to restore your blocks."
73 );
74 return Self::default();
75 };
76 let mut blocked = BTreeMap::new();
77 if let Some(map) = json.get("blocked").and_then(|v| v.as_object()) {
78 for (did, entry) in map {
79 let at = entry
80 .get("at")
81 .and_then(Value::as_str)
82 .unwrap_or_default()
83 .to_string();
84 let note = entry
85 .get("note")
86 .and_then(Value::as_str)
87 .map(str::to_string);
88 blocked.insert(did.clone(), BlockEntry { at, note });
89 }
90 }
91 Self { blocked }
92 }
93
94 pub fn block(&mut self, did: &str, note: Option<String>) -> bool {
97 match self.blocked.get_mut(did) {
98 Some(existing) => {
99 if note.is_some() {
100 existing.note = note;
101 }
102 false
103 }
104 None => {
105 self.blocked.insert(
106 did.to_string(),
107 BlockEntry {
108 at: now_iso(),
109 note,
110 },
111 );
112 true
113 }
114 }
115 }
116
117 pub fn unblock(&mut self, did: &str) -> bool {
119 self.blocked.remove(did).is_some()
120 }
121
122 pub fn is_blocked(&self, did: &str) -> bool {
124 self.blocked.contains_key(did)
125 }
126
127 pub fn blocks_card<'c>(&self, card: &'c AgentCard) -> Option<&'c str> {
131 let session_did = card.get("did").and_then(Value::as_str);
132 if let Some(d) = session_did
133 && self.is_blocked(d)
134 {
135 return Some(d);
136 }
137 if let Some(op_did) = agent_card::card_op_did(card)
138 && self.is_blocked(op_did)
139 {
140 return Some(op_did);
141 }
142 None
143 }
144
145 pub fn entries(&self) -> impl Iterator<Item = (&String, &BlockEntry)> {
147 self.blocked.iter()
148 }
149
150 pub fn len(&self) -> usize {
151 self.blocked.len()
152 }
153
154 pub fn is_empty(&self) -> bool {
155 self.blocked.is_empty()
156 }
157
158 pub fn save(&self) -> Result<()> {
160 let dir = crate::config::config_dir()?;
161 std::fs::create_dir_all(&dir)?;
162 self.save_path(&dir.join(FILE))?;
163 Ok(())
164 }
165
166 pub fn save_path(&self, path: &Path) -> std::io::Result<()> {
168 std::fs::write(path, self.to_json())
169 }
170
171 fn to_json(&self) -> String {
172 let blocked: serde_json::Map<String, Value> = self
173 .blocked
174 .iter()
175 .map(|(did, e)| {
176 let mut obj = json!({ "at": e.at });
177 if let Some(note) = &e.note {
178 obj["note"] = json!(note);
179 }
180 (did.clone(), obj)
181 })
182 .collect();
183 serde_json::to_string_pretty(&json!({ "version": 1, "blocked": blocked }))
184 .unwrap_or_else(|_| "{}".into())
185 }
186}
187
188fn now_iso() -> String {
189 OffsetDateTime::now_utc()
190 .format(&Rfc3339)
191 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use serde_json::json;
198
199 fn tmp(name: &str) -> std::path::PathBuf {
200 std::env::temp_dir().join(format!("wire-blocklist-{}-{name}.json", std::process::id()))
201 }
202
203 #[test]
204 fn missing_file_blocks_nobody() {
205 let p = tmp("missing");
206 let _ = std::fs::remove_file(&p);
207 let bl = Blocklist::load_path(&p);
208 assert!(bl.is_empty());
209 assert!(!bl.is_blocked("did:wire:anyone-deadbeef"));
210 }
211
212 #[test]
213 fn malformed_file_fails_safe_to_empty() {
214 let p = tmp("malformed");
215 std::fs::write(&p, b"not json {{{").unwrap();
216 let bl = Blocklist::load_path(&p);
217 assert!(bl.is_empty(), "malformed block-list must load empty");
218 let _ = std::fs::remove_file(&p);
219 }
220
221 #[test]
222 fn block_unblock_roundtrip_persists() {
223 let p = tmp("roundtrip");
224 let mut bl = Blocklist::default();
225 assert!(bl.block("did:wire:rogue-aabbccdd", Some("spammer".into())));
226 assert!(
227 !bl.block("did:wire:rogue-aabbccdd", None),
228 "second block of same DID is not newly-added"
229 );
230 bl.save_path(&p).unwrap();
231
232 let loaded = Blocklist::load_path(&p);
233 assert!(loaded.is_blocked("did:wire:rogue-aabbccdd"));
234 let (_, entry) = loaded.entries().next().unwrap();
235 assert_eq!(entry.note.as_deref(), Some("spammer"));
236 assert!(!entry.at.is_empty());
237 let _ = std::fs::remove_file(&p);
238 }
239
240 #[test]
241 fn unblock_reports_presence() {
242 let mut bl = Blocklist::default();
243 bl.block("did:wire:x-1", None);
244 assert!(bl.unblock("did:wire:x-1"));
245 assert!(!bl.unblock("did:wire:x-1"), "second unblock is a no-op");
246 assert!(!bl.is_blocked("did:wire:x-1"));
247 }
248
249 #[test]
250 fn blocks_card_matches_session_did() {
251 let mut bl = Blocklist::default();
252 bl.block("did:wire:peer-12345678", None);
253 let card = json!({"did": "did:wire:peer-12345678", "handle": "peer"});
254 assert_eq!(bl.blocks_card(&card), Some("did:wire:peer-12345678"));
255 }
256
257 #[test]
258 fn blocks_card_matches_op_did_across_sessions() {
259 let op = "did:wire:op:darby-0123456789abcdef0123456789abcdef";
262 let mut bl = Blocklist::default();
263 bl.block(op, Some("compromised operator".into()));
264 let card = json!({
265 "did": "did:wire:fresh-session-99887766",
266 "handle": "fresh-session",
267 "op_did": op,
268 });
269 assert_eq!(bl.blocks_card(&card), Some(op));
270 }
271
272 #[test]
273 fn blocks_card_none_for_unblocked_peer() {
274 let mut bl = Blocklist::default();
275 bl.block("did:wire:someone-else-aaaa1111", None);
276 let card = json!({
277 "did": "did:wire:innocent-bbbb2222",
278 "handle": "innocent",
279 "op_did": "did:wire:op:clean-ffffffffffffffffffffffffffffffff",
280 });
281 assert_eq!(bl.blocks_card(&card), None);
282 }
283}