1use std::path::Path;
6
7use serde::{Deserialize, Serialize};
8
9use crate::error::Result;
10use crate::files::{HARD_MAX_FILES, HARD_MAX_FILE_BYTES};
11use crate::message::{DataClass, Message};
12
13const MB: u64 = 1024 * 1024;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct Policy {
18 #[serde(default = "default_approve_verbs")]
20 pub approve_verbs: Vec<String>,
21 #[serde(default = "default_max_task_hops")]
26 pub max_task_hops: u32,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub files: Option<FileMode>,
31 #[serde(default = "default_max_file_mb")]
33 pub max_file_mb: u64,
34 #[serde(default = "default_max_files_per_message")]
35 pub max_files_per_message: usize,
36 #[serde(default = "default_daily_file_mb")]
38 pub daily_file_mb_per_member: u64,
39 #[serde(default = "default_room_file_store_mb")]
41 pub room_file_store_mb: u64,
42 #[serde(default = "default_file_keep_days")]
44 pub file_keep_days: u64,
45 #[serde(default = "default_file_max_days")]
47 pub file_max_days: u64,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum FileMode {
54 Any,
56 Safe,
58 Off,
60}
61
62impl FileMode {
63 pub fn as_str(&self) -> &'static str {
64 match self {
65 FileMode::Any => "any",
66 FileMode::Safe => "safe",
67 FileMode::Off => "off",
68 }
69 }
70}
71
72fn default_max_file_mb() -> u64 {
73 25
74}
75fn default_max_files_per_message() -> usize {
76 5
77}
78fn default_daily_file_mb() -> u64 {
79 200
80}
81fn default_room_file_store_mb() -> u64 {
82 2048
83}
84fn default_file_keep_days() -> u64 {
85 7
86}
87fn default_file_max_days() -> u64 {
88 30
89}
90
91fn default_max_task_hops() -> u32 {
92 4
93}
94
95fn default_approve_verbs() -> Vec<String> {
96 ["delete", "deploy", "pay", "mail"]
97 .map(String::from)
98 .to_vec()
99}
100
101impl Default for Policy {
102 fn default() -> Self {
103 Policy {
104 approve_verbs: default_approve_verbs(),
105 max_task_hops: default_max_task_hops(),
106 files: None,
107 max_file_mb: default_max_file_mb(),
108 max_files_per_message: default_max_files_per_message(),
109 daily_file_mb_per_member: default_daily_file_mb(),
110 room_file_store_mb: default_room_file_store_mb(),
111 file_keep_days: default_file_keep_days(),
112 file_max_days: default_file_max_days(),
113 }
114 }
115}
116
117impl Policy {
118 pub fn load(path: &Path) -> Result<Self> {
119 if !path.exists() {
120 return Ok(Policy::default());
121 }
122 let text = std::fs::read_to_string(path)?;
123 toml::from_str(&text).map_err(|e| crate::error::Error::Invalid(format!("policy: {e}")))
124 }
125
126 pub fn save(&self, path: &Path) -> Result<()> {
127 if let Some(parent) = path.parent() {
128 std::fs::create_dir_all(parent)?;
129 }
130 let text = toml::to_string_pretty(self)
131 .map_err(|e| crate::error::Error::Invalid(format!("policy: {e}")))?;
132 std::fs::write(path, text)?;
133 Ok(())
134 }
135
136 pub fn file_mode(&self, class: DataClass) -> FileMode {
138 self.files.unwrap_or(match class {
139 DataClass::Confidential | DataClass::Pii => FileMode::Off,
140 DataClass::Public | DataClass::Internal => FileMode::Any,
141 })
142 }
143
144 pub fn max_file_bytes(&self) -> u64 {
146 (self.max_file_mb.saturating_mul(MB)).min(HARD_MAX_FILE_BYTES)
147 }
148
149 pub fn max_files(&self) -> usize {
150 self.max_files_per_message.min(HARD_MAX_FILES)
151 }
152
153 pub fn daily_file_bytes(&self) -> u64 {
154 self.daily_file_mb_per_member.saturating_mul(MB)
155 }
156
157 pub fn room_file_bytes(&self) -> u64 {
158 self.room_file_store_mb.saturating_mul(MB)
159 }
160
161 pub fn requires_approve(&self, verb: &str) -> bool {
163 self.approve_verbs.iter().any(|v| v == verb)
164 }
165}
166
167pub trait PolicyHook: Send + Sync {
170 fn check(&self, policy: &Policy, msg: &Message) -> Result<()>;
172 fn needs_approve(&self, policy: &Policy, msg: &Message) -> bool {
174 msg.action
175 .as_ref()
176 .map(|a| policy.requires_approve(&a.verb))
177 .unwrap_or(false)
178 }
179}
180
181#[derive(Debug, Default, Clone, Copy)]
183pub struct DefaultHook;
184
185impl PolicyHook for DefaultHook {
186 fn check(&self, _policy: &Policy, _msg: &Message) -> Result<()> {
187 Ok(())
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn defaults_and_file_roundtrip() {
197 let p = Policy::default();
198 assert!(p.requires_approve("deploy"));
199 assert!(!p.requires_approve("lint"));
200 let dir = std::env::temp_dir().join(format!("diavlos-policy-{}", ulid::Ulid::generate()));
201 let path = dir.join("policy.toml");
202 assert_eq!(Policy::load(&path).unwrap(), Policy::default());
203 let custom = Policy {
204 approve_verbs: vec!["rm".into()],
205 max_task_hops: 2,
206 files: Some(FileMode::Safe),
207 max_file_mb: 5000,
208 ..Policy::default()
209 };
210 custom.save(&path).unwrap();
211 assert_eq!(Policy::load(&path).unwrap(), custom);
212 std::fs::remove_dir_all(dir).unwrap();
213 assert_eq!(custom.max_file_bytes(), HARD_MAX_FILE_BYTES);
215 }
216
217 #[test]
218 fn files_default_off_only_in_sensitive_rooms() {
219 let p = Policy::default();
220 assert_eq!(p.file_mode(DataClass::Internal), FileMode::Any);
221 assert_eq!(p.file_mode(DataClass::Public), FileMode::Any);
222 assert_eq!(p.file_mode(DataClass::Pii), FileMode::Off);
223 assert_eq!(p.file_mode(DataClass::Confidential), FileMode::Off);
224 let on: Policy = toml::from_str("files = \"safe\"\n").unwrap();
225 assert_eq!(on.file_mode(DataClass::Pii), FileMode::Safe);
226 assert_eq!(on.max_file_bytes(), 25 * MB);
227 }
228}