Skip to main content

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}