Skip to main content

nxd_core/adapters/
external_runtime.rs

1//! Compose linked first-party providers and configured external runtimes.
2//!
3//! External executables are identity-bound by path and content. Linked providers
4//! are supplied explicitly by the CLI composition root.
5
6use crate::contract::canonical_json;
7use crate::contract::{
8	CanonicalConfig, PROVIDER_PROTOCOL_V1, ProviderInstance, SECRET_PROTOCOL_V1,
9	SecretResolverInstance,
10};
11use crate::plan::PlanEnvelope;
12use crate::plan::sha256_hex_bytes;
13use crate::plugin_protocol::v1;
14use crate::ports::provider::Provider;
15use serde_json::json;
16use std::collections::BTreeMap;
17use std::ffi::OsString;
18use std::fs;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21
22type ProviderFactory =
23	dyn Fn(&[OsString]) -> Result<Arc<dyn Provider>, CompositionError> + Send + Sync;
24
25/// Explicit linked-provider factories owned by the CLI composition root.
26#[derive(Clone, Default)]
27pub struct LinkedProviderRegistry {
28	factories: BTreeMap<String, Arc<ProviderFactory>>,
29}
30
31impl LinkedProviderRegistry {
32	pub fn register<F>(&mut self, kind: impl Into<String>, factory: F) -> Result<(), CompositionError>
33	where
34		F: Fn(&[OsString]) -> Result<Arc<dyn Provider>, CompositionError> + Send + Sync + 'static,
35	{
36		let kind = kind.into();
37		if kind.is_empty() || self.factories.insert(kind.clone(), Arc::new(factory)).is_some() {
38			return Err(CompositionError::InvalidConfig(format!(
39				"duplicate linked provider kind {kind}"
40			)));
41		}
42		Ok(())
43	}
44
45	fn create(
46		&self,
47		kind: &str,
48		arguments: &[OsString],
49	) -> Result<Option<Arc<dyn Provider>>, CompositionError> {
50		self.factories.get(kind).map(|factory| factory(arguments)).transpose()
51	}
52}
53
54/// Immutable identity of a composed external executable.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct ExecutableIdentity {
57	pub path: PathBuf,
58	/// `sha256:<hex>` of the executable bytes at composition time.
59	pub digest: String,
60	pub protocol: String,
61}
62
63/// One provider instance registered for protocol-driven sessions.
64#[derive(Clone)]
65pub struct ProviderExecutable {
66	pub instance_id: String,
67	pub kind: String,
68	pub identity: ExecutableIdentity,
69	pub arguments: Vec<OsString>,
70	pub linked: Option<Arc<dyn Provider>>,
71	pub expected_version: Option<String>,
72	pub expected_runtime_digest: Option<String>,
73}
74
75impl std::fmt::Debug for ProviderExecutable {
76	fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77		formatter
78			.debug_struct("ProviderExecutable")
79			.field("instance_id", &self.instance_id)
80			.field("kind", &self.kind)
81			.field("identity", &self.identity)
82			.field("arguments", &self.arguments)
83			.field("linked", &self.linked.is_some())
84			.field("expected_version", &self.expected_version)
85			.field("expected_runtime_digest", &self.expected_runtime_digest)
86			.finish()
87	}
88}
89
90/// One secret resolver/sink executable registered for action-scoped resolution.
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct SecretExecutable {
93	pub resolver_id: String,
94	pub identity: ExecutableIdentity,
95	pub arguments: Vec<OsString>,
96}
97
98/// Complete external runtime graph for one evaluated configuration.
99#[derive(Clone, Debug, Default)]
100pub struct ExternalRuntimeGraph {
101	/// Provider instance id → executable.
102	pub providers: BTreeMap<String, ProviderExecutable>,
103	/// Secret resolver id → executable.
104	pub secret_resolvers: BTreeMap<String, SecretExecutable>,
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
108pub enum CompositionError {
109	InvalidConfig(String),
110	MissingExecutable(String),
111	CapabilityUnavailable(String),
112}
113
114impl std::fmt::Display for CompositionError {
115	fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116		match self {
117			Self::InvalidConfig(message)
118			| Self::MissingExecutable(message)
119			| Self::CapabilityUnavailable(message) => write!(formatter, "{message}"),
120		}
121	}
122}
123
124impl std::error::Error for CompositionError {}
125
126impl ExternalRuntimeGraph {
127	/// Rehydrate only the executable identities authorized by a reviewed plan.
128	/// Every executable is reread and its digest checked before mutation.
129	pub fn from_plan(plan: &PlanEnvelope) -> Result<Self, CompositionError> {
130		Self::from_plan_with_linked(plan, &LinkedProviderRegistry::default())
131	}
132
133	pub fn from_plan_with_linked(
134		plan: &PlanEnvelope,
135		linked: &LinkedProviderRegistry,
136	) -> Result<Self, CompositionError> {
137		let mut graph = Self::default();
138		for runtime in &plan.spec.provider_runtimes {
139			let arguments = runtime.arguments.iter().map(OsString::from).collect::<Vec<_>>();
140			if runtime.nxd_revision.is_some() {
141				if runtime.nxd_revision.as_deref() != Some(env!("NXD_GIT_REVISION")) {
142					return Err(CompositionError::CapabilityUnavailable(format!(
143						"linked provider {} NXD revision differs from the reviewed plan",
144						runtime.provider_instance
145					)));
146				}
147				let provider = linked.create(&runtime.provider_kind, &arguments)?.ok_or_else(|| {
148					CompositionError::CapabilityUnavailable(format!(
149						"linked provider kind {} is not registered",
150						runtime.provider_kind
151					))
152				})?;
153				graph.providers.insert(
154					runtime.provider_instance.clone(),
155					ProviderExecutable {
156						instance_id: runtime.provider_instance.clone(),
157						kind: runtime.provider_kind.clone(),
158						identity: ExecutableIdentity {
159							path: PathBuf::new(),
160							digest: runtime.runtime_digest.clone(),
161							protocol: "linked".into(),
162						},
163						arguments,
164						linked: Some(provider),
165						expected_version: Some(runtime.provider_version.clone()),
166						expected_runtime_digest: Some(runtime.runtime_digest.clone()),
167					},
168				);
169				continue;
170			}
171			let protocol = runtime.protocol.as_deref().ok_or_else(|| {
172				CompositionError::InvalidConfig(format!(
173					"external provider {} is missing protocol",
174					runtime.provider_instance
175				))
176			})?;
177			let executable_path = runtime.executable_path.as_deref().ok_or_else(|| {
178				CompositionError::InvalidConfig(format!(
179					"external provider {} is missing executablePath",
180					runtime.provider_instance
181				))
182			})?;
183			let identity = executable_identity(
184				executable_path,
185				protocol,
186				&format!("provider {}", runtime.provider_instance),
187			)?;
188			if Some(identity.digest.as_str()) != runtime.executable_digest.as_deref()
189				|| identity.digest != runtime.runtime_digest
190			{
191				return Err(CompositionError::CapabilityUnavailable(format!(
192					"provider {} executable digest differs from the reviewed plan",
193					runtime.provider_instance
194				)));
195			}
196			graph.providers.insert(
197				runtime.provider_instance.clone(),
198				ProviderExecutable {
199					instance_id: runtime.provider_instance.clone(),
200					kind: runtime.provider_kind.clone(),
201					identity,
202					arguments,
203					linked: None,
204					expected_version: Some(runtime.provider_version.clone()),
205					expected_runtime_digest: None,
206				},
207			);
208		}
209		for runtime in &plan.spec.secret_runtimes {
210			let identity = executable_identity(
211				&runtime.executable_path,
212				&runtime.protocol,
213				&format!("secret resolver {}", runtime.resolver_id),
214			)?;
215			if identity.digest != runtime.executable_digest {
216				return Err(CompositionError::CapabilityUnavailable(format!(
217					"secret resolver {} executable digest differs from the reviewed plan",
218					runtime.resolver_id
219				)));
220			}
221			graph.secret_resolvers.insert(
222				runtime.resolver_id.clone(),
223				SecretExecutable {
224					resolver_id: runtime.resolver_id.clone(),
225					identity,
226					arguments: runtime.arguments.iter().map(OsString::from).collect(),
227				},
228			);
229		}
230		Ok(graph)
231	}
232
233	/// Build the graph from evaluated canonical configuration.
234	///
235	/// Declared provider executable commands and secret resolvers are
236	/// verified immediately.
237	pub fn from_canonical(config: &CanonicalConfig) -> Result<Self, CompositionError> {
238		Self::from_canonical_with_linked(config, &LinkedProviderRegistry::default())
239	}
240
241	pub fn from_canonical_with_linked(
242		config: &CanonicalConfig,
243		linked: &LinkedProviderRegistry,
244	) -> Result<Self, CompositionError> {
245		let mut identities = BTreeMap::new();
246		let mut providers = BTreeMap::new();
247		for instance in &config.spec.provider_instances {
248			if let Some(executable) = provider_executable(instance, &mut identities, linked)?
249				&& providers.insert(executable.instance_id.clone(), executable).is_some()
250			{
251				return Err(CompositionError::InvalidConfig(format!(
252					"duplicate provider instance {}",
253					instance.id
254				)));
255			}
256		}
257
258		let mut secret_resolvers = BTreeMap::new();
259		for resolver in &config.spec.secret_resolvers {
260			let executable = secret_executable(resolver, &mut identities)?;
261			if secret_resolvers.insert(executable.resolver_id.clone(), executable).is_some() {
262				return Err(CompositionError::InvalidConfig(format!(
263					"duplicate secret resolver {}",
264					resolver.id
265				)));
266			}
267		}
268
269		// Fail closed when a binding names a resolver that was not composed.
270		for binding in &config.spec.secret_bindings {
271			if !secret_resolvers.contains_key(&binding.resolver) && !secret_resolvers.is_empty() {
272				return Err(CompositionError::CapabilityUnavailable(format!(
273					"secret binding {} requires secret resolver {} which is not configured",
274					binding.id, binding.resolver
275				)));
276			}
277		}
278
279		Ok(Self { providers, secret_resolvers })
280	}
281
282	pub fn provider(&self, instance_id: &str) -> Option<&ProviderExecutable> {
283		self.providers.get(instance_id)
284	}
285
286	pub fn secret_resolver(&self, resolver_id: &str) -> Option<&SecretExecutable> {
287		self.secret_resolvers.get(resolver_id)
288	}
289
290	/// Require a composed provider instance.
291	pub fn require_provider(
292		&self,
293		instance_id: &str,
294	) -> Result<&ProviderExecutable, CompositionError> {
295		self.provider(instance_id).ok_or_else(|| {
296			CompositionError::CapabilityUnavailable(format!(
297				"provider instance {instance_id} is not composed"
298			))
299		})
300	}
301
302	/// Require a secret resolver executable for action-scoped resolution.
303	pub fn require_secret_resolver(
304		&self,
305		resolver_id: &str,
306	) -> Result<&SecretExecutable, CompositionError> {
307		self.secret_resolver(resolver_id).ok_or_else(|| {
308			CompositionError::CapabilityUnavailable(format!(
309				"secret resolver {resolver_id} is not configured"
310			))
311		})
312	}
313}
314
315fn provider_executable(
316	instance: &ProviderInstance,
317	identities: &mut BTreeMap<(String, String), ExecutableIdentity>,
318	linked: &LinkedProviderRegistry,
319) -> Result<Option<ProviderExecutable>, CompositionError> {
320	match (&instance.command, &instance.protocol) {
321		(None, None) => {
322			let arguments = instance.arguments.iter().map(OsString::from).collect::<Vec<_>>();
323			let Some(provider) = linked.create(&instance.kind, &arguments)? else { return Ok(None) };
324			Ok(Some(ProviderExecutable {
325				instance_id: instance.id.clone(),
326				kind: instance.kind.clone(),
327				identity: ExecutableIdentity {
328					path: PathBuf::new(),
329					digest: linked_identity_digest(&instance.kind),
330					protocol: "linked".into(),
331				},
332				arguments,
333				linked: Some(provider),
334				expected_version: None,
335				expected_runtime_digest: None,
336			}))
337		}
338		(Some(command), Some(protocol)) => {
339			if protocol != PROVIDER_PROTOCOL_V1 {
340				return Err(CompositionError::InvalidConfig(format!(
341					"provider {} protocol must be {PROVIDER_PROTOCOL_V1}",
342					instance.id
343				)));
344			}
345			let identity = executable_identity_cached(
346				identities,
347				command,
348				protocol,
349				&format!("provider {}", instance.id),
350			)?;
351			Ok(Some(ProviderExecutable {
352				instance_id: instance.id.clone(),
353				kind: instance.kind.clone(),
354				identity,
355				arguments: instance.arguments.iter().map(OsString::from).collect(),
356				linked: None,
357				expected_version: None,
358				expected_runtime_digest: None,
359			}))
360		}
361		_ => Err(CompositionError::InvalidConfig(format!(
362			"provider {} must declare both command and protocol",
363			instance.id
364		))),
365	}
366}
367
368fn linked_identity_digest(kind: &str) -> String {
369	let identity = format!("{}\n{kind}", env!("NXD_GIT_REVISION"));
370	format!("sha256:{}", sha256_hex_bytes(identity.as_bytes()))
371}
372
373pub fn linked_runtime_digest(
374	kind: &str,
375	provider_version: &str,
376	capabilities: impl IntoIterator<Item = impl AsRef<str>>,
377	resource_kinds: &[v1::ResourceKindDescriptor],
378) -> Result<String, serde_json::Error> {
379	let mut capabilities =
380		capabilities.into_iter().map(|value| value.as_ref().to_string()).collect::<Vec<_>>();
381	capabilities.sort();
382	let mut resource_schema_digests = resource_kinds
383		.iter()
384		.map(|descriptor| {
385			format!("{}:sha256:{}", descriptor.kind, sha256_hex_bytes(&descriptor.schema_json))
386		})
387		.collect::<Vec<_>>();
388	resource_schema_digests.sort();
389	Ok(format!(
390		"sha256:{}",
391		sha256_hex_bytes(
392			canonical_json(&json!({
393				"nxdRevision": env!("NXD_GIT_REVISION"),
394				"providerKind": kind,
395				"providerVersion": provider_version,
396				"capabilities": capabilities,
397				"resourceSchemaDigests": resource_schema_digests,
398			}))?
399			.as_bytes()
400		)
401	))
402}
403
404fn secret_executable(
405	resolver: &SecretResolverInstance,
406	identities: &mut BTreeMap<(String, String), ExecutableIdentity>,
407) -> Result<SecretExecutable, CompositionError> {
408	if resolver.protocol != SECRET_PROTOCOL_V1 {
409		return Err(CompositionError::InvalidConfig(format!(
410			"secret resolver {} protocol must be {SECRET_PROTOCOL_V1}",
411			resolver.id
412		)));
413	}
414	let identity = executable_identity_cached(
415		identities,
416		&resolver.command,
417		&resolver.protocol,
418		&format!("secret resolver {}", resolver.id),
419	)?;
420	Ok(SecretExecutable {
421		resolver_id: resolver.id.clone(),
422		identity,
423		arguments: resolver.arguments.iter().map(OsString::from).collect(),
424	})
425}
426
427fn executable_identity_cached(
428	identities: &mut BTreeMap<(String, String), ExecutableIdentity>,
429	command: &str,
430	protocol: &str,
431	label: &str,
432) -> Result<ExecutableIdentity, CompositionError> {
433	let key = (command.to_string(), protocol.to_string());
434	if let Some(identity) = identities.get(&key) {
435		return Ok(identity.clone());
436	}
437	let identity = executable_identity(command, protocol, label)?;
438	identities.insert(key, identity.clone());
439	Ok(identity)
440}
441
442fn executable_identity(
443	command: &str,
444	protocol: &str,
445	label: &str,
446) -> Result<ExecutableIdentity, CompositionError> {
447	let path = PathBuf::from(command);
448	if !path.is_absolute() {
449		return Err(CompositionError::InvalidConfig(format!(
450			"{label} command must be an absolute path: {command}"
451		)));
452	}
453	if !path.is_file() {
454		return Err(CompositionError::MissingExecutable(format!(
455			"{label} executable is missing or not a file: {}",
456			path.display()
457		)));
458	}
459	let digest = file_digest(&path).map_err(|error| {
460		CompositionError::MissingExecutable(format!(
461			"{label} executable at {} could not be read for identity: {error}",
462			path.display()
463		))
464	})?;
465	Ok(ExecutableIdentity { path, digest, protocol: protocol.to_string() })
466}
467
468fn file_digest(path: &Path) -> Result<String, String> {
469	let bytes = fs::read(path).map_err(|error| error.to_string())?;
470	Ok(format!("sha256:{}", sha256_hex_bytes(&bytes)))
471}
472
473#[cfg(test)]
474#[path = "external_runtime_tests.rs"]
475mod tests;