nxd_core/domain/contract/
validation.rs1use super::{
2 CanonicalConfig, PROVIDER_PROTOCOL_V1, Resource, SECRET_PROTOCOL_V1, SecretResolverInstance,
3 resource_depends_on, resource_id,
4};
5use std::collections::{BTreeMap, BTreeSet};
6
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct ContractValidationError(pub String);
9
10pub fn validate_canonical_config(config: &CanonicalConfig) -> Result<(), ContractValidationError> {
11 let mut providers = BTreeMap::new();
12 for provider in &config.spec.provider_instances {
13 if provider.id.is_empty()
14 || provider.kind.is_empty()
15 || provider.id.chars().any(char::is_control)
16 || provider.kind.chars().any(char::is_control)
17 {
18 return Err(ContractValidationError(format!(
19 "provider instance {} has an invalid id or kind",
20 provider.id
21 )));
22 }
23 if providers.insert(provider.id.as_str(), provider.kind.as_str()).is_some() {
24 return Err(ContractValidationError(format!(
25 "duplicate provider instance id {}",
26 provider.id
27 )));
28 }
29 validate_provider_executable_declaration(provider)?;
30 }
31 let mut secret_resolvers = BTreeSet::new();
32 for resolver in &config.spec.secret_resolvers {
33 validate_secret_resolver_declaration(resolver)?;
34 if !secret_resolvers.insert(resolver.id.as_str()) {
35 return Err(ContractValidationError(format!("duplicate secret resolver id {}", resolver.id)));
36 }
37 }
38 let mut secret_bindings = BTreeSet::new();
39 for binding in &config.spec.secret_bindings {
40 if !(binding.id.starts_with("secret/") || binding.id.starts_with("public/"))
41 || !secret_bindings.insert(binding.id.as_str())
42 || binding.resolver.is_empty()
43 || binding.reference.is_empty()
44 || binding.reference.chars().any(char::is_control)
45 || binding.reference.contains("PVEAPIToken=")
46 {
47 return Err(ContractValidationError(format!(
48 "secret binding {} has an invalid ID, resolver, or reference",
49 binding.id
50 )));
51 }
52 if !secret_resolvers.is_empty() && !secret_resolvers.contains(binding.resolver.as_str()) {
56 return Err(ContractValidationError(format!(
57 "secret binding {} references unknown secret resolver {}",
58 binding.id, binding.resolver
59 )));
60 }
61 }
62 for provider in &config.spec.provider_instances {
63 let mut provider_bindings = BTreeSet::new();
64 for binding_id in &provider.secret_bindings {
65 if !binding_id.starts_with("secret/")
66 || !provider_bindings.insert(binding_id.as_str())
67 || !secret_bindings.contains(binding_id.as_str())
68 {
69 return Err(ContractValidationError(format!(
70 "provider instance {} has an unknown or duplicate secret binding {}",
71 provider.id, binding_id
72 )));
73 }
74 }
75 }
76 let mut resources = BTreeMap::new();
77 for resource in &config.spec.resources {
78 let id = resource_id(resource);
79 if resources.insert(id, resource).is_some() {
80 return Err(ContractValidationError(format!("duplicate resource ID {id}")));
81 }
82 }
83 for resource in &config.spec.resources {
84 let rid = resource_id(resource);
85 let dependencies = resource_depends_on(resource);
86 if !dependencies.is_empty()
87 && dependencies
88 .iter()
89 .any(|dependency| dependency == rid || !resources.contains_key(dependency.as_str()))
90 {
91 return Err(ContractValidationError(format!("resource {rid} has an invalid dependency")));
92 }
93 if let Resource::ProviderOwned(value) = resource {
94 if value.id.is_empty() || value.resource_kind.is_empty() {
95 return Err(ContractValidationError(format!(
96 "provider-owned resource {} has invalid identity",
97 value.id
98 )));
99 }
100 if value.provider.is_empty() {
101 return Err(ContractValidationError(format!(
102 "provider-owned resource {} is missing provider",
103 value.id
104 )));
105 }
106 if !providers.contains_key(value.provider.as_str()) {
107 return Err(ContractValidationError(format!(
108 "provider-owned resource {} references unknown provider {}",
109 value.id, value.provider
110 )));
111 }
112 if let Some(binding) = value.secret_binding.as_ref()
116 && !secret_bindings.contains(binding.as_str())
117 {
118 return Err(ContractValidationError(format!(
119 "provider-owned resource {} references unknown secret binding {binding}",
120 value.id
121 )));
122 }
123 if let Some(target) = value.deployment_target.as_ref()
124 && !target.starts_with("deployment-target/")
125 {
126 return Err(ContractValidationError(format!(
127 "provider-owned resource {} has invalid deploymentTarget {target}",
128 value.id
129 )));
130 }
131 if matches!(value.resource_kind.as_str(), "sshHostIdentity" | "trustAnchor") {
133 if providers.get(value.provider.as_str()).copied() != Some("identity") {
134 return Err(ContractValidationError(format!(
135 "managed identity resource {} must be owned by an identity provider",
136 value.id
137 )));
138 }
139 let expected_prefix = if value.resource_kind == "sshHostIdentity" {
140 "ssh-host-identity/"
141 } else {
142 "trust-anchor/"
143 };
144 if !value.id.starts_with(expected_prefix) {
145 return Err(ContractValidationError(format!(
146 "managed identity resource {} has the wrong ID namespace",
147 value.id
148 )));
149 }
150 if value.resource_kind == "sshHostIdentity" {
151 let public =
152 value.desired.get("publicBinding").and_then(|field| field.as_str()).ok_or_else(
153 || {
154 ContractValidationError(format!(
155 "managed identity resource {} has no publicBinding",
156 value.id
157 ))
158 },
159 )?;
160 if !public.starts_with("public/") || !secret_bindings.contains(public) {
161 return Err(ContractValidationError(format!(
162 "managed identity resource {} references unknown public binding {public}",
163 value.id
164 )));
165 }
166 }
167 }
168 }
169 }
170 for (name, operation_set) in &config.spec.operation_sets {
171 if invalid_safe_id_segment(name)
172 || operation_set.selectors.is_empty()
173 || operation_set.selectors.iter().any(|selector| selector.trim().is_empty())
174 {
175 return Err(ContractValidationError(format!("operation set {name} is invalid")));
176 }
177 let mut unique = BTreeSet::new();
178 if operation_set.selectors.iter().any(|selector| !unique.insert(selector)) {
179 return Err(ContractValidationError(format!("operation set {name} repeats a selector")));
180 }
181 if operation_set.selectors.iter().any(|selector| selector.starts_with("operation:")) {
182 return Err(ContractValidationError(format!(
183 "operation set {name} may not include another operation set"
184 )));
185 }
186 for selector in &operation_set.selectors {
187 let exact_resource_id = selector.contains('/')
188 && !selector.contains('*')
189 && !selector.contains('?')
190 && !selector.starts_with("label:");
191 if exact_resource_id && !resources.contains_key(selector.as_str()) {
192 return Err(ContractValidationError(format!(
193 "operation set {name} refers to unknown resource {selector}"
194 )));
195 }
196 }
197 if let Some(ref intent) = operation_set.lifecycle_intent {
198 if *intent == crate::domain::host_lifecycle::LifecycleIntent::Install
199 && operation_set.artifact_set.is_some()
200 {
201 let art_id = operation_set.artifact_set.as_ref().ok_or_else(|| {
202 ContractValidationError(format!(
203 "operation set {name} has intent {} but is missing an artifactSet",
204 intent.as_str()
205 ))
206 })?;
207 let artifact_set = config.spec.artifact_sets.iter().find(|art| &art.id == art_id);
208 if artifact_set.is_none() {
209 return Err(ContractValidationError(format!(
210 "operation set {name} refers to unknown artifactSet {art_id}"
211 )));
212 }
213 let artifact_set = artifact_set.expect("artifact set presence checked");
214 if *intent == crate::domain::host_lifecycle::LifecycleIntent::Install
215 && artifact_set.installer.is_none()
216 {
217 return Err(ContractValidationError(format!(
218 "operation set {name} has install intent but artifactSet {art_id} has no installer"
219 )));
220 }
221 } else if operation_set.artifact_set.is_some() {
222 return Err(ContractValidationError(format!(
223 "operation set {name} has intent {} which does not support artifactSet",
224 intent.as_str()
225 )));
226 }
227 } else if operation_set.artifact_set.is_some() {
228 return Err(ContractValidationError(format!(
229 "operation set {name} has no lifecycle intent but specifies an artifactSet"
230 )));
231 }
232 }
233
234 for resource in &config.spec.resources {
235 if let Resource::DeploymentTarget(target) = resource {
236 let name = target.id.strip_prefix("deployment-target/").unwrap_or("");
237 let metadata = &target.metadata;
238 let is_valid_system = ["x86_64-linux", "aarch64-linux", "x86_64-darwin", "aarch64-darwin"]
239 .contains(&metadata.system.as_str());
240 if invalid_safe_id_segment(name)
241 || invalid_text(&metadata.user, 64)
242 || !is_valid_system
243 || !["nixos", "darwin", "home-manager", "wsl", "installer", ""]
244 .contains(&metadata.class.as_str())
245 {
246 return Err(ContractValidationError(format!(
247 "deployment target {} has invalid identity or typed metadata",
248 target.id
249 )));
250 }
251 }
252 }
256 Ok(())
257}
258
259fn validate_provider_executable_declaration(
260 provider: &super::ProviderInstance,
261) -> Result<(), ContractValidationError> {
262 match (&provider.command, &provider.protocol) {
263 (None, None) => {}
264 (Some(command), Some(protocol)) => {
265 if invalid_absolute_command(command) {
266 return Err(ContractValidationError(format!(
267 "provider {} command must be an absolute executable path",
268 provider.id
269 )));
270 }
271 if protocol != PROVIDER_PROTOCOL_V1 {
272 return Err(ContractValidationError(format!(
273 "provider {} protocol must be {PROVIDER_PROTOCOL_V1}",
274 provider.id
275 )));
276 }
277 }
278 _ => {
279 return Err(ContractValidationError(format!(
280 "provider {} must declare both command and protocol for external execution",
281 provider.id
282 )));
283 }
284 }
285 for argument in &provider.arguments {
286 if argument.is_empty() || argument.chars().any(char::is_control) {
287 return Err(ContractValidationError(format!(
288 "provider {} has an invalid fixed argument",
289 provider.id
290 )));
291 }
292 }
293 Ok(())
294}
295
296fn validate_secret_resolver_declaration(
297 resolver: &SecretResolverInstance,
298) -> Result<(), ContractValidationError> {
299 if resolver.id.is_empty()
300 || resolver.id.chars().any(char::is_control)
301 || invalid_absolute_command(&resolver.command)
302 {
303 return Err(ContractValidationError(format!(
304 "secret resolver {} has an invalid id or absolute command",
305 resolver.id
306 )));
307 }
308 if resolver.protocol != SECRET_PROTOCOL_V1 {
309 return Err(ContractValidationError(format!(
310 "secret resolver {} protocol must be {SECRET_PROTOCOL_V1}",
311 resolver.id
312 )));
313 }
314 for argument in &resolver.arguments {
315 if argument.is_empty() || argument.chars().any(char::is_control) {
316 return Err(ContractValidationError(format!(
317 "secret resolver {} has an invalid fixed argument",
318 resolver.id
319 )));
320 }
321 }
322 Ok(())
323}
324
325fn invalid_absolute_command(command: &str) -> bool {
326 command.is_empty()
327 || !command.starts_with('/')
328 || command.chars().any(char::is_control)
329 || command.contains('\0')
330}
331
332fn invalid_text(value: &str, max_len: usize) -> bool {
333 value.is_empty() || value.len() > max_len || value.chars().any(char::is_control)
334}
335
336fn invalid_safe_id_segment(value: &str) -> bool {
337 value.is_empty()
338 || value.len() > 128
339 || !value
340 .chars()
341 .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character))
342}