Skip to main content

nxd_core/adapters/
plugin_supervisor.rs

1use crate::plugin_protocol::{
2	MAX_EVENTS, MAX_MESSAGE_BYTES, PROTOCOL_VERSION, v1, validate_canonical_json, validate_metadata,
3};
4use crate::ports::provider::Provider;
5use crate::ports::provider::{ProviderErrorCategory, ProviderFailure};
6use futures_util::{StreamExt, stream::BoxStream};
7use hyper_util::rt::TokioIo;
8use std::collections::BTreeSet;
9use std::collections::HashMap;
10use std::ffi::OsString;
11use std::fs;
12use std::os::unix::fs::PermissionsExt;
13use std::path::{Path, PathBuf};
14use std::process::Stdio;
15use std::sync::Arc;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::time::{Duration, SystemTime, UNIX_EPOCH};
18use tokio::io::AsyncReadExt;
19use tokio::net::UnixStream;
20use tokio::process::{Child, Command};
21use tonic::transport::{Channel, Endpoint};
22use tower::service_fn;
23use v1::provider_service_client::ProviderServiceClient;
24
25static SESSION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
26
27#[derive(Clone, Debug)]
28pub struct PluginCommand {
29	pub program: PathBuf,
30	pub arguments: Vec<OsString>,
31	pub mode: String,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub enum PluginError {
36	RunDirectory,
37	InvalidCommand,
38	Spawn,
39	StartupTimeout,
40	ProcessExited,
41	Incompatible,
42	Protocol,
43	ProtocolViolation(&'static str),
44	RpcTimeout,
45	EventFlood,
46	VerificationFailed,
47	ProviderReported(ProviderFailure),
48	Cancelled,
49}
50
51impl std::fmt::Display for PluginError {
52	fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53		write!(formatter, "provider plugin error: {self:?}")
54	}
55}
56
57impl std::error::Error for PluginError {}
58
59pub struct PluginSession {
60	child: Option<Child>,
61	client: ProviderClient,
62	run_dir: Option<PrivateRunDirectory>,
63	stderr_task: Option<tokio::task::JoinHandle<()>>,
64	request_timeout: Duration,
65	provider_instance: String,
66	provider_version: String,
67	capabilities: BTreeSet<String>,
68	/// Provider-owned resource kinds and schemas from handshake.
69	resource_kinds: Vec<v1::ResourceKindDescriptor>,
70}
71
72#[derive(Clone)]
73enum ProviderClient {
74	External(ProviderServiceClient<Channel>),
75	Linked(Arc<dyn Provider>),
76}
77
78impl ProviderClient {
79	async fn get_metadata(
80		&mut self,
81		request: v1::GetMetadataRequest,
82	) -> Result<v1::GetMetadataResponse, tonic::Status> {
83		match self {
84			Self::External(client) => client.get_metadata(request).await.map(tonic::Response::into_inner),
85			Self::Linked(provider) => provider.get_metadata(request).await,
86		}
87	}
88
89	async fn observe(
90		&mut self,
91		request: v1::ObserveRequest,
92	) -> Result<v1::ObserveResponse, tonic::Status> {
93		match self {
94			Self::External(client) => client.observe(request).await.map(tonic::Response::into_inner),
95			Self::Linked(provider) => provider.observe(request).await,
96		}
97	}
98
99	async fn capture(
100		&mut self,
101		request: v1::CaptureRequest,
102	) -> Result<v1::CaptureResponse, tonic::Status> {
103		match self {
104			Self::External(client) => client.capture(request).await.map(tonic::Response::into_inner),
105			Self::Linked(provider) => provider.capture(request).await,
106		}
107	}
108
109	async fn plan(&mut self, request: v1::PlanRequest) -> Result<v1::PlanResponse, tonic::Status> {
110		match self {
111			Self::External(client) => client.plan(request).await.map(tonic::Response::into_inner),
112			Self::Linked(provider) => provider.plan(request).await,
113		}
114	}
115
116	async fn apply(
117		&mut self,
118		request: v1::ApplyRequest,
119	) -> Result<BoxStream<'static, Result<v1::ApplyResponse, tonic::Status>>, tonic::Status> {
120		match self {
121			Self::External(client) => {
122				client.apply(request).await.map(|response| response.into_inner().boxed())
123			}
124			Self::Linked(provider) => provider.apply(request).await,
125		}
126	}
127
128	async fn verify(
129		&mut self,
130		request: v1::VerifyRequest,
131	) -> Result<v1::VerifyResponse, tonic::Status> {
132		match self {
133			Self::External(client) => client.verify(request).await.map(tonic::Response::into_inner),
134			Self::Linked(provider) => provider.verify(request).await,
135		}
136	}
137
138	async fn cancel(
139		&mut self,
140		request: v1::CancelRequest,
141	) -> Result<v1::CancelResponse, tonic::Status> {
142		match self {
143			Self::External(client) => client.cancel(request).await.map(tonic::Response::into_inner),
144			Self::Linked(provider) => provider.cancel(request).await,
145		}
146	}
147}
148
149impl PluginSession {
150	pub async fn start(
151		command: PluginCommand,
152		expected_provider_kind: &str,
153		provider_instance: &str,
154		startup_timeout: Duration,
155		request_timeout: Duration,
156	) -> Result<Self, PluginError> {
157		if !command.program.is_absolute()
158			|| !command.program.is_file()
159			|| expected_provider_kind.is_empty()
160			|| provider_instance.is_empty()
161		{
162			return Err(PluginError::InvalidCommand);
163		}
164		let run_dir = create_run_dir()?;
165		let socket = run_dir.0.join("provider.sock");
166		let mut child_command = Command::new(command.program);
167		child_command
168			.env_clear()
169			.args(command.arguments)
170			.arg("--socket")
171			.arg(&socket)
172			.arg("--mode")
173			.arg(command.mode)
174			.stdin(Stdio::null())
175			.stdout(Stdio::null())
176			.stderr(Stdio::piped())
177			.kill_on_drop(true);
178		let mut child = child_command.spawn().map_err(|_| PluginError::Spawn)?;
179		let mut stderr = child.stderr.take().ok_or(PluginError::Spawn)?;
180		let stderr_task = tokio::spawn(async move {
181			let mut output = Vec::new();
182			let _ = (&mut stderr).take(8192).read_to_end(&mut output).await;
183			output.fill(0);
184		});
185		let channel = connect(&socket, &mut child, startup_timeout).await?;
186		let mut session = Self {
187			child: Some(child),
188			client: ProviderClient::External(
189				ProviderServiceClient::new(channel)
190					.max_decoding_message_size(MAX_MESSAGE_BYTES)
191					.max_encoding_message_size(MAX_MESSAGE_BYTES),
192			),
193			run_dir: Some(run_dir),
194			stderr_task: Some(stderr_task),
195			request_timeout,
196			provider_instance: provider_instance.to_string(),
197			provider_version: String::new(),
198			capabilities: BTreeSet::new(),
199			resource_kinds: Vec::new(),
200		};
201		session.handshake(expected_provider_kind).await?;
202		Ok(session)
203	}
204
205	pub async fn linked(
206		provider: Arc<dyn Provider>,
207		expected_provider_kind: &str,
208		provider_instance: &str,
209		request_timeout: Duration,
210	) -> Result<Self, PluginError> {
211		if expected_provider_kind.is_empty() || provider_instance.is_empty() {
212			return Err(PluginError::InvalidCommand);
213		}
214		let mut session = Self {
215			child: None,
216			client: ProviderClient::Linked(provider),
217			run_dir: None,
218			stderr_task: None,
219			request_timeout,
220			provider_instance: provider_instance.to_string(),
221			provider_version: String::new(),
222			capabilities: BTreeSet::new(),
223			resource_kinds: Vec::new(),
224		};
225		session.handshake(expected_provider_kind).await?;
226		Ok(session)
227	}
228
229	async fn handshake(&mut self, expected_provider_kind: &str) -> Result<(), PluginError> {
230		let metadata = tokio::time::timeout(
231			self.request_timeout,
232			self.client.get_metadata(v1::GetMetadataRequest {
233				context: Some(request_context(&self.provider_instance, "metadata", self.request_timeout)),
234			}),
235		)
236		.await
237		.map_err(|_| PluginError::RpcTimeout)?
238		.map_err(plugin_status_error)?;
239		validate_metadata(&metadata).map_err(|_| PluginError::Incompatible)?;
240		let capabilities = metadata.capabilities.iter().map(String::as_str).collect::<BTreeSet<_>>();
241		if !["observe", "plan", "apply", "verify"]
242			.into_iter()
243			.all(|required| capabilities.contains(required))
244		{
245			return Err(PluginError::Incompatible);
246		}
247		if metadata.provider_kind != expected_provider_kind {
248			return Err(PluginError::Incompatible);
249		}
250		self.provider_version = metadata.provider_version;
251		self.capabilities = capabilities.into_iter().map(str::to_string).collect();
252		self.resource_kinds = metadata.resource_kinds;
253		Ok(())
254	}
255
256	pub fn run_dir(&self) -> &Path {
257		self.run_dir.as_ref().map(|directory| directory.0.as_path()).unwrap_or(Path::new(""))
258	}
259
260	pub fn provider_version(&self) -> &str {
261		&self.provider_version
262	}
263
264	pub fn capabilities(&self) -> &BTreeSet<String> {
265		&self.capabilities
266	}
267
268	/// Advertised provider-owned resource kind schemas from handshake.
269	pub fn resource_kinds(&self) -> &[v1::ResourceKindDescriptor] {
270		&self.resource_kinds
271	}
272
273	pub async fn observe(
274		&mut self,
275		provider_config_json: Vec<u8>,
276		selection_json: Vec<u8>,
277		secrets: HashMap<String, Vec<u8>>,
278	) -> Result<Vec<u8>, PluginError> {
279		validate_canonical_json(&provider_config_json).map_err(|_| PluginError::Protocol)?;
280		validate_canonical_json(&selection_json).map_err(|_| PluginError::Protocol)?;
281		let response = tokio::time::timeout(
282			self.request_timeout,
283			self.client.observe(v1::ObserveRequest {
284				context: Some(request_context(&self.provider_instance, "observe", self.request_timeout)),
285				provider_config_json,
286				secrets,
287				selection_json,
288			}),
289		)
290		.await
291		.map_err(|_| PluginError::RpcTimeout)?
292		.map_err(plugin_status_error)?
293		.observations_json;
294		validate_canonical_json(&response).map_err(|_| PluginError::Protocol)?;
295		Ok(response)
296	}
297
298	pub async fn capture(
299		&mut self,
300		provider_config_json: Vec<u8>,
301		resource_ids: Vec<String>,
302		secrets: HashMap<String, Vec<u8>>,
303	) -> Result<Vec<v1::CaptureArtifact>, PluginError> {
304		if !self.capabilities.contains("capture") {
305			return Err(PluginError::Incompatible);
306		}
307		validate_canonical_json(&provider_config_json).map_err(|_| PluginError::Protocol)?;
308		if resource_ids.is_empty()
309			|| resource_ids.len() > MAX_EVENTS
310			|| resource_ids.iter().any(|resource_id| resource_id.is_empty())
311		{
312			return Err(PluginError::Protocol);
313		}
314		let artifacts = tokio::time::timeout(
315			self.request_timeout,
316			self.client.capture(v1::CaptureRequest {
317				context: Some(request_context(&self.provider_instance, "capture", self.request_timeout)),
318				provider_config_json,
319				resource_ids,
320				secrets,
321			}),
322		)
323		.await
324		.map_err(|_| PluginError::RpcTimeout)?
325		.map_err(plugin_status_error)?
326		.artifacts;
327		if artifacts.len() > MAX_EVENTS {
328			return Err(PluginError::Protocol);
329		}
330		Ok(artifacts)
331	}
332
333	pub async fn plan(
334		&mut self,
335		desired_json: Vec<u8>,
336		observations_json: Vec<u8>,
337		secrets: HashMap<String, Vec<u8>>,
338	) -> Result<Vec<v1::PlannedAction>, PluginError> {
339		self
340			.plan_with_context(
341				desired_json,
342				observations_json,
343				secrets,
344				HashMap::new(),
345				v1::EnrollmentStrategy::Unspecified,
346				v1::PlanningMode::Unspecified,
347			)
348			.await
349	}
350
351	pub async fn plan_with_context(
352		&mut self,
353		desired_json: Vec<u8>,
354		observations_json: Vec<u8>,
355		secrets: HashMap<String, Vec<u8>>,
356		resource_contexts: HashMap<String, v1::ResourcePlanContext>,
357		enrollment_strategy: v1::EnrollmentStrategy,
358		planning_mode: v1::PlanningMode,
359	) -> Result<Vec<v1::PlannedAction>, PluginError> {
360		validate_canonical_json(&desired_json).map_err(|_| PluginError::Protocol)?;
361		validate_canonical_json(&observations_json).map_err(|_| PluginError::Protocol)?;
362		let actions = tokio::time::timeout(
363			self.request_timeout,
364			self.client.plan(v1::PlanRequest {
365				context: Some(request_context(&self.provider_instance, "plan", self.request_timeout)),
366				desired_json,
367				observations_json,
368				secrets,
369				resource_contexts,
370				enrollment_strategy: enrollment_strategy.into(),
371				planning_mode: planning_mode.into(),
372			}),
373		)
374		.await
375		.map_err(|_| PluginError::RpcTimeout)?
376		.map_err(plugin_status_error)?
377		.actions;
378		if actions.len() > MAX_EVENTS {
379			return Err(PluginError::Protocol);
380		}
381		for action in &actions {
382			if action.action_id.is_empty()
383				|| action.resource_id.is_empty()
384				|| action.secret_references.len() > MAX_EVENTS
385			{
386				return Err(PluginError::Protocol);
387			}
388			validate_canonical_json(&action.details_json).map_err(|_| PluginError::Protocol)?;
389		}
390		Ok(actions)
391	}
392
393	pub async fn apply(
394		&mut self,
395		action: v1::PlannedAction,
396		secrets: HashMap<String, Vec<u8>>,
397		run_id: &str,
398	) -> Result<Vec<v1::ApplyResponse>, PluginError> {
399		self.apply_with_progress(action, secrets, run_id, crate::Logger::silent()).await
400	}
401
402	pub async fn apply_with_progress(
403		&mut self,
404		action: v1::PlannedAction,
405		secrets: HashMap<String, Vec<u8>>,
406		run_id: &str,
407		logger: crate::Logger,
408	) -> Result<Vec<v1::ApplyResponse>, PluginError> {
409		let mut declared = action.secret_references.iter().map(String::as_str).collect::<BTreeSet<_>>();
410		if let Some(contract) = action.contract.as_ref() {
411			for input in &contract.inputs {
412				if input.input_name.is_empty() || !declared.insert(&input.input_name) {
413					return Err(PluginError::ProtocolViolation(
414						"action input declarations are empty or overlap",
415					));
416				}
417			}
418		}
419		let supplied = secrets.keys().map(String::as_str).collect::<BTreeSet<_>>();
420		if declared != supplied {
421			return Err(PluginError::ProtocolViolation(
422				"action secrets do not match the reviewed declarations",
423			));
424		}
425		let action_id = action.action_id.clone();
426		let action_timeout = action
427			.contract
428			.as_ref()
429			.map(|c| Duration::from_secs(c.timeout_seconds as u64))
430			.filter(|d| !d.is_zero())
431			.unwrap_or(self.request_timeout)
432			.max(self.request_timeout);
433		let mut apply_client = self.client.clone();
434		let apply_request = v1::ApplyRequest {
435			context: Some(request_context(&self.provider_instance, &action_id, action_timeout)),
436			action: Some(action),
437			secrets,
438		};
439		let apply_future = tokio::time::timeout(action_timeout, apply_client.apply(apply_request));
440		tokio::pin!(apply_future);
441		let response = loop {
442			tokio::select! {
443				result = &mut apply_future => {
444					break result
445						.map_err(|_| PluginError::RpcTimeout)?
446						.map_err(plugin_status_error)?;
447				}
448				_ = tokio::time::sleep(Duration::from_millis(100)) => {
449					if crate::execution::journal::cancellation_requested(run_id) {
450						let mut cancel_client = self.client.clone();
451						let _ = cancel_client.cancel(v1::CancelRequest {
452							context: Some(request_context(&self.provider_instance, "cancel", action_timeout)),
453							run_id: format!("operation/{action_id}"),
454						}).await;
455						// Give the provider a bounded cleanup window before Drop
456						// terminates the supervised process.
457						let _ = tokio::time::timeout(Duration::from_secs(5), &mut apply_future).await;
458						return Err(PluginError::Cancelled);
459					}
460				}
461			}
462		};
463		let mut stream = response;
464		// Stream/log progress without retaining every event (long Nix builds can
465		// emit thousands of lines). Only the terminal Completed event is kept for
466		// confidential_outputs handoff.
467		let mut completed: Option<v1::ApplyResponse> = None;
468		let mut expected_sequence = 1_u32;
469		let mut progress_count = 0_usize;
470		loop {
471			let event = tokio::select! {
472				result = tokio::time::timeout(action_timeout, stream.next()) => {
473					result
474						.map_err(|_| PluginError::RpcTimeout)?
475						.transpose()
476						.map_err(plugin_status_error)?
477				}
478				_ = tokio::time::sleep(Duration::from_millis(100)) => {
479					if crate::execution::journal::cancellation_requested(run_id) {
480						let mut cancel_client = self.client.clone();
481						let _ = cancel_client.cancel(v1::CancelRequest {
482							context: Some(request_context(&self.provider_instance, "cancel", action_timeout)),
483							run_id: format!("operation/{action_id}"),
484						}).await;
485						return Err(PluginError::Cancelled);
486					}
487					continue;
488				}
489			};
490			let Some(mut event) = event else { break };
491			if event.sequence != expected_sequence {
492				return Err(PluginError::ProtocolViolation("apply event sequence is not contiguous"));
493			}
494			if event.action_id != action_id {
495				return Err(PluginError::ProtocolViolation(
496					"apply event action identity does not match the reviewed action",
497				));
498			}
499			if event.phase == v1::EventPhase::Unspecified as i32 {
500				return Err(PluginError::ProtocolViolation("apply event phase is unspecified"));
501			}
502			if event.safe_message.len() > MAX_MESSAGE_BYTES {
503				return Err(PluginError::ProtocolViolation("apply event message exceeds its bound"));
504			}
505			event.safe_message = logger.sanitize(&event.safe_message);
506			let failure_phase = matches!(
507				v1::EventPhase::try_from(event.phase),
508				Ok(v1::EventPhase::Failed | v1::EventPhase::Cancelled)
509			);
510			let provider_failure = match (failure_phase, event.error.take()) {
511				(true, Some(error)) => Some(provider_failure_from_wire(error, &logger)?),
512				(true, None) | (false, Some(_)) => {
513					return Err(PluginError::ProtocolViolation(
514						"apply event error payload does not match its phase",
515					));
516				}
517				(false, None) => None,
518			};
519			if !event.safe_message.trim().is_empty() {
520				if event.phase == v1::EventPhase::Completed as i32 {
521					// Provider completion is action-local. Only the application
522					// scheduler may report host/operation success after the entire
523					// reviewed dependency graph has completed.
524					logger.info(&event.safe_message);
525				} else if event.phase == v1::EventPhase::Failed as i32 {
526					logger.failure(&event.safe_message);
527				} else if event.detail {
528					logger.detail(&event.safe_message);
529				} else {
530					logger.info(&event.safe_message);
531				}
532			}
533			if let Some(failure) = provider_failure {
534				return Err(PluginError::ProviderReported(failure));
535			}
536			if ((!event.confidential_outputs.is_empty() || !event.public_outputs.is_empty())
537				&& event.phase != v1::EventPhase::Completed as i32)
538				|| event.confidential_outputs.len() > MAX_EVENTS
539				|| event.public_outputs.len() > MAX_EVENTS
540				|| event.confidential_outputs.iter().any(|(name, value)| {
541					name.is_empty()
542						|| name.chars().any(char::is_control)
543						|| value.is_empty()
544						|| value.len() > MAX_MESSAGE_BYTES
545				}) {
546				return Err(PluginError::ProtocolViolation(
547					"apply event confidential outputs violate their contract",
548				));
549			}
550			if event.public_outputs.iter().any(|(name, value)| {
551				name.is_empty()
552					|| name.chars().any(char::is_control)
553					|| value.is_empty()
554					|| value.len() > MAX_MESSAGE_BYTES
555					|| event.confidential_outputs.contains_key(name)
556			}) {
557				return Err(PluginError::ProtocolViolation(
558					"apply event public outputs violate their contract",
559				));
560			}
561			expected_sequence = expected_sequence.saturating_add(1);
562			if event.phase == v1::EventPhase::Completed as i32 {
563				completed = Some(event);
564			} else {
565				progress_count = progress_count.saturating_add(1);
566				// Flood guard on streamed progress only (not retained).
567				if progress_count > MAX_EVENTS.saturating_mul(64) {
568					return Err(PluginError::EventFlood);
569				}
570			}
571		}
572		let Some(completed) = completed else {
573			return Err(PluginError::ProtocolViolation("apply stream ended without a completed event"));
574		};
575		Ok(vec![completed])
576	}
577
578	pub async fn verify(
579		&mut self,
580		action: v1::PlannedAction,
581		secrets: HashMap<String, Vec<u8>>,
582	) -> Result<(), PluginError> {
583		self.verify_with_outputs(action, secrets, HashMap::new(), HashMap::new()).await
584	}
585
586	pub async fn verify_with_outputs(
587		&mut self,
588		action: v1::PlannedAction,
589		secrets: HashMap<String, Vec<u8>>,
590		confidential_outputs: HashMap<String, Vec<u8>>,
591		public_outputs: HashMap<String, Vec<u8>>,
592	) -> Result<(), PluginError> {
593		let action_timeout = action
594			.contract
595			.as_ref()
596			.map(|c| Duration::from_secs(c.timeout_seconds as u64))
597			.filter(|d| !d.is_zero())
598			.unwrap_or(self.request_timeout)
599			.max(self.request_timeout);
600		let result = tokio::time::timeout(
601			action_timeout,
602			self.client.verify(v1::VerifyRequest {
603				context: Some(request_context(&self.provider_instance, "verify", action_timeout)),
604				action: Some(action),
605				secrets,
606				confidential_outputs,
607				public_outputs,
608			}),
609		)
610		.await
611		.map_err(|_| PluginError::RpcTimeout)?
612		.map_err(plugin_status_error)?;
613		if let Some(error) = result.error {
614			let logger = crate::process::Logger::silent();
615			return Err(PluginError::ProviderReported(provider_failure_from_wire(error, &logger)?));
616		}
617		if !result.satisfied {
618			return Err(PluginError::VerificationFailed);
619		}
620		validate_canonical_json(&result.observation_json).map_err(|_| PluginError::Protocol)?;
621		Ok(())
622	}
623
624	pub async fn cancel(&mut self, run_id: &str) -> Result<(), PluginError> {
625		tokio::time::timeout(
626			self.request_timeout,
627			self.client.cancel(v1::CancelRequest {
628				context: Some(request_context(&self.provider_instance, "cancel", self.request_timeout)),
629				run_id: run_id.to_string(),
630			}),
631		)
632		.await
633		.map_err(|_| PluginError::RpcTimeout)?
634		.map_err(plugin_status_error)?;
635		if let Some(child) = self.child.as_mut() {
636			child.start_kill().map_err(|_| PluginError::ProcessExited)?;
637			tokio::time::timeout(self.request_timeout, child.wait())
638				.await
639				.map_err(|_| PluginError::RpcTimeout)?
640				.map_err(|_| PluginError::ProcessExited)?;
641		}
642		Ok(())
643	}
644}
645
646impl Drop for PluginSession {
647	fn drop(&mut self) {
648		if let Some(child) = self.child.as_mut() {
649			let _ = child.start_kill();
650		}
651		if let Some(stderr_task) = self.stderr_task.as_ref() {
652			stderr_task.abort();
653		}
654	}
655}
656
657struct PrivateRunDirectory(PathBuf);
658
659impl Drop for PrivateRunDirectory {
660	fn drop(&mut self) {
661		let _ = fs::remove_dir_all(&self.0);
662	}
663}
664
665fn create_run_dir() -> Result<PrivateRunDirectory, PluginError> {
666	let sequence = SESSION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
667	let run_dir = PathBuf::from("/tmp").join(format!("nxd-plugin-{}-{sequence}", std::process::id()));
668	fs::create_dir(&run_dir).map_err(|_| PluginError::RunDirectory)?;
669	fs::set_permissions(&run_dir, fs::Permissions::from_mode(0o700))
670		.map_err(|_| PluginError::RunDirectory)?;
671	Ok(PrivateRunDirectory(run_dir))
672}
673
674async fn connect(
675	socket: &Path,
676	child: &mut Child,
677	timeout: Duration,
678) -> Result<Channel, PluginError> {
679	let socket = socket.to_path_buf();
680	tokio::time::timeout(timeout, async {
681		loop {
682			if child.try_wait().map_err(|_| PluginError::ProcessExited)?.is_some() {
683				return Err(PluginError::ProcessExited);
684			}
685			let path = socket.clone();
686			// Accept modest trailer sizes so a misbehaving provider status does not
687			// immediately GOAWAY; providers still truncate Status messages.
688			let endpoint = Endpoint::from_static("http://[::]:50051")
689				.connect_timeout(Duration::from_millis(100))
690				.http2_max_header_list_size(64 * 1024);
691			if let Ok(channel) = endpoint
692				.connect_with_connector(service_fn(move |_| {
693					let path = path.clone();
694					async move { UnixStream::connect(path).await.map(TokioIo::new) }
695				}))
696				.await
697			{
698				return Ok(channel);
699			}
700			tokio::time::sleep(Duration::from_millis(20)).await;
701		}
702	})
703	.await
704	.map_err(|_| PluginError::StartupTimeout)?
705}
706
707fn provider_failure_from_wire(
708	error: v1::ProviderError,
709	logger: &crate::process::Logger,
710) -> Result<ProviderFailure, PluginError> {
711	let category = match v1::ErrorCategory::try_from(error.category) {
712		Ok(v1::ErrorCategory::InvalidRequest) => ProviderErrorCategory::InvalidRequest,
713		Ok(v1::ErrorCategory::Incompatible) => ProviderErrorCategory::Incompatible,
714		Ok(v1::ErrorCategory::Timeout) => ProviderErrorCategory::Timeout,
715		Ok(v1::ErrorCategory::Cancelled) => ProviderErrorCategory::Cancelled,
716		Ok(v1::ErrorCategory::Provider) => ProviderErrorCategory::Provider,
717		Ok(v1::ErrorCategory::Transport) => ProviderErrorCategory::Transport,
718		Ok(v1::ErrorCategory::Internal) => ProviderErrorCategory::Internal,
719		Ok(v1::ErrorCategory::Unspecified) | Err(_) => {
720			return Err(PluginError::ProtocolViolation(
721				"provider failure has an unspecified error category",
722			));
723		}
724	};
725	let safe_message = provider_safe_message(logger, &error.safe_message);
726	if safe_message.is_empty() || safe_message.len() > MAX_MESSAGE_BYTES {
727		return Err(PluginError::ProtocolViolation(
728			"provider failure message is empty or exceeds its bound",
729		));
730	}
731	Ok(ProviderFailure {
732		category,
733		retryable: error.retryable,
734		ambiguous: error.ambiguous,
735		safe_message,
736	})
737}
738
739fn plugin_status_error(error: tonic::Status) -> PluginError {
740	let (category, retryable) = match error.code() {
741		tonic::Code::InvalidArgument
742		| tonic::Code::OutOfRange
743		| tonic::Code::AlreadyExists
744		| tonic::Code::NotFound => (ProviderErrorCategory::InvalidRequest, false),
745		tonic::Code::Unimplemented => (ProviderErrorCategory::Incompatible, false),
746		tonic::Code::DeadlineExceeded => (ProviderErrorCategory::Timeout, true),
747		tonic::Code::Cancelled => (ProviderErrorCategory::Cancelled, false),
748		tonic::Code::Unavailable => (ProviderErrorCategory::Transport, true),
749		tonic::Code::FailedPrecondition
750		| tonic::Code::Aborted
751		| tonic::Code::PermissionDenied
752		| tonic::Code::Unauthenticated => (ProviderErrorCategory::Provider, false),
753		tonic::Code::Unknown
754		| tonic::Code::Internal
755		| tonic::Code::DataLoss
756		| tonic::Code::ResourceExhausted
757		| tonic::Code::Ok => (ProviderErrorCategory::Internal, false),
758	};
759	let logger = crate::process::Logger::silent();
760	let safe_message = provider_safe_message(&logger, error.message());
761	if safe_message.is_empty() {
762		return PluginError::ProtocolViolation("provider returned an empty gRPC status message");
763	}
764	PluginError::ProviderReported(ProviderFailure {
765		category,
766		retryable,
767		ambiguous: false,
768		safe_message,
769	})
770}
771
772fn provider_safe_message(logger: &crate::process::Logger, message: &str) -> String {
773	logger.sanitize(message).split_whitespace().collect::<Vec<_>>().join(" ")
774}
775
776fn request_context(
777	provider_instance: &str,
778	operation: &str,
779	deadline_from_now: Duration,
780) -> v1::RequestContext {
781	let deadline_unix_ms = SystemTime::now()
782		.duration_since(UNIX_EPOCH)
783		.unwrap_or_default()
784		.saturating_add(deadline_from_now)
785		.as_millis() as u64;
786	v1::RequestContext {
787		protocol_version: PROTOCOL_VERSION.to_string(),
788		provider_instance: provider_instance.to_string(),
789		operation_id: format!("operation/{operation}"),
790		deadline_unix_ms,
791	}
792}
793
794#[cfg(test)]
795mod tests {
796	use super::*;
797
798	#[test]
799	fn provider_failure_preserves_typed_retry_and_ambiguity() {
800		let logger = crate::process::Logger::silent();
801		let failure = provider_failure_from_wire(
802			v1::ProviderError {
803				category: v1::ErrorCategory::Transport as i32,
804				safe_message: "endpoint not ready".into(),
805				retryable: true,
806				ambiguous: true,
807			},
808			&logger,
809		)
810		.expect("typed provider failure");
811		assert_eq!(failure.category, ProviderErrorCategory::Transport);
812		assert!(failure.retryable);
813		assert!(failure.ambiguous);
814		assert!(!failure.may_retry(), "ambiguous failures must never retry");
815	}
816
817	#[test]
818	fn provider_failure_sanitizes_multiline_detail_without_losing_its_type() {
819		let logger = crate::process::Logger::silent();
820		let failure = provider_failure_from_wire(
821			v1::ProviderError {
822				category: v1::ErrorCategory::Provider as i32,
823				safe_message: "QGA file read failed:\npermission denied".into(),
824				retryable: false,
825				ambiguous: false,
826			},
827			&logger,
828		)
829		.expect("sanitized typed provider failure");
830		assert_eq!(failure.category, ProviderErrorCategory::Provider);
831		assert_eq!(failure.safe_message, "QGA file read failed: permission denied");
832	}
833
834	#[test]
835	fn malformed_provider_failure_is_protocol_error() {
836		let logger = crate::process::Logger::silent();
837		for error in [
838			v1::ProviderError {
839				category: v1::ErrorCategory::Unspecified as i32,
840				safe_message: "missing category".into(),
841				retryable: false,
842				ambiguous: false,
843			},
844			v1::ProviderError {
845				category: 999,
846				safe_message: "unknown category".into(),
847				retryable: false,
848				ambiguous: false,
849			},
850			v1::ProviderError {
851				category: v1::ErrorCategory::Provider as i32,
852				safe_message: String::new(),
853				retryable: false,
854				ambiguous: false,
855			},
856		] {
857			assert!(matches!(
858				provider_failure_from_wire(error, &logger),
859				Err(PluginError::ProtocolViolation(_))
860			));
861		}
862	}
863
864	#[test]
865	fn grpc_status_retry_policy_is_code_based() {
866		let PluginError::ProviderReported(unavailable) =
867			plugin_status_error(tonic::Status::unavailable("temporary"))
868		else {
869			panic!("unavailable maps to a typed provider failure");
870		};
871		assert!(unavailable.may_retry());
872
873		let PluginError::ProviderReported(auth) =
874			plugin_status_error(tonic::Status::permission_denied("denied"))
875		else {
876			panic!("permission denied maps to a typed provider failure");
877		};
878		assert!(!auth.may_retry());
879		assert!(matches!(
880			plugin_status_error(tonic::Status::unknown("")),
881			PluginError::ProtocolViolation("provider returned an empty gRPC status message")
882		));
883	}
884}