greentic_component/
capabilities.rs1use 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
11pub 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 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#[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 {}