Skip to main content

greentic_component/
capabilities.rs

1use greentic_types::SecretKey;
2pub use greentic_types::component::{
3    ComponentCapabilities as Capabilities, ComponentConfigurators, ComponentProfiles,
4    EnvCapabilities, EventsCapabilities, FilesystemCapabilities, FilesystemMode, FilesystemMount,
5    HostCapabilities, HttpCapabilities, IaCCapabilities, MessagingCapabilities,
6    SecretsCapabilities, StateCapabilities, TelemetryCapabilities, TelemetryScope,
7    WasiCapabilities,
8};
9use std::collections::HashSet;
10
11/// Validates a capability declaration, ensuring basic structural correctness.
12pub fn validate_capabilities(caps: &Capabilities) -> Result<(), CapabilityError> {
13    validate_wasi(&caps.wasi)?;
14    validate_host(&caps.host)?;
15    Ok(())
16}
17
18fn validate_wasi(wasi: &WasiCapabilities) -> Result<(), CapabilityError> {
19    if let Some(fs) = &wasi.filesystem {
20        validate_filesystem(fs)?;
21    }
22    if let Some(env) = &wasi.env {
23        validate_env(env)?;
24    }
25    Ok(())
26}
27
28fn validate_filesystem(fs: &FilesystemCapabilities) -> Result<(), CapabilityError> {
29    if fs.mode != FilesystemMode::None && fs.mounts.is_empty() {
30        return Err(CapabilityError::invalid(
31            "wasi.filesystem.mounts",
32            "filesystem mounts must be declared when exposing the filesystem",
33        ));
34    }
35    for mount in &fs.mounts {
36        validate_mount(mount)?;
37    }
38    Ok(())
39}
40
41fn validate_mount(mount: &FilesystemMount) -> Result<(), CapabilityError> {
42    if mount.name.trim().is_empty() {
43        return Err(CapabilityError::invalid(
44            "wasi.filesystem.mounts[].name",
45            "mount name cannot be empty",
46        ));
47    }
48    if mount.host_class.trim().is_empty() {
49        return Err(CapabilityError::invalid(
50            "wasi.filesystem.mounts[].host_class",
51            "host_class must describe a storage class",
52        ));
53    }
54    if mount.guest_path.trim().is_empty() {
55        return Err(CapabilityError::invalid(
56            "wasi.filesystem.mounts[].guest_path",
57            "guest_path cannot be empty",
58        ));
59    }
60    Ok(())
61}
62
63fn validate_env(env: &EnvCapabilities) -> Result<(), CapabilityError> {
64    for var in &env.allow {
65        if var.trim().is_empty() {
66            return Err(CapabilityError::invalid(
67                "wasi.env.allow[]",
68                "environment variable names cannot be empty",
69            ));
70        }
71    }
72    Ok(())
73}
74
75fn validate_host(host: &HostCapabilities) -> Result<(), CapabilityError> {
76    if let Some(secrets) = &host.secrets {
77        validate_secrets(secrets)?;
78    }
79    if let Some(state) = &host.state
80        && !state.read
81        && !state.write
82    {
83        return Err(CapabilityError::invalid(
84            "host.state",
85            "state capability must enable read and/or write",
86        ));
87    }
88    if let Some(telemetry) = &host.telemetry {
89        validate_telemetry(telemetry)?;
90    }
91    if let Some(iac) = &host.iac {
92        validate_iac(iac)?;
93    }
94    Ok(())
95}
96
97fn validate_secrets(secrets: &SecretsCapabilities) -> Result<(), CapabilityError> {
98    let mut seen = HashSet::new();
99    for requirement in &secrets.required {
100        let key = requirement.key.as_str();
101        if !seen.insert(key.to_string()) {
102            return Err(CapabilityError::invalid(
103                "host.secrets.required",
104                format!("duplicate secret `{key}`"),
105            ));
106        }
107
108        SecretKey::new(key)
109            .map_err(|err| CapabilityError::invalid("host.secrets.required", err.to_string()))?;
110
111        let scope = requirement.scope.as_ref().ok_or_else(|| {
112            CapabilityError::invalid(
113                "host.secrets.required.scope",
114                "scope must include env and tenant",
115            )
116        })?;
117        if scope.env.trim().is_empty() {
118            return Err(CapabilityError::invalid(
119                "host.secrets.required.scope.env",
120                "scope.env must not be empty",
121            ));
122        }
123        if scope.tenant.trim().is_empty() {
124            return Err(CapabilityError::invalid(
125                "host.secrets.required.scope.tenant",
126                "scope.tenant must not be empty",
127            ));
128        }
129        if let Some(team) = &scope.team
130            && team.trim().is_empty()
131        {
132            return Err(CapabilityError::invalid(
133                "host.secrets.required.scope.team",
134                "scope.team must not be empty when provided",
135            ));
136        }
137
138        if requirement.format.is_none() {
139            return Err(CapabilityError::invalid(
140                "host.secrets.required.format",
141                "format must be specified",
142            ));
143        }
144        if let Some(schema) = &requirement.schema
145            && !schema.is_object()
146        {
147            return Err(CapabilityError::invalid(
148                "host.secrets.required.schema",
149                "schema must be an object when provided",
150            ));
151        }
152    }
153    Ok(())
154}
155
156fn validate_telemetry(telemetry: &TelemetryCapabilities) -> Result<(), CapabilityError> {
157    // No structural validation beyond ensuring the enum is populated.
158    let _ = telemetry.scope;
159    Ok(())
160}
161
162fn validate_iac(iac: &IaCCapabilities) -> Result<(), CapabilityError> {
163    if !iac.write_templates && !iac.execute_plans {
164        return Err(CapabilityError::invalid(
165            "host.iac",
166            "iac capability must enable template writes and/or plan execution",
167        ));
168    }
169    Ok(())
170}
171
172/// Error produced when capability declarations are malformed.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct CapabilityError {
175    pub path: &'static str,
176    pub message: String,
177}
178
179impl CapabilityError {
180    pub fn invalid(path: &'static str, message: impl Into<String>) -> Self {
181        Self {
182            path,
183            message: message.into(),
184        }
185    }
186}
187
188impl core::fmt::Display for CapabilityError {
189    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
190        write!(f, "invalid capability `{}`: {}", self.path, self.message)
191    }
192}
193
194impl std::error::Error for CapabilityError {}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use greentic_types::{
200        SecretFormat, SecretKey, SecretRequirement, SecretScope, component::ComponentCapabilities,
201    };
202
203    fn baseline_caps() -> Capabilities {
204        ComponentCapabilities {
205            wasi: WasiCapabilities {
206                filesystem: None,
207                env: None,
208                random: false,
209                clocks: false,
210            },
211            host: HostCapabilities {
212                messaging: None,
213                events: None,
214                http: None,
215                secrets: None,
216                state: None,
217                telemetry: None,
218                iac: None,
219            },
220        }
221    }
222
223    fn secret_requirement(key: &str) -> SecretRequirement {
224        let mut requirement = SecretRequirement::default();
225        requirement.key = SecretKey::new(key).expect("valid secret key");
226        requirement.required = true;
227        requirement.scope = Some(SecretScope {
228            env: "dev".into(),
229            tenant: "tenant-a".into(),
230            team: None,
231        });
232        requirement.format = Some(SecretFormat::Text);
233        requirement
234    }
235
236    #[test]
237    fn rejects_filesystem_access_without_mounts() {
238        let mut caps = baseline_caps();
239        caps.wasi.filesystem = Some(FilesystemCapabilities {
240            mode: FilesystemMode::ReadOnly,
241            mounts: vec![],
242        });
243
244        let err = validate_capabilities(&caps).expect_err("filesystem policy should be invalid");
245        assert_eq!(err.path, "wasi.filesystem.mounts");
246    }
247
248    #[test]
249    fn rejects_duplicate_secret_requirements() {
250        let mut caps = baseline_caps();
251        caps.host.secrets = Some(SecretsCapabilities {
252            required: vec![
253                secret_requirement("API_TOKEN"),
254                secret_requirement("API_TOKEN"),
255            ],
256        });
257
258        let err = validate_capabilities(&caps).expect_err("duplicate secrets should be rejected");
259        assert_eq!(err.path, "host.secrets.required");
260        assert!(err.message.contains("API_TOKEN"));
261    }
262
263    #[test]
264    fn rejects_blank_secret_team_when_present() {
265        let mut requirement = secret_requirement("API_TOKEN");
266        requirement.scope.as_mut().expect("scope").team = Some("   ".into());
267
268        let mut caps = baseline_caps();
269        caps.host.secrets = Some(SecretsCapabilities {
270            required: vec![requirement],
271        });
272
273        let err =
274            validate_capabilities(&caps).expect_err("blank team should be structurally invalid");
275        assert_eq!(err.path, "host.secrets.required.scope.team");
276    }
277
278    #[test]
279    fn rejects_iac_capability_with_no_enabled_actions() {
280        let mut caps = baseline_caps();
281        caps.host.iac = Some(IaCCapabilities {
282            write_templates: false,
283            execute_plans: false,
284        });
285
286        let err = validate_capabilities(&caps).expect_err("iac must enable something");
287        assert_eq!(err.path, "host.iac");
288    }
289}