1use crate::pair_decision::{InboundMode, OrgPolicy};
19use anyhow::Result;
20use serde_json::json;
21use std::collections::HashMap;
22use std::path::Path;
23
24const FILE: &str = "org_policies.json";
25
26#[derive(Debug, Clone, Default)]
29pub struct FileOrgPolicy {
30 orgs: HashMap<String, InboundMode>,
31}
32
33impl FileOrgPolicy {
34 pub fn load() -> Self {
37 match crate::config::config_dir() {
38 Ok(dir) => Self::load_path(&dir.join(FILE)),
39 Err(_) => Self::default(),
40 }
41 }
42
43 pub fn load_path(path: &Path) -> Self {
45 let Ok(bytes) = std::fs::read(path) else {
46 return Self::default();
47 };
48 let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
49 return Self::default();
50 };
51 let mut orgs = HashMap::new();
52 if let Some(map) = json.get("orgs").and_then(|v| v.as_object()) {
53 for (org_did, entry) in map {
54 if let Some(mode) = entry
55 .get("inbound")
56 .and_then(|v| v.as_str())
57 .and_then(parse_mode)
58 {
59 orgs.insert(org_did.clone(), mode);
60 }
61 }
62 }
63 Self { orgs }
64 }
65
66 pub fn set(&mut self, org_did: &str, mode: InboundMode) {
68 self.orgs.insert(org_did.to_string(), mode);
69 }
70
71 pub fn remove(&mut self, org_did: &str) {
73 self.orgs.remove(org_did);
74 }
75
76 pub fn len(&self) -> usize {
78 self.orgs.len()
79 }
80
81 pub fn entries(&self) -> impl Iterator<Item = (&String, &InboundMode)> {
84 self.orgs.iter()
85 }
86
87 pub fn is_empty(&self) -> bool {
88 self.orgs.is_empty()
89 }
90
91 pub fn save(&self) -> Result<()> {
93 let dir = crate::config::config_dir()?;
94 std::fs::create_dir_all(&dir)?;
95 self.save_path(&dir.join(FILE))?;
96 Ok(())
97 }
98
99 pub fn save_path(&self, path: &Path) -> std::io::Result<()> {
101 std::fs::write(path, self.to_json())
102 }
103
104 fn to_json(&self) -> String {
105 let orgs: serde_json::Map<String, serde_json::Value> = self
106 .orgs
107 .iter()
108 .map(|(k, v)| (k.clone(), json!({ "inbound": mode_str(*v) })))
109 .collect();
110 serde_json::to_string_pretty(&json!({ "version": 1, "orgs": orgs }))
111 .unwrap_or_else(|_| "{}".into())
112 }
113}
114
115impl OrgPolicy for FileOrgPolicy {
116 fn inbound_mode(&self, org_did: &str) -> Option<InboundMode> {
117 self.orgs.get(org_did).copied()
118 }
119}
120
121fn parse_mode(s: &str) -> Option<InboundMode> {
122 match s {
123 "auto" => Some(InboundMode::Auto),
124 "notify" => Some(InboundMode::Notify),
125 _ => None,
126 }
127}
128
129fn mode_str(m: InboundMode) -> &'static str {
130 match m {
131 InboundMode::Auto => "auto",
132 InboundMode::Notify => "notify",
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 fn tmp(name: &str) -> std::path::PathBuf {
141 std::env::temp_dir().join(format!("wire-orgpol-{}-{name}.json", std::process::id()))
142 }
143
144 #[test]
145 fn missing_file_is_default_deny() {
146 let p = tmp("missing");
147 let _ = std::fs::remove_file(&p);
148 let pol = FileOrgPolicy::load_path(&p);
149 assert!(pol.is_empty());
150 assert_eq!(pol.inbound_mode("did:wire:org:slanchaai-1"), None);
151 }
152
153 #[test]
154 fn malformed_file_is_default_deny() {
155 let p = tmp("malformed");
156 std::fs::write(&p, b"not json {{{").unwrap();
157 let pol = FileOrgPolicy::load_path(&p);
158 assert!(pol.is_empty(), "malformed policy must fail closed to empty");
159 let _ = std::fs::remove_file(&p);
160 }
161
162 #[test]
163 fn set_save_load_roundtrip() {
164 let p = tmp("roundtrip");
165 let mut pol = FileOrgPolicy::default();
166 pol.set("did:wire:org:slanchaai-1", InboundMode::Auto);
167 pol.set("did:wire:org:contractor-2", InboundMode::Notify);
168 pol.save_path(&p).unwrap();
169
170 let loaded = FileOrgPolicy::load_path(&p);
171 assert_eq!(
172 loaded.inbound_mode("did:wire:org:slanchaai-1"),
173 Some(InboundMode::Auto)
174 );
175 assert_eq!(
176 loaded.inbound_mode("did:wire:org:contractor-2"),
177 Some(InboundMode::Notify)
178 );
179 assert_eq!(loaded.inbound_mode("did:wire:org:unknown-9"), None);
180 let _ = std::fs::remove_file(&p);
181 }
182
183 #[test]
184 fn unknown_mode_string_is_skipped() {
185 let p = tmp("badmode");
186 std::fs::write(
187 &p,
188 br#"{"version":1,"orgs":{"did:wire:org:x-1":{"inbound":"superuser"}}}"#,
189 )
190 .unwrap();
191 let pol = FileOrgPolicy::load_path(&p);
192 assert_eq!(pol.inbound_mode("did:wire:org:x-1"), None);
193 let _ = std::fs::remove_file(&p);
194 }
195
196 #[test]
197 fn remove_drops_org() {
198 let mut pol = FileOrgPolicy::default();
199 pol.set("did:wire:org:x-1", InboundMode::Auto);
200 pol.remove("did:wire:org:x-1");
201 assert_eq!(pol.inbound_mode("did:wire:org:x-1"), None);
202 }
203}