magma_plugin/lib.rs
1//! magma-plugin — HashiCorp go-plugin handshake + mTLS bootstrap + gRPC
2//! client lifecycle + subprocess management for Terraform / OpenTofu
3//! providers.
4//!
5//! Load-bearing layer per `theory/MAGMA.md` §IV. Spawn a provider
6//! binary, complete the go-plugin handshake (magic cookie validation,
7//! stdout-handshake-line parse, mTLS cert exchange), and return a
8//! typed `Plugin` handle ready for gRPC calls.
9//!
10//! Handshake protocol:
11//!
12//! 1. Parent generates a self-signed cert + key via rcgen; DER-encodes
13//! the cert; base64-encodes it; sets the env:
14//! - `PLUGIN_MIN_PORT`, `PLUGIN_MAX_PORT` (port range)
15//! - `<MAGIC_COOKIE_KEY>` = cookie value
16//! - `PLUGIN_PROTOCOL_VERSIONS=5,6`
17//! - `PLUGIN_CLIENT_CERT=<base64 PEM>`
18//! 2. Parent spawns provider as subprocess.
19//! 3. Provider validates magic cookie; exits 1 if mismatch.
20//! 4. Provider generates its own self-signed leaf cert, binds to a
21//! port in the allowed range, prints one handshake line:
22//! `CORE_PROTOCOL|APP_PROTOCOL|NETWORK|ADDRESS|PROTO_TYPE|CERT`.
23//! 5. Parent parses the line, builds a tonic gRPC `Channel` to the
24//! address. Production builds layer mTLS via tokio-rustls using
25//! `parent_cert` (own identity) + `provider_cert` (trusted root);
26//! M0 ships the plain TCP dial — the cert exchange happens but
27//! encryption layering ships in M0.x once tonic's TLS config is
28//! pinned to a known-good rustls version pair.
29//! 6. Subsequent calls go over the gRPC channel.
30//! 7. Parent sends SIGTERM (then SIGKILL after grace period) on Drop.
31
32use std::path::PathBuf;
33use std::process::Stdio;
34use std::sync::Arc;
35use std::time::Duration;
36
37use base64::Engine;
38use base64::engine::general_purpose::STANDARD as B64;
39use hyper_util::rt::TokioIo;
40use rcgen::{
41 BasicConstraints, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose,
42};
43use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
44use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime};
45use rustls::{DigitallySignedStruct, SignatureScheme};
46use thiserror::Error;
47use tokio::io::{AsyncBufReadExt, BufReader};
48use tokio::process::{Child, Command};
49use tracing::{debug, warn};
50
51use magma_protocol::PluginProtocol;
52
53/// Provider schema → cty implied type (terraform's `Block.ImpliedType`).
54/// The bridge from `GetProviderSchema` to the `magma-cty` apply codec.
55pub mod schema;
56
57/// Typed provider-RPC wrappers (configure / plan / apply) over a dialed
58/// channel, speaking `magma-cty` values. The layer that makes apply real.
59pub mod provider;
60
61// The proactive import-prepass path: a free-function `ImportResourceState`
62// RPC over a re-exported tonic `Channel`. Coexists with the engine's
63// reactive on-conflict import (`provider::ProviderConn::import_resource_state`)
64// — both drive the same provider RPC at different points (prepass before
65// plan vs. on create-conflict during apply).
66pub mod import;
67pub use import::import_resource_state;
68
69/// Install the rustls process-default `CryptoProvider`. Required
70/// before any rustls operation in 0.23 — the ring feature flag alone
71/// isn't enough when tonic also pulls in rustls. Idempotent; ignores
72/// the "already installed" error.
73/// Walk an error's `source()` chain into one string. tonic's transport
74/// `Display` is just "transport error"; the cause lives in the chain.
75fn err_chain(e: &dyn std::error::Error) -> String {
76 let mut s = e.to_string();
77 let mut src = e.source();
78 while let Some(inner) = src {
79 s.push_str(" -> ");
80 s.push_str(&inner.to_string());
81 src = inner.source();
82 }
83 s
84}
85
86/// A gRPC transport that drives a `hyper` HTTP/2 connection **directly**.
87///
88/// We run the h2 handshake on the already-dialed (TLS or plaintext) IO
89/// ourselves and `tokio::spawn` the connection driver, rather than routing
90/// through tonic's `Channel` (whose buffer/reconnect tower layers add no
91/// value for a single pinned provider socket). `call` also (a) injects the
92/// `:scheme`/`:authority` pseudo-headers tonic's own `Channel` would add and
93/// (b) collects the unary body into one self-terminating `Full` frame.
94///
95/// `SendRequest` is a cheap clonable handle; cloning shares the one spawned
96/// connection. The generated `ProviderClient<H2Channel>` consumes this via
97/// the `GrpcService` blanket impl over `tower::Service`.
98#[derive(Clone)]
99pub struct H2Channel {
100 inner: hyper::client::conn::http2::SendRequest<tonic::body::Body>,
101 // The reason the underlying h2 connection died, captured by the
102 // connection-driver task. Without this, an RPC after the connection
103 // drops surfaces only tonic's opaque "Service was not ready: channel
104 // closed" — the REAL cause (TLS/mTLS rejection, provider crash, EOF)
105 // is logged at debug and lost. Callers read `close_reason()` to turn
106 // an opaque transport failure into a precise one.
107 close_reason: std::sync::Arc<std::sync::Mutex<Option<String>>>,
108}
109
110impl H2Channel {
111 /// The reason the underlying h2 connection closed, if it has. `None`
112 /// while the connection is healthy. Lets the apply engine surface the
113 /// true cause (e.g. "peer closed connection without sending TLS
114 /// close_notify" = mTLS rejection) instead of a generic channel error.
115 #[must_use]
116 pub fn close_reason(&self) -> Option<String> {
117 self.close_reason.lock().ok().and_then(|g| g.clone())
118 }
119}
120
121// ── Provider crash capture ─────────────────────────────────────────
122//
123// A Go provider that SIGSEGVs (e.g. cloudflare 5.13.0 nil-deref during
124// ReadDataSource) writes its panic + `[signal SIGSEGV]` + goroutine
125// backtrace to STDERR (Go runtime fatals go to fd 2). The stderr drain
126// task previously logged those lines at `trace!` only, so the crash was
127// invisible at INFO and the downstream RPC surfaced just the opaque
128// tonic "channel closed". `CrashRing` captures ONLY the crash-signal
129// lines into a bounded ring the `Plugin` exposes, so every RPC failure
130// path can turn "channel closed" into "provider crashed (SIGSEGV
131// nil-deref): <panic line>".
132
133/// A typed summary of a provider subprocess crash, assembled from the
134/// captured stderr/stdout crash-signal lines (and, best-effort, the
135/// process exit signal). Returned by [`Plugin::crash_summary`] iff any
136/// crash-signal line was seen. The operator's anomaly classifier matches
137/// the typed `EngineError::ProviderCrashed` this enriches, never a
138/// substring.
139#[derive(Debug, Clone, Default)]
140pub struct ProviderCrash {
141 /// The crash-signal lines captured from the provider's stderr/stdout,
142 /// in arrival order (e.g. `panic: runtime error: ...`,
143 /// `goroutine 1 [running]:`).
144 pub lines: Vec<String>,
145 /// The unix signal the subprocess died from, if observed via
146 /// `try_wait()` before Drop reaped it (`11` = SIGSEGV). Best-effort
147 /// confirmation; `None` when unobserved or non-unix.
148 pub signal: Option<i32>,
149}
150
151impl ProviderCrash {
152 /// The first captured backtrace frame that names a Go source location
153 /// (`…/file.go:NNN`) — the actual crash SITE. This is the single most
154 /// useful line for root-causing a provider panic: it names the file +
155 /// line that nil-deref'd (e.g. a data source's `Read` whose API client
156 /// was never built). `None` when only the header was captured (no
157 /// backtrace, or frames arrived past the ring window). The leading
158 /// `+0x…` PC offset is trimmed — the file:line is the meaning.
159 #[must_use]
160 pub fn crash_site(&self) -> Option<String> {
161 self.lines
162 .iter()
163 .map(|l| l.trim())
164 .find(|l| l.contains(".go:"))
165 .map(|l| l.split(" +0x").next().unwrap_or(l).trim().to_string())
166 }
167
168 /// The most human-meaningful single line: the `panic:` / `[signal …]`
169 /// header if present, else the first captured line. Used by the RPC
170 /// error builder; `None` only when no line was captured at all.
171 #[must_use]
172 pub fn headline(&self) -> Option<&str> {
173 self.lines
174 .iter()
175 .find(|l| l.contains("panic:") || l.contains("[signal"))
176 .or_else(|| self.lines.first())
177 .map(String::as_str)
178 }
179}
180
181/// Does a provider output line look like a runtime crash / fatal panic?
182/// Matches the documented Go-runtime fatal markers (case-insensitive).
183/// Used by the stderr/stdout drain tasks to route ONLY crash lines into
184/// the [`CrashRing`] (ordinary provider info logs stay at `trace!`).
185#[must_use]
186pub fn is_crash_line(l: &str) -> bool {
187 let lower = l.to_ascii_lowercase();
188 const MARKERS: &[&str] = &[
189 "panic:",
190 "signal sigsegv",
191 "sigsegv",
192 "fatal error",
193 "runtime error",
194 "nil pointer dereference",
195 "goroutine ",
196 "[signal ",
197 ];
198 MARKERS.iter().any(|m| lower.contains(m))
199}
200
201/// How many lines following a crash MARKER to keep capturing as part of
202/// the backtrace. A Go fatal emits `panic:` → `[signal …]` → a blank line
203/// → the goroutine frames contiguously; the frames that name the crash
204/// SITE (`…/file.go:NNN +0x…`) carry NO marker of their own, so without a
205/// capture window they drop to `trace!` and the single most useful
206/// diagnostic — the file:line that nil-deref'd — is lost. Refreshed each
207/// time a new marker (e.g. a fresh `goroutine N [running]:`) appears, so a
208/// long multi-goroutine dump keeps flowing; bounded so a healthy provider
209/// log line that merely follows a transient marker can't capture forever.
210const BACKTRACE_WINDOW: usize = 64;
211
212/// Classify one drained provider line for crash capture, advancing the
213/// backtrace-capture `budget`. Returns `true` when the line should be
214/// captured into the [`CrashRing`] + logged at `error!` — either a crash
215/// header ([`is_crash_line`]) OR a non-blank frame within the post-marker
216/// window. Returns `false` for ordinary provider logs (`trace!`) and for
217/// blank lines (which advance the window but aren't worth keeping). One
218/// function so the stderr + stdout drain tasks classify identically.
219fn classify_crash_capture(l: &str, budget: &mut usize) -> bool {
220 if is_crash_line(l) {
221 *budget = BACKTRACE_WINDOW;
222 return true;
223 }
224 if *budget > 0 {
225 *budget -= 1;
226 return !l.trim().is_empty();
227 }
228 false
229}
230
231/// A bounded FIFO ring of captured crash-signal lines. Caps at `cap`
232/// lines (oldest evicted first) so a runaway backtrace can't grow
233/// unbounded. Shared across the stderr + stdout drain tasks via
234/// `Arc<Mutex<_>>`.
235struct CrashRing {
236 buf: std::collections::VecDeque<String>,
237 cap: usize,
238}
239
240impl CrashRing {
241 fn new(cap: usize) -> Self {
242 Self {
243 buf: std::collections::VecDeque::new(),
244 cap: cap.max(1),
245 }
246 }
247
248 /// Append a crash line, evicting the oldest if at capacity.
249 fn push(&mut self, line: String) {
250 if self.buf.len() >= self.cap {
251 self.buf.pop_front();
252 }
253 self.buf.push_back(line);
254 }
255
256 /// Snapshot the captured lines in arrival order.
257 fn snapshot(&self) -> Vec<String> {
258 self.buf.iter().cloned().collect()
259 }
260}
261
262type BoxErr = Box<dyn std::error::Error + Send + Sync>;
263
264impl tower::Service<http::Request<tonic::body::Body>> for H2Channel {
265 type Response = http::Response<hyper::body::Incoming>;
266 type Error = BoxErr;
267 type Future = std::pin::Pin<
268 Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
269 >;
270
271 fn poll_ready(
272 &mut self,
273 cx: &mut std::task::Context<'_>,
274 ) -> std::task::Poll<Result<(), Self::Error>> {
275 self.inner.poll_ready(cx).map_err(Into::into)
276 }
277
278 fn call(&mut self, req: http::Request<tonic::body::Body>) -> Self::Future {
279 use http_body_util::{BodyExt, Full};
280 let mut sender = self.inner.clone();
281 Box::pin(async move {
282 let (mut parts, body) = req.into_parts();
283 // tonic over a raw h2 service builds the request URI from the gRPC
284 // path only (`/tfplugin5.Provider/GetSchema`). HTTP/2 requires the
285 // `:scheme` + `:authority` pseudo-headers that tonic's own
286 // `Channel` would inject — fill them in (the connection is already
287 // pinned to this one provider, so the authority is nominal).
288 if parts.uri.authority().is_none() {
289 let pq = parts
290 .uri
291 .path_and_query()
292 .map_or("/", http::uri::PathAndQuery::as_str)
293 .to_string();
294 if let Ok(uri) = http::Uri::builder()
295 .scheme("http")
296 .authority("localhost")
297 .path_and_query(pq)
298 .build()
299 {
300 parts.uri = uri;
301 }
302 }
303 // Collect the (small, unary) gRPC request body fully, then send it
304 // as ONE `Full` frame so END_STREAM rides on the data frame. A
305 // streaming body makes h2 emit a separate trailing empty
306 // END_STREAM DATA frame, which — against go-plugin providers —
307 // intermittently fails to flush: the provider receives the message
308 // bytes but never the stream-end, so the unary handler blocks
309 // forever and the RPC hangs. One self-terminating frame removes
310 // that failure mode.
311 let bytes = body
312 .collect()
313 .await
314 .map_err(Into::<BoxErr>::into)?
315 .to_bytes();
316 // tonic 0.13 made `body::BoxBody` private and exposes the concrete
317 // `body::Body` instead. `Body::new` takes any http_body with
318 // `Data = Bytes`, so `Full` goes in directly and the deliberate
319 // single-frame shape described above is unchanged — the explicit
320 // Infallible map_err and boxed_unsync it used to need are now
321 // internal to `Body::new`.
322 let full = tonic::body::Body::new(Full::new(bytes));
323 let req = http::Request::from_parts(parts, full);
324 // Ensure the cloned sender handle is ready before sending.
325 std::future::poll_fn(|cx| sender.poll_ready(cx))
326 .await
327 .map_err(Into::<BoxErr>::into)?;
328 sender.send_request(req).await.map_err(Into::<BoxErr>::into)
329 })
330 }
331}
332
333/// Run the HTTP/2 client handshake over an already-connected IO, spawn the
334/// connection driver, and return a cloneable [`H2Channel`]. The spawned task
335/// owns the connection for the life of the provider; it ends when the
336/// provider closes the socket (Drop kills the subprocess).
337async fn h2_channel<IO>(io: IO) -> Result<H2Channel, PluginError>
338where
339 IO: hyper::rt::Read + hyper::rt::Write + Unpin + Send + 'static,
340{
341 use hyper_util::rt::TokioExecutor;
342 // Large FIXED windows (no adaptive). A provider's GetProviderSchema
343 // response is multi-MB; with a small window the server sends one window
344 // then blocks for a WINDOW_UPDATE, and that update only goes out when the
345 // connection task is incidentally re-polled — over the provider's local
346 // socket that re-poll is unreliable, so the response stalls. Sizing the
347 // initial window past the largest response lets the server stream it in
348 // one burst, drained on the first read with zero mid-stream round-trips.
349 const WIN: u32 = 64 * 1024 * 1024;
350 let (send_req, conn) = hyper::client::conn::http2::Builder::new(TokioExecutor::new())
351 .initial_stream_window_size(WIN)
352 .initial_connection_window_size(WIN)
353 .max_frame_size(4 * 1024 * 1024)
354 .handshake::<_, tonic::body::Body>(io)
355 .await
356 .map_err(|e| PluginError::Transport(err_chain(&e)))?;
357 let close_reason = std::sync::Arc::new(std::sync::Mutex::new(None));
358 let close_reason_w = std::sync::Arc::clone(&close_reason);
359 tokio::spawn(async move {
360 if let Err(e) = conn.await {
361 let chain = err_chain(&e);
362 debug!("magma-plugin h2 connection closed: {chain}");
363 // Capture the real cause so the next RPC failure isn't opaque.
364 if let Ok(mut g) = close_reason_w.lock() {
365 *g = Some(chain);
366 }
367 }
368 });
369 Ok(H2Channel {
370 inner: send_req,
371 close_reason,
372 })
373}
374
375fn ensure_crypto_provider() {
376 use std::sync::Once;
377 static INIT: Once = Once::new();
378 INIT.call_once(|| {
379 let _ = rustls::crypto::ring::default_provider().install_default();
380 });
381}
382
383// ── Custom certificate verifier (self-signed peer trust) ──────────
384
385/// rustls custom verifier that trusts only one specific peer cert.
386/// The go-plugin handshake exchanges self-signed certs both ways; the
387/// parent trusts ONLY the provider's cert (not a CA chain) and vice
388/// versa. WebPki-style chain validation doesn't apply.
389#[derive(Debug)]
390struct TrustOnlyPeerVerifier {
391 trusted_cert_der: Vec<u8>,
392}
393
394impl ServerCertVerifier for TrustOnlyPeerVerifier {
395 fn verify_server_cert(
396 &self,
397 end_entity: &CertificateDer<'_>,
398 _intermediates: &[CertificateDer<'_>],
399 _server_name: &ServerName<'_>,
400 _ocsp_response: &[u8],
401 _now: UnixTime,
402 ) -> Result<ServerCertVerified, rustls::Error> {
403 if end_entity.as_ref() == self.trusted_cert_der.as_slice() {
404 Ok(ServerCertVerified::assertion())
405 } else {
406 Err(rustls::Error::General(
407 "magma-plugin: peer cert does not match the trusted handshake cert".into(),
408 ))
409 }
410 }
411
412 fn verify_tls12_signature(
413 &self,
414 _message: &[u8],
415 _cert: &CertificateDer<'_>,
416 _dss: &DigitallySignedStruct,
417 ) -> Result<HandshakeSignatureValid, rustls::Error> {
418 Ok(HandshakeSignatureValid::assertion())
419 }
420
421 fn verify_tls13_signature(
422 &self,
423 _message: &[u8],
424 _cert: &CertificateDer<'_>,
425 _dss: &DigitallySignedStruct,
426 ) -> Result<HandshakeSignatureValid, rustls::Error> {
427 Ok(HandshakeSignatureValid::assertion())
428 }
429
430 fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
431 vec![
432 SignatureScheme::ECDSA_NISTP256_SHA256,
433 SignatureScheme::ECDSA_NISTP384_SHA384,
434 SignatureScheme::ECDSA_NISTP521_SHA512,
435 SignatureScheme::RSA_PKCS1_SHA256,
436 SignatureScheme::RSA_PSS_SHA256,
437 SignatureScheme::RSA_PKCS1_SHA384,
438 SignatureScheme::RSA_PSS_SHA384,
439 SignatureScheme::RSA_PKCS1_SHA512,
440 SignatureScheme::RSA_PSS_SHA512,
441 SignatureScheme::ED25519,
442 ]
443 }
444}
445
446// ── Errors ─────────────────────────────────────────────────────────
447
448#[derive(Debug, Error)]
449pub enum PluginError {
450 #[error("provider binary not found: {0:?}")]
451 BinaryNotFound(PathBuf),
452 #[error("provider binary not executable: {0:?}")]
453 NotExecutable(PathBuf),
454 #[error("magic cookie validation failed (provider rejected handshake)")]
455 MagicCookieMismatch,
456 #[error("provider exited before printing handshake: code {0:?}")]
457 EarlyExit(Option<i32>),
458 #[error("handshake line malformed: {0}")]
459 HandshakeMalformed(String),
460 #[error("unsupported protocol version: requested {requested}, provider offered {offered}")]
461 UnsupportedProtocol { requested: String, offered: String },
462 #[error("certificate generation failed: {0}")]
463 CertGen(String),
464 #[error("base64 decode error: {0}")]
465 Base64(String),
466 #[error("tonic transport error: {0}")]
467 Transport(String),
468 #[error("io error: {0}")]
469 Io(#[from] std::io::Error),
470 #[error("tls / cert error: {0}")]
471 Tls(String),
472 #[error("ImportResourceState RPC error: {0}")]
473 ImportRpc(String),
474 #[error("provider rejected import of {type_name} (id {id:?}): {reason}")]
475 ImportRejected {
476 type_name: String,
477 id: String,
478 reason: String,
479 },
480 #[error("imported-state decode error: {0}")]
481 ImportDecode(String),
482}
483
484// ── Parent identity (cert + key for mTLS) ──────────────────────────
485
486/// Self-signed parent certificate generated for one Plugin spawn.
487/// Production: regenerate per-spawn (the cert is ephemeral, scoped to
488/// one provider session) so a leaked cert can't compromise other
489/// providers spawned later.
490///
491/// The cert is generated with CA:true basic constraints because
492/// go-plugin's server side uses the parent cert as a "trusted CA" in
493/// its rustls/Go-tls ClientCAs pool. A non-CA self-signed cert is
494/// rejected by webpki-style chain validation; with CA:true the cert
495/// can act as its own root for the single hop.
496#[derive(Debug, Clone)]
497pub struct ParentIdentity {
498 pub cert_der: Vec<u8>,
499 pub cert_pem: String,
500 pub key_pem: String,
501 pub key_der: Vec<u8>,
502 pub base64_cert: String,
503}
504
505impl ParentIdentity {
506 /// Generate a fresh self-signed cert + key via rcgen. Cheap (<1ms
507 /// on Apple Silicon). Called per Plugin::spawn.
508 pub fn generate() -> Result<Self, PluginError> {
509 let mut params = CertificateParams::new(vec!["localhost".to_string()])
510 .map_err(|e| PluginError::CertGen(e.to_string()))?;
511 params
512 .distinguished_name
513 .push(rcgen::DnType::CommonName, "magma-parent");
514 // CA:true so go-plugin's server-side ClientCAs accepts this cert
515 // as a valid root for the one client cert it signs (itself).
516 params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
517 // KeyUsage = KeyCertSign + DigitalSignature so webpki accepts
518 // this as a CA that's also a valid leaf signer.
519 params.key_usages = vec![
520 KeyUsagePurpose::KeyCertSign,
521 KeyUsagePurpose::DigitalSignature,
522 KeyUsagePurpose::CrlSign,
523 ];
524 // ExtendedKeyUsage = ClientAuth + ServerAuth so the same cert
525 // can be used in either TLS role across go-plugin's bidirectional
526 // mTLS handshake.
527 params.extended_key_usages = vec![
528 ExtendedKeyUsagePurpose::ClientAuth,
529 ExtendedKeyUsagePurpose::ServerAuth,
530 ];
531
532 let key_pair = KeyPair::generate().map_err(|e| PluginError::CertGen(e.to_string()))?;
533 let cert = params
534 .self_signed(&key_pair)
535 .map_err(|e| PluginError::CertGen(e.to_string()))?;
536
537 let cert_pem = cert.pem();
538 let cert_der = cert.der().to_vec();
539 let key_pem = key_pair.serialize_pem();
540 let key_der = key_pair.serialize_der();
541 // HashiCorp's go-plugin reads PLUGIN_CLIENT_CERT, base64-decodes
542 // it, then expects PEM bytes (pem.Decode). So we base64-encode
543 // the PEM string, not the DER bytes.
544 let base64_cert = B64.encode(cert_pem.as_bytes());
545
546 Ok(Self {
547 cert_der,
548 cert_pem,
549 key_pem,
550 key_der,
551 base64_cert,
552 })
553 }
554}
555
556// ── Parsed handshake line ──────────────────────────────────────────
557
558/// Result of parsing the provider's stdout handshake line.
559/// Format: `CORE_PROTOCOL|APP_PROTOCOL|NETWORK|ADDRESS|PROTO_TYPE|CERT`.
560#[derive(Debug, Clone)]
561pub struct HandshakeLine {
562 pub core_protocol: u32,
563 pub app_protocol: PluginProtocol,
564 pub network: String,
565 pub address: String,
566 pub proto_type: String,
567 pub cert_pem_base64: Option<String>,
568}
569
570impl HandshakeLine {
571 pub fn parse(line: &str) -> Result<Self, PluginError> {
572 let parts: Vec<&str> = line.trim().split('|').collect();
573 if parts.len() < 5 {
574 return Err(PluginError::HandshakeMalformed(format!(
575 "expected ≥5 pipe-separated fields, got {}: {line:?}",
576 parts.len(),
577 )));
578 }
579 let core_protocol = parts[0]
580 .parse::<u32>()
581 .map_err(|e| PluginError::HandshakeMalformed(format!("core_protocol not u32: {e}")))?;
582 let app_protocol = match parts[1] {
583 "5" => PluginProtocol::V5,
584 "6" => PluginProtocol::V6,
585 other => {
586 return Err(PluginError::UnsupportedProtocol {
587 requested: "5 or 6".into(),
588 offered: other.into(),
589 });
590 }
591 };
592 Ok(Self {
593 core_protocol,
594 app_protocol,
595 network: parts[2].to_string(),
596 address: parts[3].to_string(),
597 proto_type: parts[4].to_string(),
598 cert_pem_base64: parts.get(5).map(|s| (*s).to_string()),
599 })
600 }
601
602 /// Decode the provider's cert from base64. Returns `None` if the
603 /// provider didn't include a cert (legacy protocol mode).
604 ///
605 /// Real Terraform / OpenTofu providers emit base64-encoded DER
606 /// **without padding** in the handshake line; the standard base64
607 /// decoder requires `=` padding. We pad the input ourselves before
608 /// decoding so both padded + unpadded encodings work.
609 pub fn provider_cert_der(&self) -> Option<Result<Vec<u8>, PluginError>> {
610 self.cert_pem_base64.as_ref().map(|b64| {
611 let pad_count = (4 - b64.len() % 4) % 4;
612 let padded = format!("{b64}{}", "=".repeat(pad_count));
613 B64.decode(&padded)
614 .map_err(|e| PluginError::Base64(e.to_string()))
615 })
616 }
617}
618
619// ── Spawn config ───────────────────────────────────────────────────
620
621/// Parameters required to spawn and handshake with a provider plugin.
622#[derive(Debug, Clone)]
623pub struct PluginSpec {
624 pub binary: PathBuf,
625 pub magic_cookie_key: String,
626 pub magic_cookie_value: String,
627 pub accepted_protocols: Vec<PluginProtocol>,
628 pub min_port: u16,
629 pub max_port: u16,
630 pub kill_grace: Duration,
631 /// When `true`, `Plugin::dial` layers mTLS (go-plugin AutoMTLS) on the
632 /// gRPC channel: it sets `PLUGIN_CLIENT_CERT` (making the provider serve
633 /// TLS), then connects with rustls + the rcgen parent identity + a
634 /// verifier trusting the provider's handshake cert. When `false`
635 /// (DEFAULT), `PLUGIN_CLIENT_CERT` is unset so the provider serves
636 /// plaintext h2c over its local socket, and `dial` uses the plain h2c
637 /// path. For a co-located subprocess provider talking over a
638 /// process-local unix socket / loopback in the SAME pod, plaintext is
639 /// both the secure boundary (the socket is filesystem- + namespace-
640 /// scoped) and the known-good transport; AutoMTLS is defense-in-depth
641 /// for *remote/untrusted* providers and is opt-in until the
642 /// custom-connector h2-over-mTLS path is verified against the full
643 /// provider matrix (a real protocol-6 provider closes the mTLS channel
644 /// post-handshake today — surfaced precisely via `H2Channel::close_reason`).
645 pub secure: bool,
646}
647
648impl Default for PluginSpec {
649 fn default() -> Self {
650 Self {
651 binary: PathBuf::new(),
652 magic_cookie_key: "TF_PLUGIN_MAGIC_COOKIE".into(),
653 // Real value from OpenTofu's internal/plugin/serve.go +
654 // Terraform's terraform-plugin-go HandshakeConfig. This is
655 // the publicly-published well-known cookie every Terraform-
656 // ecosystem provider validates against.
657 magic_cookie_value: "d602bf8f470bc67ca7faa0386276bbdd4330efaf76d1a219cb4d6991ca9872b2"
658 .into(),
659 accepted_protocols: vec![PluginProtocol::V6, PluginProtocol::V5],
660 min_port: 10_000,
661 max_port: 25_000,
662 kill_grace: Duration::from_secs(5),
663 // Plaintext h2c by default — co-located subprocess providers
664 // serve plaintext when PLUGIN_CLIENT_CERT is unset (standard
665 // go-plugin), and the local socket is already the trust
666 // boundary. mTLS (`secure: true`) is opt-in for remote
667 // providers; see the field doc.
668 secure: false,
669 }
670 }
671}
672
673// ── Plugin handle ──────────────────────────────────────────────────
674
675/// A live provider plugin. Holds the subprocess, the parsed handshake,
676/// the ephemeral parent identity used for mTLS, and (once dialed) the
677/// tonic gRPC `Channel` ready for typed RPC.
678pub struct Plugin {
679 process: Child,
680 handshake: HandshakeLine,
681 spec: PluginSpec,
682 identity: ParentIdentity,
683 channel: Option<H2Channel>,
684 /// Crash-signal lines captured from the provider subprocess's
685 /// stderr/stdout drain tasks. Read via [`Plugin::crash_lines`] /
686 /// [`Plugin::crash_summary`] at every RPC failure site so a provider
687 /// SIGSEGV becomes a precise typed error instead of an opaque
688 /// "channel closed".
689 crash: Arc<std::sync::Mutex<CrashRing>>,
690}
691
692impl Plugin {
693 /// Spawn a provider plugin, complete the handshake, return a live `Plugin`.
694 ///
695 /// # Errors
696 ///
697 /// Returns `PluginError` if the binary is missing, the magic cookie is
698 /// rejected, the handshake line is malformed, or the protocol negotiation
699 /// fails.
700 pub async fn spawn(spec: PluginSpec) -> Result<Self, PluginError> {
701 if !spec.binary.exists() {
702 return Err(PluginError::BinaryNotFound(spec.binary.clone()));
703 }
704
705 // Install rustls crypto provider exactly once per process.
706 ensure_crypto_provider();
707
708 // Generate ephemeral parent cert + key for mTLS. The cert
709 // travels to the provider via PLUGIN_CLIENT_CERT; the key stays
710 // in this process for tonic's mTLS client config.
711 let identity = ParentIdentity::generate()?;
712
713 let mut cmd = Command::new(&spec.binary);
714 cmd.env(&spec.magic_cookie_key, &spec.magic_cookie_value)
715 .env("PLUGIN_MIN_PORT", spec.min_port.to_string())
716 .env("PLUGIN_MAX_PORT", spec.max_port.to_string())
717 .env(
718 "PLUGIN_PROTOCOL_VERSIONS",
719 spec.accepted_protocols
720 .iter()
721 .map(|p| p.version_str())
722 .collect::<Vec<_>>()
723 .join(","),
724 )
725 .stdin(Stdio::null())
726 .stdout(Stdio::piped())
727 .stderr(Stdio::piped())
728 .kill_on_drop(true);
729
730 // AutoMTLS is opt-in: setting PLUGIN_CLIENT_CERT makes the provider
731 // serve mTLS (and `dial` must do the matching TLS handshake). When
732 // `secure` is false we DON'T set it, so the provider serves plaintext
733 // h2c over its local socket and `dial` uses the standard tonic path
734 // (no custom TLS connector). For a co-located subprocess provider
735 // over localhost/unix this is the pragmatic transport; mTLS is
736 // defense-in-depth, re-enabled once the custom-connector h2 path is
737 // sorted. go-plugin's server does
738 // `certPool.AppendCertsFromPEM([]byte(env))`, so the value must be
739 // the RAW PEM (not base64).
740 if spec.secure {
741 cmd.env("PLUGIN_CLIENT_CERT", &identity.cert_pem);
742 }
743
744 debug!(binary = ?spec.binary, "spawning provider plugin");
745 let mut child = cmd.spawn()?;
746
747 let stdout = child.stdout.take().ok_or_else(|| {
748 PluginError::Io(std::io::Error::other(
749 "stdout pipe missing after spawn (Stdio::piped requested above)",
750 ))
751 })?;
752 let mut reader = BufReader::new(stdout).lines();
753 let line = match reader.next_line().await? {
754 Some(line) => line,
755 None => {
756 let status = child.wait().await.ok().and_then(|s| s.code());
757 return Err(PluginError::EarlyExit(status));
758 }
759 };
760
761 let handshake = HandshakeLine::parse(&line)?;
762 debug!(?handshake, "provider handshake received");
763
764 // ── Drain the provider's stderr + post-handshake stdout for life ──
765 //
766 // A go-plugin provider writes its own logs to stderr (and sometimes
767 // stdout) WHILE serving RPCs. We pipe both, but only read the single
768 // handshake line — so if nothing drains the rest, the OS pipe buffer
769 // (~64KiB) fills and the provider BLOCKS on its next write, MID-RPC.
770 // Every call then hangs: the request is delivered + the provider is
771 // wedged on a stderr write, never sending the response. (This was the
772 // real cause of the long-standing "provider RPC stalls" — NOT the h2
773 // transport.) Forward both streams to tracing so the pipe never fills
774 // and provider diagnostics are still observable.
775 // Shared crash ring written by BOTH drain tasks. A SIGSEGV
776 // backtrace lands on stderr, but providers occasionally mis-route
777 // fatal output to stdout, so both tasks classify each line and
778 // push crash-signal lines here. Bounded so a runaway backtrace
779 // can't grow without limit.
780 let crash = Arc::new(std::sync::Mutex::new(CrashRing::new(256)));
781 let bin = spec.binary.clone();
782 if let Some(stderr) = child.stderr.take() {
783 let bin = bin.clone();
784 let crash_w = Arc::clone(&crash);
785 tokio::spawn(async move {
786 let mut lines = BufReader::new(stderr).lines();
787 // Per-task backtrace-capture window: a crash MARKER opens it;
788 // the following stack frames (which carry no marker) are kept
789 // until it closes, so the `…/file.go:NNN` crash SITE survives.
790 let mut budget = 0usize;
791 while let Ok(Some(l)) = lines.next_line().await {
792 if classify_crash_capture(&l, &mut budget) {
793 // Bump to error! so the panic + SIGSEGV + the stack
794 // frames are visible at INFO (they were invisible at
795 // trace!), and capture into the ring the RPC error
796 // paths read.
797 tracing::error!(provider = ?bin, stream = "stderr", "{l}");
798 if let Ok(mut g) = crash_w.lock() {
799 g.push(l);
800 }
801 } else {
802 tracing::trace!(provider = ?bin, "{l}");
803 }
804 }
805 });
806 }
807 let crash_w = Arc::clone(&crash);
808 tokio::spawn(async move {
809 // `reader` owns the rest of stdout after the handshake line.
810 let mut budget = 0usize;
811 while let Ok(Some(l)) = reader.next_line().await {
812 if classify_crash_capture(&l, &mut budget) {
813 tracing::error!(provider = ?bin, stream = "stdout", "{l}");
814 if let Ok(mut g) = crash_w.lock() {
815 g.push(l);
816 }
817 } else {
818 tracing::trace!(provider = ?bin, stream = "stdout", "{l}");
819 }
820 }
821 });
822
823 if !spec.accepted_protocols.contains(&handshake.app_protocol) {
824 return Err(PluginError::UnsupportedProtocol {
825 requested: spec
826 .accepted_protocols
827 .iter()
828 .map(|p| p.version_str())
829 .collect::<Vec<_>>()
830 .join(","),
831 offered: handshake.app_protocol.version_str().into(),
832 });
833 }
834
835 Ok(Self {
836 process: child,
837 handshake,
838 spec,
839 identity,
840 channel: None,
841 crash,
842 })
843 }
844
845 /// Dial the gRPC channel to the provider over mTLS. Supports both
846 /// `tcp` and `unix` network transports per the go-plugin handshake.
847 ///
848 /// Builds a rustls ClientConfig:
849 /// - parent cert + key as client identity (mTLS client auth)
850 /// - custom verifier that trusts only the provider's specific cert
851 /// from the handshake line (not a CA chain — both ends are
852 /// self-signed)
853 ///
854 /// Wraps the underlying stream (UnixStream or TcpStream) in a
855 /// tokio_rustls TlsStream, then drives a `hyper` HTTP/2 connection over
856 /// it directly (see [`H2Channel`] for why not tonic `Channel`).
857 pub async fn dial(&mut self) -> Result<&H2Channel, PluginError> {
858 // Compute the channel into a local only when it isn't already
859 // cached (`is_some()` is a `bool` — it holds no borrow, so the
860 // tail return below can take a fresh borrow without conflicting
861 // with this guard, unlike an early `as_ref()` return). All paths
862 // funnel to a single unwrap-free tail return.
863 if self.channel.is_none() {
864 let channel = self.dial_channel().await?;
865 self.channel = Some(channel);
866 }
867 // By construction `self.channel` is `Some` here (just set, or was
868 // already cached). `ok_or_else` keeps this unwrap-free and honest:
869 // the `None` arm is logically unreachable but yields a typed error
870 // rather than a panic if that invariant is ever broken.
871 self.channel
872 .as_ref()
873 .ok_or_else(|| PluginError::Transport("internal: channel vanished after dial".into()))
874 }
875
876 /// Dial a fresh [`H2Channel`] to the provider (no caching — `dial`
877 /// owns the `self.channel` cache). Splitting this out lets `dial`
878 /// store-then-return in one unwrap-free tail.
879 async fn dial_channel(&self) -> Result<H2Channel, PluginError> {
880 let network = self.handshake.network.clone();
881 let address = self.handshake.address.clone();
882
883 // Insecure path — plain TCP/h2c. Used by offline tests against
884 // mock providers (and providers spawned without PLUGIN_CLIENT_CERT).
885 // Production / real-provider paths set `secure: true` (default).
886 if !self.spec.secure {
887 let channel = match network.as_str() {
888 "tcp" => {
889 let stream = tokio::net::TcpStream::connect(&address)
890 .await
891 .map_err(|e| PluginError::Transport(err_chain(&e)))?;
892 h2_channel(TokioIo::new(stream)).await?
893 }
894 "unix" => {
895 let stream = tokio::net::UnixStream::connect(&address)
896 .await
897 .map_err(|e| PluginError::Transport(err_chain(&e)))?;
898 h2_channel(TokioIo::new(stream)).await?
899 }
900 other => {
901 return Err(PluginError::Transport(format!(
902 "unsupported handshake network type: {other:?}",
903 )));
904 }
905 };
906 return Ok(channel);
907 }
908
909 // Build the mTLS ClientConfig once for this dial.
910 let provider_cert_der = self.handshake.provider_cert_der().ok_or_else(|| {
911 PluginError::Transport("provider handshake omitted cert; mTLS impossible".into())
912 })??;
913 let parent_cert = CertificateDer::from(self.identity.cert_der.clone());
914 let parent_key =
915 PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(self.identity.key_der.clone()));
916
917 let verifier = Arc::new(TrustOnlyPeerVerifier {
918 trusted_cert_der: provider_cert_der,
919 });
920 let mut tls_config = rustls::ClientConfig::builder()
921 .dangerous()
922 .with_custom_certificate_verifier(verifier)
923 .with_client_auth_cert(vec![parent_cert], parent_key)
924 .map_err(|e| PluginError::Tls(format!("client auth cert: {e}")))?;
925 // go-plugin negotiates h2 over ALPN for gRPC; without this the
926 // server may pick http/1.1 and the h2 handshake fails.
927 tls_config.alpn_protocols = vec![b"h2".to_vec()];
928 let tls_config = Arc::new(tls_config);
929 let connector = tokio_rustls::TlsConnector::from(tls_config);
930 let server_name = ServerName::try_from("localhost")
931 .map_err(|e| PluginError::Tls(format!("server_name: {e}")))?;
932
933 let channel = match network.as_str() {
934 "tcp" => {
935 let stream = tokio::net::TcpStream::connect(&address)
936 .await
937 .map_err(|e| PluginError::Transport(err_chain(&e)))?;
938 let tls = connector
939 .connect(server_name, stream)
940 .await
941 .map_err(|e| PluginError::Tls(err_chain(&e)))?;
942 h2_channel(TokioIo::new(tls)).await?
943 }
944 "unix" => {
945 let stream = tokio::net::UnixStream::connect(&address)
946 .await
947 .map_err(|e| PluginError::Transport(err_chain(&e)))?;
948 let tls = connector
949 .connect(server_name, stream)
950 .await
951 .map_err(|e| PluginError::Tls(err_chain(&e)))?;
952 h2_channel(TokioIo::new(tls)).await?
953 }
954 other => {
955 return Err(PluginError::Transport(format!(
956 "unsupported handshake network type: {other:?} (expected `tcp` or `unix`)",
957 )));
958 }
959 };
960 Ok(channel)
961 }
962
963 /// The negotiated handshake (protocol version, transport, address, cert).
964 #[must_use]
965 pub fn handshake(&self) -> &HandshakeLine {
966 &self.handshake
967 }
968
969 /// The ephemeral parent identity (cert + key) generated for this
970 /// spawn. Exposed for tonic-rustls mTLS layering in M0.x.
971 #[must_use]
972 pub fn parent_identity(&self) -> &ParentIdentity {
973 &self.identity
974 }
975
976 /// The dialed gRPC channel, if `dial()` has been called.
977 #[must_use]
978 pub fn channel(&self) -> Option<&H2Channel> {
979 self.channel.as_ref()
980 }
981
982 /// Snapshot of the crash-signal lines captured from the provider's
983 /// stderr/stdout (panic / SIGSEGV / fatal-error backtrace). Empty
984 /// while the provider is healthy. Read at every RPC failure site so a
985 /// subprocess crash surfaces as a precise typed error.
986 #[must_use]
987 pub fn crash_lines(&self) -> Vec<String> {
988 self.crash.lock().map(|g| g.snapshot()).unwrap_or_default()
989 }
990
991 /// A typed [`ProviderCrash`] summary, `Some` iff any crash-signal line
992 /// was captured. Best-effort: the backtrace may still be draining when
993 /// an RPC error returns (the panic + the h2 broken-pipe arrive on
994 /// separate tasks), so this returns whatever has been seen so far.
995 /// `signal` is read via the non-blocking [`Plugin::exit_signal`]
996 /// (`&mut`) confirmation when available; from behind a shared borrow
997 /// it stays `None` and the captured panic lines carry the meaning.
998 #[must_use]
999 pub fn crash_summary(&self) -> Option<ProviderCrash> {
1000 let lines = self.crash_lines();
1001 if lines.is_empty() {
1002 None
1003 } else {
1004 Some(ProviderCrash {
1005 lines,
1006 signal: None,
1007 })
1008 }
1009 }
1010
1011 /// Best-effort: the unix signal the subprocess died from, if it has
1012 /// already exited and not yet been reaped by Drop. `11` = SIGSEGV.
1013 /// Non-blocking (`try_wait`), so `None` while the process is still
1014 /// alive, on a non-unix target, or if the exit carried no signal. A
1015 /// numeric confirmation of the stderr-captured panic — never
1016 /// load-bearing; the captured panic lines are the meaning.
1017 pub fn exit_signal(&mut self) -> Option<i32> {
1018 #[cfg(unix)]
1019 {
1020 use std::os::unix::process::ExitStatusExt;
1021 self.process
1022 .try_wait()
1023 .ok()
1024 .flatten()
1025 .and_then(|s| s.signal())
1026 }
1027 #[cfg(not(unix))]
1028 {
1029 None
1030 }
1031 }
1032}
1033
1034impl Drop for Plugin {
1035 fn drop(&mut self) {
1036 let _ = &self.spec.kill_grace;
1037 warn!(handshake = ?self.handshake, "dropping plugin; subprocess will be killed by tokio");
1038 let _ = self.process.start_kill();
1039 }
1040}
1041
1042// ── Tests ──────────────────────────────────────────────────────────
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047
1048 #[test]
1049 fn parse_handshake_v6() {
1050 let line = "1|6|tcp|127.0.0.1:42839|grpc|MIIBkTCCATegAwIBAgIBATAK";
1051 let h = HandshakeLine::parse(line).unwrap();
1052 assert_eq!(h.core_protocol, 1);
1053 assert_eq!(h.app_protocol, PluginProtocol::V6);
1054 assert_eq!(h.network, "tcp");
1055 assert_eq!(h.address, "127.0.0.1:42839");
1056 assert_eq!(h.proto_type, "grpc");
1057 assert_eq!(
1058 h.cert_pem_base64.as_deref(),
1059 Some("MIIBkTCCATegAwIBAgIBATAK")
1060 );
1061 }
1062
1063 #[test]
1064 fn parse_handshake_v5_no_cert() {
1065 let line = "1|5|tcp|127.0.0.1:10001|grpc";
1066 let h = HandshakeLine::parse(line).unwrap();
1067 assert_eq!(h.app_protocol, PluginProtocol::V5);
1068 assert!(h.cert_pem_base64.is_none());
1069 }
1070
1071 #[test]
1072 fn parse_handshake_rejects_unknown_protocol() {
1073 let line = "1|99|tcp|127.0.0.1:10001|grpc";
1074 assert!(matches!(
1075 HandshakeLine::parse(line),
1076 Err(PluginError::UnsupportedProtocol { .. })
1077 ));
1078 }
1079
1080 #[test]
1081 fn parse_handshake_rejects_malformed() {
1082 let line = "this-is-not-pipe-separated";
1083 assert!(matches!(
1084 HandshakeLine::parse(line),
1085 Err(PluginError::HandshakeMalformed(_))
1086 ));
1087 }
1088
1089 #[test]
1090 fn generate_parent_identity() {
1091 let identity = ParentIdentity::generate().unwrap();
1092 assert!(!identity.cert_der.is_empty());
1093 assert!(identity.cert_pem.contains("BEGIN CERTIFICATE"));
1094 assert!(identity.key_pem.contains("PRIVATE KEY"));
1095 assert!(!identity.base64_cert.is_empty());
1096 // base64(PEM) — what go-plugin's PLUGIN_CLIENT_CERT consumers
1097 // expect. Provider does base64-decode + pem.Decode to recover
1098 // the cert.
1099 let decoded = B64.decode(&identity.base64_cert).unwrap();
1100 assert_eq!(decoded, identity.cert_pem.as_bytes());
1101 }
1102
1103 #[test]
1104 fn is_crash_line_matches_the_live_sigsegv_evidence() {
1105 // The exact line the cloudflare 5.13.0 provider emitted on rio.
1106 assert!(is_crash_line(
1107 "panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV]"
1108 ));
1109 // The signal/addr second line Go emits.
1110 assert!(is_crash_line(
1111 "[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x...]"
1112 ));
1113 // The goroutine backtrace frames.
1114 assert!(is_crash_line("goroutine 1 [running]:"));
1115 // Other Go fatals.
1116 assert!(is_crash_line("fatal error: concurrent map writes"));
1117 // Case-insensitive.
1118 assert!(is_crash_line("PANIC: something blew up"));
1119 }
1120
1121 #[test]
1122 fn is_crash_line_ignores_ordinary_provider_logs() {
1123 assert!(!is_crash_line(
1124 "2026-06-12T00:00:00Z [INFO] provider: configuring client: host=api.cloudflare.com"
1125 ));
1126 assert!(!is_crash_line(
1127 "[DEBUG] ReadDataSource: cloudflare_accounts"
1128 ));
1129 assert!(!is_crash_line(""));
1130 // The word "panic" only as a substring of an unrelated word must
1131 // not trip — we anchor on "panic:" (with the colon Go emits).
1132 assert!(!is_crash_line("the operation did not panic and succeeded"));
1133 }
1134
1135 #[test]
1136 fn crash_ring_evicts_oldest_at_capacity() {
1137 let mut ring = CrashRing::new(2);
1138 ring.push("first".to_string());
1139 ring.push("second".to_string());
1140 ring.push("third".to_string());
1141 // Oldest ("first") evicted; the two most-recent survive in order.
1142 assert_eq!(
1143 ring.snapshot(),
1144 vec!["second".to_string(), "third".to_string()]
1145 );
1146 }
1147
1148 #[test]
1149 fn crash_ring_zero_cap_is_clamped_to_one() {
1150 // A zero cap would deadlock the push (evict-then-push of nothing);
1151 // `CrashRing::new` clamps to >=1 so the ring always holds the most
1152 // recent line.
1153 let mut ring = CrashRing::new(0);
1154 ring.push("only".to_string());
1155 assert_eq!(ring.snapshot(), vec!["only".to_string()]);
1156 }
1157
1158 #[tokio::test]
1159 async fn crash_capture_surfaces_panic_from_a_fake_stderr_stream() {
1160 // Mirror the spawn() stderr drain task exactly: classify each line
1161 // via `classify_crash_capture`, push captured lines into the shared
1162 // ring. Drives the same path the real drain uses, over an in-memory
1163 // stderr stream carrying the live SIGSEGV evidence — no subprocess.
1164 // Crucially this includes the blank line + `.go:NNN` frame Go emits;
1165 // the WINDOW must keep those frames even though they carry no marker.
1166 let stderr_bytes = concat!(
1167 "[INFO] provider: starting up\n",
1168 "panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV]\n",
1169 "[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0xabc]\n",
1170 "\n",
1171 "goroutine 17 [running]:\n",
1172 "github.com/cloudflare/terraform-provider-cloudflare/internal/services/zones.(*ZonesDataSource).Read(0xc0001)\n",
1173 "\t/home/runner/work/terraform-provider-cloudflare/internal/services/zones/list_data_source.go:103 +0x2a4\n",
1174 )
1175 .as_bytes()
1176 .to_vec();
1177
1178 let crash = Arc::new(std::sync::Mutex::new(CrashRing::new(256)));
1179 let crash_w = Arc::clone(&crash);
1180 let mut lines = BufReader::new(std::io::Cursor::new(stderr_bytes)).lines();
1181 let mut budget = 0usize;
1182 while let Ok(Some(l)) = lines.next_line().await {
1183 if classify_crash_capture(&l, &mut budget) {
1184 if let Ok(mut g) = crash_w.lock() {
1185 g.push(l);
1186 }
1187 }
1188 }
1189
1190 let captured = crash.lock().unwrap().snapshot();
1191 assert!(
1192 captured
1193 .iter()
1194 .any(|l| l.contains("nil pointer dereference") && l.contains("SIGSEGV")),
1195 "captured crash lines must include the nil-deref panic: {captured:?}"
1196 );
1197 assert!(captured.iter().any(|l| l.contains("goroutine 17")));
1198 // THE regression this fix closes: the frame that names the crash
1199 // SITE (`list_data_source.go:103`) carries no marker, yet must be
1200 // captured via the backtrace window — it was dropped at trace! before.
1201 assert!(
1202 captured
1203 .iter()
1204 .any(|l| l.contains("list_data_source.go:103")),
1205 "the .go:NNN crash-site frame must be captured: {captured:?}"
1206 );
1207 assert!(
1208 !captured.iter().any(|l| l.contains("starting up")),
1209 "ordinary info logs must NOT be captured as crash lines"
1210 );
1211
1212 // And the typed summary distills that frame into the crash SITE.
1213 let pc = ProviderCrash {
1214 lines: captured,
1215 signal: Some(11),
1216 };
1217 let site = pc.crash_site().expect("crash_site from the .go: frame");
1218 assert!(site.contains("list_data_source.go:103"), "site: {site}");
1219 assert!(!site.contains("+0x"), "PC offset trimmed from site: {site}");
1220 assert!(pc.headline().unwrap().contains("nil pointer dereference"));
1221 }
1222
1223 #[test]
1224 fn classify_crash_capture_window_spans_blank_then_frames() {
1225 // A bare frame with NO preceding marker is NOT captured (budget 0).
1226 let mut budget = 0usize;
1227 assert!(!classify_crash_capture(
1228 "\t/some/file.go:1 +0x0",
1229 &mut budget
1230 ));
1231 // After a marker the window opens; a blank line passes through
1232 // (advances but isn't captured) and the following frame is captured.
1233 assert!(classify_crash_capture("panic: boom", &mut budget));
1234 assert!(!classify_crash_capture("", &mut budget)); // blank: not kept
1235 assert!(classify_crash_capture(
1236 "\t/some/file.go:42 +0x0",
1237 &mut budget
1238 ));
1239 }
1240
1241 #[test]
1242 fn crash_site_is_none_without_a_go_frame() {
1243 let pc = ProviderCrash {
1244 lines: vec!["panic: boom".into(), "goroutine 1 [running]:".into()],
1245 signal: None,
1246 };
1247 assert!(pc.crash_site().is_none());
1248 assert_eq!(pc.headline(), Some("panic: boom"));
1249 }
1250
1251 #[test]
1252 fn provider_cert_round_trip() {
1253 let identity = ParentIdentity::generate().unwrap();
1254 let handshake = HandshakeLine {
1255 core_protocol: 1,
1256 app_protocol: PluginProtocol::V6,
1257 network: "tcp".into(),
1258 address: "127.0.0.1:50051".into(),
1259 proto_type: "grpc".into(),
1260 cert_pem_base64: Some(identity.base64_cert.clone()),
1261 };
1262 // The `provider_cert_der` helper preserves whatever base64-decoded
1263 // bytes the provider emitted. Since parent + provider use the
1264 // same encoding convention, the round-trip recovers the PEM
1265 // bytes (not the DER).
1266 let decoded = handshake.provider_cert_der().unwrap().unwrap();
1267 assert_eq!(decoded, identity.cert_pem.as_bytes());
1268 }
1269}