Skip to main content

astrid_core/profile/
validation.rs

1//! Semantic validation for [`PrincipalProfile`] and its nested configs.
2//!
3//! Validation runs on both load and save — a malformed profile on disk is
4//! never silently accepted, and a malformed in-memory profile is never
5//! persisted.
6
7use 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    /// Enforce semantic validation rules. Invoked on both load and save.
17    ///
18    /// # Errors
19    ///
20    /// Returns [`ProfileError::Invalid`] on the first failing rule; order
21    /// of rule evaluation is not part of the public contract.
22    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    /// Validate quota bounds.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`ProfileError::Invalid`] if any quota is zero where
56    /// non-zero is required, or exceeds its documented upper bound.
57    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            // Fail-closed: a zero CPU rate would trap every guest instruction
85            // immediately. There is no "unlimited" sentinel — exemption is a
86            // capability (`CAP_RESOURCES_UNBOUNDED`), never a quota value.
87            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    /// Validate public keys. Method variants are enforced by serde via the
97    /// closed [`AuthMethod`](super::AuthMethod) enum.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`ProfileError::Invalid`] if any public key is empty.
102    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    /// Validate egress entries.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`ProfileError::Invalid`] if any entry is an empty string.
120    /// Richer grammar checking is deferred to Layer 5.
121    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    /// Validate process-allow entries.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`ProfileError::Invalid`] if any entry is an empty string.
139    /// Richer grammar checking is deferred to Layer 5.
140    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
152/// Same character set + length cap as [`PrincipalId`](crate::PrincipalId):
153/// `[a-zA-Z0-9_-]` and up to [`MAX_GROUP_NAME_LEN`] characters.
154fn 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)] // tests mutate a known-good baseline
178mod tests {
179    use super::*;
180
181    // ── Quotas ────────────────────────────────────────────────────────
182
183    #[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    // ── Auth ──────────────────────────────────────────────────────────
247
248    #[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    // ── Groups ────────────────────────────────────────────────────────
264
265    #[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    // ── Grants / revokes (capability grammar) ─────────────────────────
300
301    #[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    // ── Network / process ─────────────────────────────────────────────
342
343    #[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    // ── Version gate ──────────────────────────────────────────────────
358
359    #[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}