Skip to main content

nxd_core/domain/contract/
validation.rs

1use 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		// When secret resolvers are declared, every binding must name one.
53		// Empty secretResolvers remains valid for pure fixture configs that do
54		// not compose an external secret executable.
55		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			// Executable command/protocol pairing is composition-time
113			// (packaging fills optional packaged providers). Canonical validate
114			// only requires the provider instance to exist.
115			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			// Vendor field schemas are provider-owned (handshake resource_kinds).
132			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		// PVE/PBS/Headscale kinds are provider-owned envelopes; field schemas are
253		// enforced at plan time via advertised resource_kinds. Core only checks
254		// deployment-target identity above and common envelope identity earlier.
255	}
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}