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 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 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 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 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 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 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 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}