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 value.resource_kind == "headscalePreauthKey" && value.secret_binding.is_some() {
133 return Err(ContractValidationError(format!(
134 "headscalePreauthKey resource {} specifies secretBinding; preauth keys deliver single-use keys to ephemeral run bindings",
135 value.id
136 )));
137 }
138 if matches!(value.resource_kind.as_str(), "sshHostIdentity" | "trustAnchor") {
139 if providers.get(value.provider.as_str()).copied() != Some("identity") {
140 return Err(ContractValidationError(format!(
141 "managed identity resource {} must be owned by an identity provider",
142 value.id
143 )));
144 }
145 let expected_prefix = if value.resource_kind == "sshHostIdentity" {
146 "ssh-host-identity/"
147 } else {
148 "trust-anchor/"
149 };
150 if !value.id.starts_with(expected_prefix) {
151 return Err(ContractValidationError(format!(
152 "managed identity resource {} has the wrong ID namespace",
153 value.id
154 )));
155 }
156 if value.resource_kind == "sshHostIdentity" {
157 let public =
158 value.desired.get("publicBinding").and_then(|field| field.as_str()).ok_or_else(
159 || {
160 ContractValidationError(format!(
161 "managed identity resource {} has no publicBinding",
162 value.id
163 ))
164 },
165 )?;
166 if !public.starts_with("public/") || !secret_bindings.contains(public) {
167 return Err(ContractValidationError(format!(
168 "managed identity resource {} references unknown public binding {public}",
169 value.id
170 )));
171 }
172 }
173 }
174 }
175 }
176 for (name, operation_set) in &config.spec.operation_sets {
177 if invalid_safe_id_segment(name)
178 || operation_set.selectors.is_empty()
179 || operation_set.selectors.iter().any(|selector| selector.trim().is_empty())
180 {
181 return Err(ContractValidationError(format!("operation set {name} is invalid")));
182 }
183 let mut unique = BTreeSet::new();
184 if operation_set.selectors.iter().any(|selector| !unique.insert(selector)) {
185 return Err(ContractValidationError(format!("operation set {name} repeats a selector")));
186 }
187 if operation_set.selectors.iter().any(|selector| selector.starts_with("operation:")) {
188 return Err(ContractValidationError(format!(
189 "operation set {name} may not include another operation set"
190 )));
191 }
192 for selector in &operation_set.selectors {
193 let exact_resource_id = selector.contains('/')
194 && !selector.contains('*')
195 && !selector.contains('?')
196 && !selector.starts_with("label:");
197 if exact_resource_id && !resources.contains_key(selector.as_str()) {
198 return Err(ContractValidationError(format!(
199 "operation set {name} refers to unknown resource {selector}"
200 )));
201 }
202 }
203 if let Some(ref intent) = operation_set.lifecycle_intent {
204 if *intent == crate::domain::host_lifecycle::LifecycleIntent::Install
205 && operation_set.artifact_set.is_some()
206 {
207 let art_id = operation_set.artifact_set.as_ref().ok_or_else(|| {
208 ContractValidationError(format!(
209 "operation set {name} has intent {} but is missing an artifactSet",
210 intent.as_str()
211 ))
212 })?;
213 let artifact_set = config.spec.artifact_sets.iter().find(|art| &art.id == art_id);
214 if artifact_set.is_none() {
215 return Err(ContractValidationError(format!(
216 "operation set {name} refers to unknown artifactSet {art_id}"
217 )));
218 }
219 let artifact_set = artifact_set.expect("artifact set presence checked");
220 if *intent == crate::domain::host_lifecycle::LifecycleIntent::Install
221 && artifact_set.installer.is_none()
222 {
223 return Err(ContractValidationError(format!(
224 "operation set {name} has install intent but artifactSet {art_id} has no installer"
225 )));
226 }
227 } else if operation_set.artifact_set.is_some() {
228 return Err(ContractValidationError(format!(
229 "operation set {name} has intent {} which does not support artifactSet",
230 intent.as_str()
231 )));
232 }
233 } else if operation_set.artifact_set.is_some() {
234 return Err(ContractValidationError(format!(
235 "operation set {name} has no lifecycle intent but specifies an artifactSet"
236 )));
237 }
238 }
239
240 for resource in &config.spec.resources {
241 if let Resource::DeploymentTarget(target) = resource {
242 let name = target.id.strip_prefix("deployment-target/").unwrap_or("");
243 let metadata = &target.metadata;
244 let is_valid_system = ["x86_64-linux", "aarch64-linux", "x86_64-darwin", "aarch64-darwin"]
245 .contains(&metadata.system.as_str());
246 if invalid_safe_id_segment(name)
247 || invalid_text(&metadata.user, 64)
248 || !is_valid_system
249 || !["nixos", "darwin", "home-manager", "wsl", "installer", ""]
250 .contains(&metadata.class.as_str())
251 {
252 return Err(ContractValidationError(format!(
253 "deployment target {} has invalid identity or typed metadata",
254 target.id
255 )));
256 }
257 }
258 }
262 Ok(())
263}
264
265fn validate_provider_executable_declaration(
266 provider: &super::ProviderInstance,
267) -> Result<(), ContractValidationError> {
268 match (&provider.command, &provider.protocol) {
269 (None, None) => {}
270 (Some(command), Some(protocol)) => {
271 if invalid_absolute_command(command) {
272 return Err(ContractValidationError(format!(
273 "provider {} command must be an absolute executable path",
274 provider.id
275 )));
276 }
277 if protocol != PROVIDER_PROTOCOL_V1 {
278 return Err(ContractValidationError(format!(
279 "provider {} protocol must be {PROVIDER_PROTOCOL_V1}",
280 provider.id
281 )));
282 }
283 }
284 _ => {
285 return Err(ContractValidationError(format!(
286 "provider {} must declare both command and protocol for external execution",
287 provider.id
288 )));
289 }
290 }
291 for argument in &provider.arguments {
292 if argument.is_empty() || argument.chars().any(char::is_control) {
293 return Err(ContractValidationError(format!(
294 "provider {} has an invalid fixed argument",
295 provider.id
296 )));
297 }
298 }
299 Ok(())
300}
301
302fn validate_secret_resolver_declaration(
303 resolver: &SecretResolverInstance,
304) -> Result<(), ContractValidationError> {
305 if resolver.id.is_empty()
306 || resolver.id.chars().any(char::is_control)
307 || invalid_absolute_command(&resolver.command)
308 {
309 return Err(ContractValidationError(format!(
310 "secret resolver {} has an invalid id or absolute command",
311 resolver.id
312 )));
313 }
314 if resolver.protocol != SECRET_PROTOCOL_V1 {
315 return Err(ContractValidationError(format!(
316 "secret resolver {} protocol must be {SECRET_PROTOCOL_V1}",
317 resolver.id
318 )));
319 }
320 for argument in &resolver.arguments {
321 if argument.is_empty() || argument.chars().any(char::is_control) {
322 return Err(ContractValidationError(format!(
323 "secret resolver {} has an invalid fixed argument",
324 resolver.id
325 )));
326 }
327 }
328 Ok(())
329}
330
331fn invalid_absolute_command(command: &str) -> bool {
332 command.is_empty()
333 || !command.starts_with('/')
334 || command.chars().any(char::is_control)
335 || command.contains('\0')
336}
337
338fn invalid_text(value: &str, max_len: usize) -> bool {
339 value.is_empty() || value.len() > max_len || value.chars().any(char::is_control)
340}
341
342fn invalid_safe_id_segment(value: &str) -> bool {
343 value.is_empty()
344 || value.len() > 128
345 || !value
346 .chars()
347 .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character))
348}