1use ows_core::Policy;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::error::OwsLibError;
6use crate::vault;
7
8pub fn policies_dir(vault_path: Option<&Path>) -> Result<PathBuf, OwsLibError> {
11 let base = vault::resolve_vault_path(vault_path);
12 let dir = base.join("policies");
13 fs::create_dir_all(&dir)?;
14 Ok(dir)
15}
16
17pub fn save_policy(policy: &Policy, vault_path: Option<&Path>) -> Result<(), OwsLibError> {
19 let dir = policies_dir(vault_path)?;
20 let path = dir.join(format!("{}.json", policy.id));
21 let json = serde_json::to_string_pretty(policy)?;
22 fs::write(&path, json)?;
23 Ok(())
24}
25
26pub fn load_policy(id: &str, vault_path: Option<&Path>) -> Result<Policy, OwsLibError> {
28 let dir = policies_dir(vault_path)?;
29 let path = dir.join(format!("{id}.json"));
30 if !path.exists() {
31 return Err(OwsLibError::InvalidInput(format!("policy not found: {id}")));
32 }
33 let contents = fs::read_to_string(&path)?;
34 let policy: Policy = serde_json::from_str(&contents)?;
35 Ok(policy)
36}
37
38pub fn list_policies(vault_path: Option<&Path>) -> Result<Vec<Policy>, OwsLibError> {
40 let dir = policies_dir(vault_path)?;
41 let mut policies = Vec::new();
42
43 let entries = match fs::read_dir(&dir) {
44 Ok(entries) => entries,
45 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(policies),
46 Err(e) => return Err(e.into()),
47 };
48
49 for entry in entries {
50 let entry = entry?;
51 let path = entry.path();
52 if path.extension().and_then(|e| e.to_str()) != Some("json") {
53 continue;
54 }
55 match fs::read_to_string(&path) {
56 Ok(contents) => match serde_json::from_str::<Policy>(&contents) {
57 Ok(p) => policies.push(p),
58 Err(e) => eprintln!("warning: skipping {}: {e}", path.display()),
59 },
60 Err(e) => eprintln!("warning: skipping {}: {e}", path.display()),
61 }
62 }
63
64 policies.sort_by(|a, b| a.name.cmp(&b.name));
65 Ok(policies)
66}
67
68pub fn delete_policy(id: &str, vault_path: Option<&Path>) -> Result<(), OwsLibError> {
70 let dir = policies_dir(vault_path)?;
71 let path = dir.join(format!("{id}.json"));
72 if !path.exists() {
73 return Err(OwsLibError::InvalidInput(format!("policy not found: {id}")));
74 }
75 fs::remove_file(&path)?;
76 Ok(())
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use ows_core::{PolicyAction, PolicyRule};
83
84 fn test_policy(id: &str, name: &str) -> Policy {
85 Policy {
86 id: id.to_string(),
87 name: name.to_string(),
88 version: 1,
89 created_at: "2026-03-22T10:00:00Z".to_string(),
90 rules: vec![PolicyRule::AllowedChains {
91 chain_ids: vec!["eip155:8453".to_string()],
92 }],
93 executable: None,
94 config: None,
95 action: PolicyAction::Deny,
96 }
97 }
98
99 #[test]
100 fn save_and_load_roundtrip() {
101 let dir = tempfile::tempdir().unwrap();
102 let vault = dir.path().to_path_buf();
103 let policy = test_policy("base-only", "Base Only");
104
105 save_policy(&policy, Some(&vault)).unwrap();
106 let loaded = load_policy("base-only", Some(&vault)).unwrap();
107
108 assert_eq!(loaded.id, "base-only");
109 assert_eq!(loaded.name, "Base Only");
110 assert_eq!(loaded.rules.len(), 1);
111 }
112
113 #[test]
114 fn list_returns_sorted_by_name() {
115 let dir = tempfile::tempdir().unwrap();
116 let vault = dir.path().to_path_buf();
117
118 save_policy(&test_policy("z-policy", "Zebra"), Some(&vault)).unwrap();
119 save_policy(&test_policy("a-policy", "Alpha"), Some(&vault)).unwrap();
120 save_policy(&test_policy("m-policy", "Middle"), Some(&vault)).unwrap();
121
122 let policies = list_policies(Some(&vault)).unwrap();
123 assert_eq!(policies.len(), 3);
124 assert_eq!(policies[0].name, "Alpha");
125 assert_eq!(policies[1].name, "Middle");
126 assert_eq!(policies[2].name, "Zebra");
127 }
128
129 #[test]
130 fn delete_removes_file() {
131 let dir = tempfile::tempdir().unwrap();
132 let vault = dir.path().to_path_buf();
133
134 save_policy(&test_policy("del-me", "Delete Me"), Some(&vault)).unwrap();
135 assert_eq!(list_policies(Some(&vault)).unwrap().len(), 1);
136
137 delete_policy("del-me", Some(&vault)).unwrap();
138 assert_eq!(list_policies(Some(&vault)).unwrap().len(), 0);
139 }
140
141 #[test]
142 fn load_nonexistent_returns_error() {
143 let dir = tempfile::tempdir().unwrap();
144 let vault = dir.path().to_path_buf();
145
146 let result = load_policy("nope", Some(&vault));
147 assert!(result.is_err());
148 }
149
150 #[test]
151 fn delete_nonexistent_returns_error() {
152 let dir = tempfile::tempdir().unwrap();
153 let vault = dir.path().to_path_buf();
154
155 let result = delete_policy("nope", Some(&vault));
156 assert!(result.is_err());
157 }
158
159 #[test]
160 fn list_empty_vault_returns_empty() {
161 let dir = tempfile::tempdir().unwrap();
162 let vault = dir.path().to_path_buf();
163
164 let policies = list_policies(Some(&vault)).unwrap();
165 assert!(policies.is_empty());
166 }
167
168 #[test]
169 fn save_overwrites_existing() {
170 let dir = tempfile::tempdir().unwrap();
171 let vault = dir.path().to_path_buf();
172
173 let mut policy = test_policy("overwrite-me", "Version 1");
174 save_policy(&policy, Some(&vault)).unwrap();
175
176 policy.name = "Version 2".to_string();
177 policy.version = 2;
178 save_policy(&policy, Some(&vault)).unwrap();
179
180 let loaded = load_policy("overwrite-me", Some(&vault)).unwrap();
181 assert_eq!(loaded.name, "Version 2");
182 assert_eq!(loaded.version, 2);
183 assert_eq!(list_policies(Some(&vault)).unwrap().len(), 1);
184 }
185
186 #[test]
187 fn policy_with_executable_roundtrips() {
188 let dir = tempfile::tempdir().unwrap();
189 let vault = dir.path().to_path_buf();
190
191 let policy = Policy {
192 id: "sim-policy".to_string(),
193 name: "Simulation".to_string(),
194 version: 1,
195 created_at: "2026-03-22T10:00:00Z".to_string(),
196 rules: vec![],
197 executable: Some("/usr/local/bin/simulate-tx".to_string()),
198 config: Some(serde_json::json!({"rpc": "https://mainnet.base.org"})),
199 action: PolicyAction::Deny,
200 };
201
202 save_policy(&policy, Some(&vault)).unwrap();
203 let loaded = load_policy("sim-policy", Some(&vault)).unwrap();
204 assert_eq!(loaded.executable.unwrap(), "/usr/local/bin/simulate-tx");
205 assert!(loaded.config.is_some());
206 }
207}