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 {}