Skip to main content

ows_lib/
policy_store.rs

1use ows_core::Policy;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::error::OwsLibError;
6use crate::vault;
7
8/// Returns the policies directory, creating it if needed.
9/// Policies are not secret — no restrictive permissions applied.
10pub 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
17/// Save a policy to `~/.ows/policies/<id>.json`.
18pub 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
26/// Load a single policy by ID.
27pub 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
38/// List all policies, sorted alphabetically by name.
39pub 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
68/// Delete a policy by ID.
69pub 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}