astrid_core/profile/
validation.rs1use crate::capability_grammar::validate_capability;
8
9use super::{
10 AuthConfig, BACKGROUND_PROCESSES_UPPER_BOUND, CURRENT_PROFILE_VERSION, MAX_GROUP_NAME_LEN,
11 NetworkConfig, PrincipalProfile, ProcessConfig, ProfileError, ProfileResult, Quotas,
12 TIMEOUT_SECS_UPPER_BOUND,
13};
14
15impl PrincipalProfile {
16 pub fn validate(&self) -> ProfileResult<()> {
23 if self.profile_version > CURRENT_PROFILE_VERSION {
24 return Err(ProfileError::Invalid(format!(
25 "profile_version {} exceeds supported version {}",
26 self.profile_version, CURRENT_PROFILE_VERSION
27 )));
28 }
29 self.quotas.validate()?;
30 self.auth.validate()?;
31 for group in &self.groups {
32 validate_group_name(group)?;
33 }
34 for cap in &self.grants {
35 validate_capability(cap).map_err(|e| {
36 ProfileError::Invalid(format!("grants entry {cap:?} rejected: {e}"))
37 })?;
38 }
39 for cap in &self.revokes {
40 validate_capability(cap).map_err(|e| {
41 ProfileError::Invalid(format!("revokes entry {cap:?} rejected: {e}"))
42 })?;
43 }
44 self.network.validate()?;
45 self.process.validate()?;
46 Ok(())
47 }
48}
49
50impl Quotas {
51 pub fn validate(&self) -> ProfileResult<()> {
58 if self.max_memory_bytes == 0 {
59 return Err(ProfileError::Invalid(
60 "quotas.max_memory_bytes must be > 0".into(),
61 ));
62 }
63 if self.max_timeout_secs == 0 || self.max_timeout_secs > TIMEOUT_SECS_UPPER_BOUND {
64 return Err(ProfileError::Invalid(format!(
65 "quotas.max_timeout_secs must be in 1..={TIMEOUT_SECS_UPPER_BOUND}",
66 )));
67 }
68 if self.max_ipc_throughput_bytes == 0 {
69 return Err(ProfileError::Invalid(
70 "quotas.max_ipc_throughput_bytes must be > 0".into(),
71 ));
72 }
73 if self.max_background_processes > BACKGROUND_PROCESSES_UPPER_BOUND {
74 return Err(ProfileError::Invalid(format!(
75 "quotas.max_background_processes must be <= {BACKGROUND_PROCESSES_UPPER_BOUND}",
76 )));
77 }
78 if self.max_storage_bytes == 0 {
79 return Err(ProfileError::Invalid(
80 "quotas.max_storage_bytes must be > 0".into(),
81 ));
82 }
83 if self.max_cpu_fuel_per_sec == 0 {
84 return Err(ProfileError::Invalid(
88 "quotas.max_cpu_fuel_per_sec must be > 0".into(),
89 ));
90 }
91 Ok(())
92 }
93}
94
95impl AuthConfig {
96 pub fn validate(&self) -> ProfileResult<()> {
103 for key in &self.public_keys {
104 if key.is_empty() {
105 return Err(ProfileError::Invalid(
106 "auth.public_keys entries must be non-empty".into(),
107 ));
108 }
109 }
110 Ok(())
111 }
112}
113
114impl NetworkConfig {
115 pub fn validate(&self) -> ProfileResult<()> {
122 for pattern in &self.egress {
123 if pattern.trim().is_empty() {
124 return Err(ProfileError::Invalid(
125 "network.egress entries must be non-empty".into(),
126 ));
127 }
128 }
129 Ok(())
130 }
131}
132
133impl ProcessConfig {
134 pub fn validate(&self) -> ProfileResult<()> {
141 for entry in &self.allow {
142 if entry.trim().is_empty() {
143 return Err(ProfileError::Invalid(
144 "process.allow entries must be non-empty".into(),
145 ));
146 }
147 }
148 Ok(())
149 }
150}
151
152fn validate_group_name(name: &str) -> ProfileResult<()> {
155 if name.is_empty() {
156 return Err(ProfileError::Invalid(
157 "groups entries must be non-empty".into(),
158 ));
159 }
160 if name.len() > MAX_GROUP_NAME_LEN {
161 return Err(ProfileError::Invalid(format!(
162 "groups entry exceeds {MAX_GROUP_NAME_LEN} characters: {name:?}",
163 )));
164 }
165 if let Some(bad) = name
166 .chars()
167 .find(|c| !c.is_ascii_alphanumeric() && *c != '-' && *c != '_')
168 {
169 return Err(ProfileError::Invalid(format!(
170 "groups entry {name:?} contains invalid character {bad:?} (allowed: a-z, A-Z, 0-9, -, _)",
171 )));
172 }
173 Ok(())
174}
175
176#[cfg(test)]
177#[allow(clippy::field_reassign_with_default)] mod tests {
179 use super::*;
180
181 #[test]
184 fn rejects_zero_memory() {
185 let mut p = PrincipalProfile::default();
186 p.quotas.max_memory_bytes = 0;
187 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
188 }
189
190 #[test]
191 fn rejects_zero_timeout() {
192 let mut p = PrincipalProfile::default();
193 p.quotas.max_timeout_secs = 0;
194 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
195 }
196
197 #[test]
198 fn rejects_timeout_over_cap() {
199 let mut p = PrincipalProfile::default();
200 p.quotas.max_timeout_secs = TIMEOUT_SECS_UPPER_BOUND + 1;
201 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
202 }
203
204 #[test]
205 fn accepts_timeout_at_cap() {
206 let mut p = PrincipalProfile::default();
207 p.quotas.max_timeout_secs = TIMEOUT_SECS_UPPER_BOUND;
208 p.validate().unwrap();
209 }
210
211 #[test]
212 fn rejects_zero_ipc_throughput() {
213 let mut p = PrincipalProfile::default();
214 p.quotas.max_ipc_throughput_bytes = 0;
215 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
216 }
217
218 #[test]
219 fn rejects_background_procs_over_cap() {
220 let mut p = PrincipalProfile::default();
221 p.quotas.max_background_processes = BACKGROUND_PROCESSES_UPPER_BOUND + 1;
222 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
223 }
224
225 #[test]
226 fn accepts_background_procs_at_cap() {
227 let mut p = PrincipalProfile::default();
228 p.quotas.max_background_processes = BACKGROUND_PROCESSES_UPPER_BOUND;
229 p.validate().unwrap();
230 }
231
232 #[test]
233 fn rejects_zero_storage() {
234 let mut p = PrincipalProfile::default();
235 p.quotas.max_storage_bytes = 0;
236 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
237 }
238
239 #[test]
240 fn rejects_zero_cpu_fuel_per_sec() {
241 let mut p = PrincipalProfile::default();
242 p.quotas.max_cpu_fuel_per_sec = 0;
243 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
244 }
245
246 #[test]
249 fn accepts_all_known_auth_methods() {
250 use super::super::AuthMethod;
251 let mut p = PrincipalProfile::default();
252 p.auth.methods = vec![AuthMethod::Keypair, AuthMethod::Passkey, AuthMethod::System];
253 p.validate().unwrap();
254 }
255
256 #[test]
257 fn rejects_empty_public_key() {
258 let mut p = PrincipalProfile::default();
259 p.auth.public_keys = vec![String::new()];
260 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
261 }
262
263 #[test]
266 fn accepts_valid_group_names() {
267 let mut p = PrincipalProfile::default();
268 p.groups = vec![
269 "admins".into(),
270 "ops_team".into(),
271 "agent-007".into(),
272 "X".into(),
273 "a".repeat(MAX_GROUP_NAME_LEN),
274 ];
275 p.validate().unwrap();
276 }
277
278 #[test]
279 fn rejects_empty_group() {
280 let mut p = PrincipalProfile::default();
281 p.groups = vec![String::new()];
282 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
283 }
284
285 #[test]
286 fn rejects_group_with_bad_char() {
287 let mut p = PrincipalProfile::default();
288 p.groups = vec!["ops/team".into()];
289 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
290 }
291
292 #[test]
293 fn rejects_group_too_long() {
294 let mut p = PrincipalProfile::default();
295 p.groups = vec!["a".repeat(MAX_GROUP_NAME_LEN + 1)];
296 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
297 }
298
299 #[test]
302 fn accepts_valid_grants_and_revokes() {
303 let mut p = PrincipalProfile::default();
304 p.grants = vec!["system:shutdown".into(), "self:*".into(), "*".into()];
305 p.revokes = vec!["audit:read:alice".into(), "a:*:b".into()];
306 p.validate().unwrap();
307 }
308
309 #[test]
310 fn rejects_grant_with_shell_metachar() {
311 let mut p = PrincipalProfile::default();
312 p.grants = vec!["system:shutdown;rm".into()];
313 let err = p.validate().unwrap_err();
314 match err {
315 ProfileError::Invalid(msg) => assert!(msg.contains("grants entry"), "msg: {msg}"),
316 other => panic!("expected Invalid, got: {other:?}"),
317 }
318 }
319
320 #[test]
321 fn rejects_grant_with_double_glob() {
322 let mut p = PrincipalProfile::default();
323 p.grants = vec!["capsule:**".into()];
324 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
325 }
326
327 #[test]
328 fn rejects_empty_grant_entry() {
329 let mut p = PrincipalProfile::default();
330 p.grants = vec![String::new()];
331 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
332 }
333
334 #[test]
335 fn rejects_revoke_with_trailing_colon() {
336 let mut p = PrincipalProfile::default();
337 p.revokes = vec!["system:".into()];
338 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
339 }
340
341 #[test]
344 fn rejects_whitespace_egress() {
345 let mut p = PrincipalProfile::default();
346 p.network.egress = vec![" ".into()];
347 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
348 }
349
350 #[test]
351 fn rejects_empty_process_allow() {
352 let mut p = PrincipalProfile::default();
353 p.process.allow = vec![String::new()];
354 assert!(matches!(p.validate(), Err(ProfileError::Invalid(_))));
355 }
356
357 #[test]
360 fn rejects_future_version() {
361 let mut p = PrincipalProfile::default();
362 p.profile_version = CURRENT_PROFILE_VERSION + 1;
363 let err = p.validate().unwrap_err();
364 match err {
365 ProfileError::Invalid(msg) => assert!(msg.contains("profile_version"), "msg: {msg}"),
366 other => panic!("expected Invalid, got: {other:?}"),
367 }
368 }
369}