Skip to main content

pact_plugin_driver/
lua_plugin.rs

1//! Support for Pact plugins written in Lua.
2//!
3//! A Lua plugin is loaded as an embedded [`mlua`] interpreter running in the driver's own
4//! process (`executableType: "lua"` in `pact-plugin.json`), instead of a separate child
5//! process speaking gRPC. The plugin script must define these global functions:
6//!
7//! - `init(implementation, version) -> table` - returns an array of catalogue entries,
8//!   each shaped as `{ entryType = "CONTENT_MATCHER", key = "...", values = { ... } }`.
9//! - `configure_interaction(content_type, config) -> table` - see [`PluginInstance::configure_interaction`].
10//! - `match_contents(request) -> table` - see [`PluginInstance::compare_contents`].
11//! - `generate_content(contents, generators, test_mode)` (optional) - see [`PluginInstance::generate_content`].
12//! - `update_catalogue(catalogue)` (optional) - see [`PluginInstance::update_catalogue`].
13//!
14//! A Lua plugin that registers a `TRANSPORT` catalogue entry (instead of, or as well as, a
15//! `CONTENT_MATCHER`/`CONTENT_GENERATOR` one) must also define these functions. The plugin
16//! itself is responsible for whatever the transport actually requires (opening sockets,
17//! making outbound calls, etc.) - the driver only calls these functions at the right points
18//! in the test lifecycle, exactly as it would over gRPC for an `exec` plugin:
19//!
20//! - `start_mock_server(request) -> table` - see [`PluginInstance::start_mock_server`] /
21//!   [`PluginInstance::start_mock_server_v2`].
22//! - `shutdown_mock_server(server_key) -> table` - see [`PluginInstance::shutdown_mock_server`].
23//! - `get_mock_server_results(server_key) -> table` - see [`PluginInstance::get_mock_server_results`].
24//! - `prepare_interaction_for_verification(request) -> table` - see
25//!   [`PluginInstance::prepare_interaction_for_verification`] /
26//!   [`PluginInstance::prepare_interaction_for_verification_v2`].
27//! - `verify_interaction(request) -> table` - see [`PluginInstance::verify_interaction`] /
28//!   [`PluginInstance::verify_interaction_v2`].
29//!
30//! Each of these is called with either a V1-shaped or a V2-shaped request table, never both,
31//! depending on the plugin's own `pluginInterfaceVersion` in its manifest - the same static,
32//! per-instance choice the driver makes for gRPC plugins (see `plugin_manager.rs`).
33//!
34//! From within `match_contents`/`generate_content`, a script can also call back into a
35//! host-provided or another plugin's capability, named by catalogue entry key (proposal 007,
36//! "Lua transport" - the in-process equivalent of the gRPC `PluginHost` callback service):
37//!
38//! - `host_compare_contents(entry_key, request) -> table` - same request/response shape as
39//!   `match_contents` itself, so a result can be returned straight through.
40//! - `host_generate_content(entry_key, contents, generators, test_mode) -> body` - same
41//!   arguments and return shape as `generate_content` itself.
42//!
43//! These are only reachable from Lua code running inside `match_contents`/`generate_content`
44//! (the two entry points the driver invokes via `call_async`); calling them from another entry
45//! point currently fails, since mlua can only resolve an async host function from within a Lua
46//! call chain that was itself started asynchronously.
47
48use std::collections::HashMap;
49use std::fs::File;
50use std::io::Write;
51use std::path::{Path, PathBuf};
52use std::sync::{Arc, Mutex};
53
54use anyhow::anyhow;
55use async_trait::async_trait;
56use mlua::{Function, Lua, LuaSerdeExt, Table, Value, Variadic};
57use rsa::pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPublicKey, LineEnding};
58use rsa::{Pkcs1v15Sign, RsaPrivateKey, RsaPublicKey};
59use sha2::{Digest, Sha512};
60use tracing::debug;
61
62use crate::call_chain;
63use crate::catalogue_manager::{CatalogueEntryType, ResolvedCapability, resolve_capability};
64use crate::plugin_manager::lookup_plugin;
65use crate::plugin_models::{
66  PactPluginManifest, PactPluginRpc, PluginInitRequest, PluginInitResponse, PluginInstance,
67};
68use crate::proto::*;
69use crate::proto_v2;
70use crate::utils::{proto_struct_to_json, proto_value_to_json, to_proto_struct, to_proto_value};
71
72/// A running Lua plugin instance. Each instance owns its own embedded Lua VM.
73///
74/// The mutex is `tokio::sync::Mutex`, not `std::sync::Mutex`: the two host functions plugins can
75/// call to reach a host-provided or another plugin's capability (`host_compare_contents`,
76/// `host_generate_content` - see [`register_host_functions`] and proposal 007) need to hold the
77/// lock across an `.await` while they dispatch to an async [`crate::core_capabilities`] handler
78/// or forward to another plugin, which a `std::sync::MutexGuard` cannot do (it isn't `Send`,
79/// and `PluginInstance`'s `#[async_trait]` methods require a `Send` future).
80pub struct LuaPactPlugin {
81  runtime: Arc<tokio::sync::Mutex<Lua>>,
82  manifest: PactPluginManifest,
83  instance_id: String,
84  plugin_capabilities: Vec<String>,
85}
86
87impl std::fmt::Debug for LuaPactPlugin {
88  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89    f.debug_struct("LuaPactPlugin")
90      .field("manifest", &self.manifest)
91      .field("instance_id", &self.instance_id)
92      .field("plugin_capabilities", &self.plugin_capabilities)
93      .finish()
94  }
95}
96
97/// Start a Lua plugin: resolve the entry point script, create a Lua VM, register the host
98/// functions the plugin can call, and load (execute) the script.
99pub(crate) fn start_lua_plugin(
100  manifest: &PactPluginManifest,
101  instance_id: String,
102) -> anyhow::Result<LuaPactPlugin> {
103  let script_path = resolve_entry_point(manifest)?;
104  debug!("Loading Lua plugin {} from {:?}", manifest.name, script_path);
105
106  let log = Arc::new(LuaPluginLog::open(&manifest.name, &instance_id));
107  let lua = Lua::new();
108  set_package_path(&lua, manifest)?;
109  add_luarocks_path(&lua, manifest)?;
110  register_host_functions(&lua, &manifest.name, &log)?;
111  load_script(&lua, &script_path)?;
112
113  Ok(LuaPactPlugin {
114    runtime: Arc::new(tokio::sync::Mutex::new(lua)),
115    manifest: manifest.clone(),
116    instance_id,
117    plugin_capabilities: vec![],
118  })
119}
120
121impl LuaPactPlugin {
122  /// Set the capabilities negotiated for this plugin instance (called once, after the init
123  /// handshake, before the instance is shared behind an `Arc`).
124  pub(crate) fn set_plugin_capabilities(&mut self, capabilities: Vec<String>) {
125    self.plugin_capabilities = capabilities;
126  }
127}
128
129/// Captures a Lua plugin's diagnostic output (`print` and `logger()` calls) into the same
130/// per-instance log file a gRPC plugin's stderr is captured to -
131/// `<pact-dir>/logs/pact-plugin-<name>-<instance_id>.log` (see
132/// `child_process::open_plugin_log_file`) - so operators don't need to know which kind of
133/// plugin they're looking at to find its log. A Lua plugin runs embedded in the driver's own
134/// process, so without this its `print` output would otherwise go straight to the driver's
135/// own real stdout, mixed in with everything else.
136struct LuaPluginLog {
137  file: Mutex<Option<File>>,
138}
139
140impl LuaPluginLog {
141  fn open(plugin_name: &str, instance_id: &str) -> Self {
142    LuaPluginLog {
143      file: Mutex::new(crate::child_process::open_plugin_log_file(plugin_name, instance_id)),
144    }
145  }
146
147  fn write_line(&self, line: &str) {
148    if let Ok(mut guard) = self.file.lock()
149      && let Some(file) = guard.as_mut() {
150      let _ = writeln!(file, "{}", line);
151      let _ = file.flush();
152    }
153  }
154}
155
156fn resolve_entry_point(manifest: &PactPluginManifest) -> anyhow::Result<PathBuf> {
157  let entry_point = PathBuf::from(&manifest.entry_point);
158  let path = if entry_point.is_absolute() && entry_point.exists() {
159    entry_point
160  } else {
161    PathBuf::from(&manifest.plugin_dir).join(&manifest.entry_point)
162  };
163  if !path.exists() {
164    return Err(anyhow!("Lua plugin entry point {:?} does not exist", path));
165  }
166  Ok(path)
167}
168
169/// Adds the plugin's own directory (not the entry point script's directory, which may be a
170/// subdirectory of it if `entryPoint` is a nested path) to `package.path`, matching the JVM
171/// driver's `LuaPactPlugin.kt`, which always uses `manifest.pluginDir` for the same purpose.
172fn set_package_path(lua: &Lua, manifest: &PactPluginManifest) -> anyhow::Result<()> {
173  let plugin_dir = PathBuf::from(&manifest.plugin_dir);
174  let package: Table = lua.globals().get("package")?;
175  let existing: String = package.get("path").unwrap_or_default();
176  let new_path = format!(
177    "{}/?.lua;{}/?/init.lua;{}",
178    plugin_dir.to_string_lossy(), plugin_dir.to_string_lossy(), existing
179  );
180  package.set("path", new_path)?;
181  Ok(())
182}
183
184/// The Lua version this driver embeds (fixed by mlua's `lua54` feature) - also the version
185/// segment LuaRocks uses in its per-version tree layout (e.g. `share/lua/5.4/`).
186const LUAROCKS_LUA_VERSION: &str = "5.4";
187
188/// Makes pure-Lua packages installed via `luarocks` available to `require`, so a plugin can
189/// depend on rocks instead of vendoring every third-party library it uses.
190///
191/// LuaRocks installs modules under `<rocks_dir>/share/lua/<version>/`, where `<rocks_dir>`
192/// defaults to `~/.luarocks` (its standard per-user tree) but can be a system tree or a
193/// custom prefix if the user configured LuaRocks differently. A plugin can override the
194/// directory this driver looks in via a `luaRocksDir` key in the manifest's `pluginConfig`.
195/// Only the `share/lua` (pure Lua) path is added - packages with compiled C extensions
196/// (under `lib/lua`) are not supported.
197fn add_luarocks_path(lua: &Lua, manifest: &PactPluginManifest) -> anyhow::Result<()> {
198  let configured = manifest.plugin_config.get("luaRocksDir").and_then(|v| v.as_str());
199  let rocks_dir = match configured {
200    Some(dir) => PathBuf::from(dir),
201    None => match home::home_dir() {
202      Some(home) => home.join(".luarocks"),
203      None => return Ok(()),
204    },
205  };
206
207  let lua_dir = rocks_dir.join("share").join("lua").join(LUAROCKS_LUA_VERSION);
208  if !lua_dir.exists() {
209    if configured.is_some() {
210      debug!(
211        "Configured luaRocksDir '{}' does not have a share/lua/{} directory, ignoring",
212        rocks_dir.display(), LUAROCKS_LUA_VERSION
213      );
214    }
215    return Ok(());
216  }
217
218  let package: Table = lua.globals().get("package")?;
219  let existing: String = package.get("path").unwrap_or_default();
220  let new_path = format!(
221    "{}/?.lua;{}/?/init.lua;{}",
222    lua_dir.to_string_lossy(), lua_dir.to_string_lossy(), existing
223  );
224  package.set("path", new_path)?;
225  debug!("Added LuaRocks path {:?} for plugin {}", lua_dir, manifest.name);
226  Ok(())
227}
228
229fn load_script(lua: &Lua, script_path: &Path) -> anyhow::Result<()> {
230  let script = std::fs::read_to_string(script_path)?;
231  lua
232    .load(script)
233    .set_name(script_path.to_string_lossy().to_string())
234    .exec()
235    .map_err(|err| anyhow!("Failed to load Lua plugin script {:?} - {}", script_path, err))?;
236  Ok(())
237}
238
239/// Registers the host (Rust) functions that a Lua plugin script can call: a logger, and the
240/// RSA/base64 primitives needed by the JWT plugin (Lua has no crypto standard library).
241fn register_host_functions(lua: &Lua, plugin_name: &str, log: &Arc<LuaPluginLog>) -> anyhow::Result<()> {
242  let globals = lua.globals();
243
244  let name = plugin_name.to_string();
245  let logger_log = log.clone();
246  globals.set(
247    "logger",
248    lua.create_function(move |_, message: String| {
249      debug!(plugin = name.as_str(), "{}", message);
250      logger_log.write_line(&message);
251      Ok(())
252    })?,
253  )?;
254
255  // Redirects Lua's built-in `print` (its "stdout") into the same per-instance log file, so
256  // it doesn't leak into the driver's own real stdout - see `LuaPluginLog`.
257  let print_log = log.clone();
258  globals.set(
259    "print",
260    lua.create_function(move |lua, args: Variadic<Value>| {
261      let tostring: Function = lua.globals().get("tostring")?;
262      let mut parts = Vec::with_capacity(args.len());
263      for arg in args.iter() {
264        parts.push(tostring.call::<String>(arg.clone())?);
265      }
266      print_log.write_line(&parts.join("\t"));
267      Ok(())
268    })?,
269  )?;
270
271  globals.set(
272    "rsa_sign",
273    lua.create_function(|_, (data, key): (mlua::String, String)| {
274      let private_key = RsaPrivateKey::from_pkcs1_pem(&key).map_err(mlua::Error::external)?;
275      let digest = Sha512::digest(data.as_bytes().as_ref());
276      let signature = private_key
277        .sign(Pkcs1v15Sign::new::<Sha512>(), &digest)
278        .map_err(mlua::Error::external)?;
279      Ok(base64::Engine::encode(
280        &base64::engine::general_purpose::URL_SAFE_NO_PAD,
281        signature,
282      ))
283    })?,
284  )?;
285
286  globals.set(
287    "rsa_public_key",
288    lua.create_function(|_, key: String| {
289      let private_key = RsaPrivateKey::from_pkcs1_pem(&key).map_err(mlua::Error::external)?;
290      let public_key = RsaPublicKey::from(&private_key);
291      let pem = public_key
292        .to_pkcs1_pem(LineEnding::LF)
293        .map_err(mlua::Error::external)?;
294      Ok(pem)
295    })?,
296  )?;
297
298  globals.set(
299    "rsa_validate",
300    lua.create_function(|_, (token_parts, algorithm, key): (Vec<String>, String, String)| {
301      if algorithm != "RS512" {
302        return Err(mlua::Error::RuntimeError(format!(
303          "Unsupported JWT algorithm '{}': only RS512 is supported",
304          algorithm
305        )));
306      }
307      if token_parts.len() != 3 {
308        return Err(mlua::Error::RuntimeError(
309          "Expected a 3 part JWT token (header, payload, signature)".to_string(),
310        ));
311      }
312
313      let public_key = match RsaPublicKey::from_pkcs1_pem(&key) {
314        Ok(key) => key,
315        Err(_) => return Ok(false),
316      };
317      let signature = match decode_base64_lenient(&token_parts[2]) {
318        Ok(bytes) => bytes,
319        Err(_) => return Ok(false),
320      };
321      let base_token = format!("{}.{}", token_parts[0], token_parts[1]);
322      let digest = Sha512::digest(base_token.as_bytes());
323      Ok(
324        public_key
325          .verify(Pkcs1v15Sign::new::<Sha512>(), &digest, &signature)
326          .is_ok(),
327      )
328    })?,
329  )?;
330
331  globals.set(
332    "b64_decode_no_pad",
333    lua.create_function(|lua, data: String| {
334      let bytes = decode_base64_lenient(&data).map_err(mlua::Error::external)?;
335      lua.create_string(&bytes)
336    })?,
337  )?;
338
339  // Callback host functions (proposal 007, "Lua transport"): let a plugin script delegate to a
340  // host-provided or another plugin's content matcher/generator, named by catalogue entry key,
341  // instead of reimplementing it. Registered as *async* Lua functions - resolving the entry may
342  // need to await an async core capability handler or forward the call to another plugin - which
343  // is why the script's own `match_contents`/`generate_content` are invoked via `call_async` (see
344  // `PluginInstance::compare_contents`/`generate_content` below): mlua only allows an async host
345  // function to be reached from a Lua call chain that was itself started with `call_async`.
346  //
347  // No call-chain ID or cycle detection is needed here, unlike the gRPC `PluginHost` callback
348  // path (`plugin_host.rs`) - this is a direct, synchronous (from Lua's perspective) Rust call;
349  // a true cycle shows up as a native stack overflow, the same reasoning that applies to WASM.
350  globals.set(
351    "host_compare_contents",
352    lua.create_async_function(move |lua, (entry_key, request): (String, Table)| async move {
353      let request = lua_to_compare_request(&lua, request).map_err(mlua::Error::external)?;
354      let response = call_host_compare_contents(&entry_key, request).await.map_err(mlua::Error::external)?;
355      compare_response_to_lua(&lua, &response)
356    })?,
357  )?;
358
359  globals.set(
360    "host_generate_content",
361    lua.create_async_function(move |lua, (entry_key, contents, generators, test_mode): (String, Value, Option<Table>, Option<String>)| async move {
362      let request = lua_to_generate_request(&lua, contents, generators, test_mode).map_err(mlua::Error::external)?;
363      let response = call_host_generate_content(&entry_key, request).await.map_err(mlua::Error::external)?;
364      body_to_lua(&lua, &response.contents)
365    })?,
366  )?;
367
368  Ok(())
369}
370
371/// Resolve `entry_key` to a content matcher capability and dispatch to it - a host-registered
372/// [`crate::core_capabilities::CoreContentMatcher`] called in-process, or another running plugin
373/// called via a freshly-started call chain (see [`crate::call_chain`]), matching the same
374/// resolver [`crate::plugin_host`] uses for the gRPC callback path. Backs the `host_compare_contents`
375/// Lua host function.
376async fn call_host_compare_contents(
377  entry_key: &str,
378  request: CompareContentsRequest
379) -> anyhow::Result<CompareContentsResponse> {
380  match resolve_capability(entry_key, CatalogueEntryType::CONTENT_MATCHER)? {
381    ResolvedCapability::Core(core_key) => {
382      let handler = crate::core_capabilities::lookup_core_content_matcher(&core_key)
383        .ok_or_else(|| anyhow!("No core content matcher registered for '{}'", core_key))?;
384      handler.compare_contents(request).await
385    }
386    ResolvedCapability::Plugin(manifest) => {
387      let plugin = lookup_plugin(&manifest.as_dependency())
388        .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
389      let chain_id = call_chain::new_call_chain_id();
390      let deadline_ms = call_chain::default_deadline_ms();
391      plugin.compare_contents_with_chain(request, &chain_id, deadline_ms).await
392    }
393  }
394}
395
396/// Resolve `entry_key` to a content generator capability and dispatch to it. See
397/// [`call_host_compare_contents`]; backs the `host_generate_content` Lua host function.
398async fn call_host_generate_content(
399  entry_key: &str,
400  request: GenerateContentRequest
401) -> anyhow::Result<GenerateContentResponse> {
402  match resolve_capability(entry_key, CatalogueEntryType::CONTENT_GENERATOR)? {
403    ResolvedCapability::Core(core_key) => {
404      let handler = crate::core_capabilities::lookup_core_content_generator(&core_key)
405        .ok_or_else(|| anyhow!("No core content generator registered for '{}'", core_key))?;
406      handler.generate_content(request).await
407    }
408    ResolvedCapability::Plugin(manifest) => {
409      let plugin = lookup_plugin(&manifest.as_dependency())
410        .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
411      let chain_id = call_chain::new_call_chain_id();
412      let deadline_ms = call_chain::default_deadline_ms();
413      plugin.generate_content_with_chain(request, &chain_id, deadline_ms).await
414    }
415  }
416}
417
418/// Decode base64 (URL-safe), trying the padded then the un-padded alphabet.
419fn decode_base64_lenient(data: &str) -> anyhow::Result<Vec<u8>> {
420  use base64::Engine;
421  base64::engine::general_purpose::URL_SAFE
422    .decode(data)
423    .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(data))
424    .map_err(|err| anyhow!("Failed to base64 decode value - {}", err))
425}
426
427fn call_init(
428  lua: &Lua,
429  implementation: &str,
430  version: &str,
431) -> anyhow::Result<Vec<CatalogueEntry>> {
432  let init_fn: Function = lua
433    .globals()
434    .get("init")
435    .map_err(|_| anyhow!("Lua plugin does not define a global 'init' function"))?;
436  let result: Table = init_fn
437    .call((implementation.to_string(), version.to_string()))
438    .map_err(|err| anyhow!("Lua init() function failed - {}", err))?;
439  lua_table_to_catalogue_entries(result)
440}
441
442fn lua_table_to_catalogue_entries(table: Table) -> anyhow::Result<Vec<CatalogueEntry>> {
443  let mut entries = vec![];
444  for entry in table.sequence_values::<Table>() {
445    let entry = entry?;
446    let entry_type_str: String = entry.get("entryType")?;
447    let key: String = entry.get("key")?;
448    let values: Option<HashMap<String, String>> = entry.get("values")?;
449    let entry_type = catalogue_entry::EntryType::from_str_name(&entry_type_str)
450      .ok_or_else(|| anyhow!("Unknown catalogue entry type '{}'", entry_type_str))?;
451    entries.push(CatalogueEntry {
452      r#type: entry_type as i32,
453      key,
454      values: values.unwrap_or_default(),
455    });
456  }
457  Ok(entries)
458}
459
460// ---- Body <-> Lua ----
461
462fn content_type_hint_to_str(hint: i32) -> &'static str {
463  match body::ContentTypeHint::try_from(hint).unwrap_or(body::ContentTypeHint::Default) {
464    body::ContentTypeHint::Default => "DEFAULT",
465    body::ContentTypeHint::Text => "TEXT",
466    body::ContentTypeHint::Binary => "BINARY",
467  }
468}
469
470fn str_to_content_type_hint(hint: &str) -> i32 {
471  match hint {
472    "TEXT" => body::ContentTypeHint::Text as i32,
473    "BINARY" => body::ContentTypeHint::Binary as i32,
474    _ => body::ContentTypeHint::Default as i32,
475  }
476}
477
478fn body_to_lua(lua: &Lua, body: &Option<Body>) -> mlua::Result<Value> {
479  match body {
480    None => Ok(Value::Nil),
481    Some(body) => {
482      let table = lua.create_table()?;
483      table.set("content_type", body.content_type.clone())?;
484      match &body.content {
485        Some(bytes) => table.set("contents", lua.create_string(bytes)?)?,
486        None => table.set("contents", Value::Nil)?,
487      }
488      table.set("content_type_hint", content_type_hint_to_str(body.content_type_hint))?;
489      Ok(Value::Table(table))
490    }
491  }
492}
493
494fn lua_to_body(value: Value) -> anyhow::Result<Option<Body>> {
495  match value {
496    Value::Nil => Ok(None),
497    Value::Table(table) => {
498      let content_type: String = table.get("content_type")?;
499      let contents: Option<mlua::String> = table.get("contents")?;
500      let content_type_hint: Option<String> = table.get("content_type_hint")?;
501      Ok(Some(Body {
502        content_type,
503        content: contents.map(|s| s.as_bytes().to_vec()),
504        content_type_hint: content_type_hint
505          .map(|h| str_to_content_type_hint(&h))
506          .unwrap_or(body::ContentTypeHint::Default as i32),
507      }))
508    }
509    _ => Err(anyhow!("Expected a body table or nil from Lua, got {}", value.type_name())),
510  }
511}
512
513// ---- Matching rules / generators / plugin configuration <-> Lua ----
514
515fn matching_rules_to_lua(lua: &Lua, rules: &HashMap<String, MatchingRules>) -> mlua::Result<Table> {
516  let table = lua.create_table()?;
517  for (path, rule_list) in rules {
518    let rules_table = lua.create_table()?;
519    for rule in &rule_list.rule {
520      let rule_table = lua.create_table()?;
521      rule_table.set("type", rule.r#type.clone())?;
522      if let Some(values) = &rule.values {
523        rule_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
524      }
525      rules_table.push(rule_table)?;
526    }
527    table.set(path.clone(), rules_table)?;
528  }
529  Ok(table)
530}
531
532/// Reverse of [`matching_rules_to_lua`] - used by `host_compare_contents` to convert the rules a
533/// plugin script builds when calling back into a host-provided or another plugin's matcher.
534fn lua_to_matching_rules(lua: &Lua, table: Option<Table>) -> anyhow::Result<HashMap<String, MatchingRules>> {
535  let mut result = HashMap::new();
536  if let Some(table) = table {
537    for pair in table.pairs::<String, Table>() {
538      let (path, rules_table) = pair?;
539      let mut rule = vec![];
540      for rule_value in rules_table.sequence_values::<Table>() {
541        let rule_table = rule_value?;
542        let r#type: String = rule_table.get("type")?;
543        let values: Option<Value> = rule_table.get("values")?;
544        let values = match values {
545          Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
546          None => None,
547        };
548        rule.push(MatchingRule { r#type, values });
549      }
550      result.insert(path, MatchingRules { rule });
551    }
552  }
553  Ok(result)
554}
555
556fn plugin_configuration_to_lua(lua: &Lua, config: &Option<PluginConfiguration>) -> mlua::Result<Value> {
557  match config {
558    None => Ok(Value::Nil),
559    Some(config) => {
560      let table = lua.create_table()?;
561      if let Some(interaction_configuration) = &config.interaction_configuration {
562        table.set(
563          "interaction_configuration",
564          lua.to_value(&proto_struct_to_json(interaction_configuration))?,
565        )?;
566      }
567      if let Some(pact_configuration) = &config.pact_configuration {
568        table.set(
569          "pact_configuration",
570          lua.to_value(&proto_struct_to_json(pact_configuration))?,
571        )?;
572      }
573      Ok(Value::Table(table))
574    }
575  }
576}
577
578fn lua_to_plugin_configuration(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<PluginConfiguration>> {
579  match value {
580    None | Some(Value::Nil) => Ok(None),
581    Some(Value::Table(table)) => {
582      let interaction_configuration: Option<serde_json::Value> =
583        table.get::<Option<Value>>("interaction_configuration")?
584          .map(|v| lua.from_value(v))
585          .transpose()?;
586      let pact_configuration: Option<serde_json::Value> =
587        table.get::<Option<Value>>("pact_configuration")?
588          .map(|v| lua.from_value(v))
589          .transpose()?;
590      Ok(Some(PluginConfiguration {
591        interaction_configuration: interaction_configuration.map(|v| to_proto_struct(&as_json_map(v))),
592        pact_configuration: pact_configuration.map(|v| to_proto_struct(&as_json_map(v))),
593      }))
594    }
595    Some(other) => Err(anyhow!("Expected a plugin_config table or nil from Lua, got {}", other.type_name())),
596  }
597}
598
599fn as_json_map(value: serde_json::Value) -> HashMap<String, serde_json::Value> {
600  match value {
601    serde_json::Value::Object(map) => map.into_iter().collect(),
602    _ => HashMap::new(),
603  }
604}
605
606// ---- CompareContents <-> Lua ----
607
608fn compare_request_to_lua(lua: &Lua, request: &CompareContentsRequest) -> mlua::Result<Table> {
609  let table = lua.create_table()?;
610  table.set("expected", body_to_lua(lua, &request.expected)?)?;
611  table.set("actual", body_to_lua(lua, &request.actual)?)?;
612  table.set("allow_unexpected_keys", request.allow_unexpected_keys)?;
613  table.set("rules", matching_rules_to_lua(lua, &request.rules)?)?;
614  table.set(
615    "plugin_configuration",
616    plugin_configuration_to_lua(lua, &request.plugin_configuration)?,
617  )?;
618  Ok(table)
619}
620
621/// Reverse of [`compare_request_to_lua`] - the request table shape a plugin script builds when
622/// calling `host_compare_contents(entry_key, request)` (see [`register_host_functions`]) is the
623/// same shape its own `match_contents(request)` function receives.
624fn lua_to_compare_request(lua: &Lua, table: Table) -> anyhow::Result<CompareContentsRequest> {
625  let expected: Value = table.get("expected")?;
626  let actual: Value = table.get("actual")?;
627  let allow_unexpected_keys: Option<bool> = table.get("allow_unexpected_keys")?;
628  let rules: Option<Table> = table.get("rules")?;
629  let plugin_configuration: Option<Value> = table.get("plugin_configuration")?;
630  Ok(CompareContentsRequest {
631    expected: lua_to_body(expected)?,
632    actual: lua_to_body(actual)?,
633    allow_unexpected_keys: allow_unexpected_keys.unwrap_or(false),
634    rules: lua_to_matching_rules(lua, rules)?,
635    plugin_configuration: lua_to_plugin_configuration(lua, plugin_configuration)?,
636  })
637}
638
639fn lua_to_compare_response(table: Table) -> anyhow::Result<CompareContentsResponse> {
640  let error: Option<String> = table.get("error")?;
641  if let Some(error) = error {
642    return Ok(CompareContentsResponse {
643      error,
644      type_mismatch: None,
645      results: HashMap::new(),
646    });
647  }
648
649  let type_mismatch: Option<Table> = table.get("type-mismatch")?;
650  if let Some(type_mismatch) = type_mismatch {
651    let expected: String = type_mismatch.get("expected")?;
652    let actual: String = type_mismatch.get("actual")?;
653    return Ok(CompareContentsResponse {
654      error: String::new(),
655      type_mismatch: Some(ContentTypeMismatch { expected, actual }),
656      results: HashMap::new(),
657    });
658  }
659
660  let mismatches: Option<Table> = table.get("mismatches")?;
661  let mut results = HashMap::new();
662  if let Some(mismatches) = mismatches {
663    for pair in mismatches.pairs::<String, Value>() {
664      let (path, value) = pair?;
665      let mismatch_list = lua_value_to_content_mismatches(&path, value)?;
666      if !mismatch_list.is_empty() {
667        results.insert(path, ContentMismatches { mismatches: mismatch_list });
668      }
669    }
670  }
671
672  Ok(CompareContentsResponse {
673    error: String::new(),
674    type_mismatch: None,
675    results,
676  })
677}
678
679/// Stringifies a scalar Lua value (used for the `expected`/`actual` fields of a mismatch
680/// table), rather than requiring exactly a Lua string - a claim/header value being compared
681/// could just as easily be a number or boolean.
682fn lua_scalar_to_string(value: Value) -> anyhow::Result<Option<String>> {
683  match value {
684    Value::Nil => Ok(None),
685    Value::Boolean(b) => Ok(Some(b.to_string())),
686    Value::Integer(i) => Ok(Some(i.to_string())),
687    Value::Number(n) => Ok(Some(n.to_string())),
688    Value::String(s) => Ok(Some(s.to_str()?.to_string())),
689    other => Err(anyhow!("Expected a scalar mismatch value from Lua, got {}", other.type_name())),
690  }
691}
692
693fn lua_value_to_content_mismatches(path: &str, value: Value) -> anyhow::Result<Vec<ContentMismatch>> {
694  match value {
695    Value::String(s) => Ok(vec![ContentMismatch {
696      expected: None,
697      actual: None,
698      mismatch: s.to_str()?.to_string(),
699      path: path.to_string(),
700      diff: String::new(),
701      mismatch_type: String::new(),
702    }]),
703    Value::Table(table) => {
704      // Either a single mismatch table ({mismatch=..., expected=..., ...}), or a sequence of them / plain strings
705      let mismatch_field: Option<String> = table.get("mismatch")?;
706      if let Some(mismatch) = mismatch_field {
707        // expected/actual can reasonably be non-string Lua values (e.g. a numeric or boolean
708        // claim value), so stringify whatever's there rather than requiring exactly a string.
709        let expected = lua_scalar_to_string(table.get("expected")?)?;
710        let actual = lua_scalar_to_string(table.get("actual")?)?;
711        let path_override: Option<String> = table.get("path")?;
712        let diff: Option<String> = table.get("diff")?;
713        let mismatch_type: Option<String> = table.get("mismatch_type")?;
714        Ok(vec![ContentMismatch {
715          expected: expected.map(|s| s.into_bytes()),
716          actual: actual.map(|s| s.into_bytes()),
717          mismatch,
718          path: path_override.unwrap_or_else(|| path.to_string()),
719          diff: diff.unwrap_or_default(),
720          mismatch_type: mismatch_type.unwrap_or_default(),
721        }])
722      } else {
723        let mut result = vec![];
724        for entry in table.sequence_values::<Value>() {
725          result.extend(lua_value_to_content_mismatches(path, entry?)?);
726        }
727        Ok(result)
728      }
729    }
730    Value::Nil => Ok(vec![]),
731    other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
732  }
733}
734
735/// Converts a path's mismatches into the sequence-of-tables shape
736/// [`lua_value_to_content_mismatches`] parses, so a `host_compare_contents` response can be
737/// passed straight through as part of the calling plugin's own `match_contents` response.
738fn content_mismatches_to_lua(lua: &Lua, mismatches: &[ContentMismatch]) -> mlua::Result<Table> {
739  let list = lua.create_table()?;
740  for mismatch in mismatches {
741    let table = lua.create_table()?;
742    table.set("mismatch", mismatch.mismatch.clone())?;
743    if let Some(expected) = &mismatch.expected {
744      table.set("expected", lua.create_string(expected)?)?;
745    }
746    if let Some(actual) = &mismatch.actual {
747      table.set("actual", lua.create_string(actual)?)?;
748    }
749    table.set("path", mismatch.path.clone())?;
750    if !mismatch.diff.is_empty() {
751      table.set("diff", mismatch.diff.clone())?;
752    }
753    if !mismatch.mismatch_type.is_empty() {
754      table.set("mismatch_type", mismatch.mismatch_type.clone())?;
755    }
756    list.push(table)?;
757  }
758  Ok(list)
759}
760
761/// Reverse of [`lua_to_compare_response`] - the table `host_compare_contents` returns is shaped
762/// exactly like what a plugin's own `match_contents` function is expected to return, so a plugin
763/// can pass a host/forwarded comparison's result straight through as its own response.
764fn compare_response_to_lua(lua: &Lua, response: &CompareContentsResponse) -> mlua::Result<Table> {
765  let table = lua.create_table()?;
766  if !response.error.is_empty() {
767    table.set("error", response.error.clone())?;
768    return Ok(table);
769  }
770  if let Some(type_mismatch) = &response.type_mismatch {
771    let mismatch_table = lua.create_table()?;
772    mismatch_table.set("expected", type_mismatch.expected.clone())?;
773    mismatch_table.set("actual", type_mismatch.actual.clone())?;
774    table.set("type-mismatch", mismatch_table)?;
775    return Ok(table);
776  }
777  if !response.results.is_empty() {
778    let mismatches_table = lua.create_table()?;
779    for (path, content_mismatches) in &response.results {
780      mismatches_table.set(path.clone(), content_mismatches_to_lua(lua, &content_mismatches.mismatches)?)?;
781    }
782    table.set("mismatches", mismatches_table)?;
783  }
784  Ok(table)
785}
786
787// ---- GenerateContent <-> Lua ----
788
789/// Converts the `(entry_key, contents, generators, test_mode)` arguments a plugin script passes
790/// to `host_generate_content` (see [`register_host_functions`]) into a `GenerateContentRequest` -
791/// the same three trailing arguments its own `generate_content(contents, generators, test_mode)`
792/// function receives.
793fn lua_to_generate_request(
794  lua: &Lua,
795  contents: Value,
796  generators: Option<Table>,
797  test_mode: Option<String>
798) -> anyhow::Result<GenerateContentRequest> {
799  let mut generator_map = HashMap::new();
800  if let Some(generators) = generators {
801    for pair in generators.pairs::<String, Table>() {
802      let (path, generator_table) = pair?;
803      let r#type: String = generator_table.get("type")?;
804      let values: Option<Value> = generator_table.get("values")?;
805      let values = match values {
806        Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
807        None => None,
808      };
809      generator_map.insert(path, Generator { r#type, values });
810    }
811  }
812  let test_mode = match test_mode.as_deref() {
813    Some("Consumer") => generate_content_request::TestMode::Consumer,
814    Some("Provider") => generate_content_request::TestMode::Provider,
815    _ => generate_content_request::TestMode::Unknown,
816  };
817  Ok(GenerateContentRequest {
818    contents: lua_to_body(contents)?,
819    generators: generator_map,
820    test_mode: test_mode as i32,
821    .. GenerateContentRequest::default()
822  })
823}
824
825// ---- ConfigureInteraction <-> Lua ----
826
827/// Converts a single Lua interaction-contents table (shaped as
828/// `{ contents = <body>, part_name = "...", plugin_config = <table> }`) into an
829/// `InteractionResponse`.
830fn lua_to_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<InteractionResponse> {
831  let contents: Option<Value> = table.get("contents")?;
832  let body = match contents {
833    Some(value) => lua_to_body(value)?,
834    None => None,
835  };
836  let plugin_config: Option<Value> = table.get("plugin_config")?;
837  let part_name: Option<String> = table.get("part_name")?;
838  Ok(InteractionResponse {
839    contents: body,
840    rules: HashMap::new(),
841    generators: HashMap::new(),
842    message_metadata: None,
843    plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
844    interaction_markup: String::new(),
845    interaction_markup_type: 0,
846    part_name: part_name.unwrap_or_default(),
847    metadata_rules: HashMap::new(),
848    metadata_generators: HashMap::new(),
849  })
850}
851
852/// Converts the table returned by the Lua `configure_interaction` function, shaped as
853/// `{ interactions = { { contents = <body>, part_name = "..." }, ... }, plugin_config = <table> }`,
854/// into a `ConfigureInteractionResponse`. `interactions` is always a sequence, even when there
855/// is only one interaction (as is the case for a plain body content-matcher like JWT).
856fn lua_to_configure_response(lua: &Lua, table: Table) -> anyhow::Result<ConfigureInteractionResponse> {
857  let mut interactions = vec![];
858  let items: Option<Table> = table.get("interactions")?;
859  if let Some(items) = items {
860    for entry in items.sequence_values::<Table>() {
861      interactions.push(lua_to_interaction_response(lua, entry?)?);
862    }
863  }
864
865  let plugin_config: Option<Value> = table.get("plugin_config")?;
866  Ok(ConfigureInteractionResponse {
867    error: String::new(),
868    interaction: interactions,
869    plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
870  })
871}
872
873// ---- TRANSPORT plugin support: mock server / verification <-> Lua ----
874
875/// Converts a `google.protobuf.Struct` to a plain Lua value, `nil` if not set.
876fn struct_to_lua(lua: &Lua, value: &Option<prost_types::Struct>) -> mlua::Result<Value> {
877  match value {
878    Some(value) => lua.to_value(&proto_struct_to_json(value)),
879    None => Ok(Value::Nil),
880  }
881}
882
883/// Converts V2 `InteractionContents` (structured per-interaction data sent in place of a whole
884/// Pact JSON document) into a Lua table shaped as
885/// `{ interaction_type, consumer, provider, plugin_configuration = { interaction_configuration, pact_configuration } }`.
886fn interaction_contents_to_lua(lua: &Lua, contents: &proto_v2::InteractionContents) -> mlua::Result<Table> {
887  let table = lua.create_table()?;
888  table.set("interaction_type", contents.interaction_type.clone())?;
889  table.set("consumer", contents.consumer.clone())?;
890  table.set("provider", contents.provider.clone())?;
891  if let Some(plugin_configuration) = &contents.plugin_configuration {
892    let config_table = lua.create_table()?;
893    if let Some(interaction_configuration) = &plugin_configuration.interaction_configuration {
894      config_table.set("interaction_configuration", lua.to_value(&proto_struct_to_json(interaction_configuration))?)?;
895    }
896    if let Some(pact_configuration) = &plugin_configuration.pact_configuration {
897      config_table.set("pact_configuration", lua.to_value(&proto_struct_to_json(pact_configuration))?)?;
898    }
899    table.set("plugin_configuration", config_table)?;
900  }
901  Ok(table)
902}
903
904/// V1 `InteractionData` and V2 `InteractionData` are structurally identical (same wire format);
905/// converting via an encode/decode round trip lets the rest of this module deal with a single
906/// (V1) type, matching the approach `plugin_manager.rs` uses in the other direction (see
907/// `to_proto_v2_interaction_data`). Returns an error rather than panicking if the round trip
908/// ever fails (it shouldn't, given the identical wire format, but this data originates from a
909/// caller-supplied gRPC request, so a decode failure should be a recoverable error, not a
910/// crash).
911fn v2_interaction_data_to_v1(data: &proto_v2::InteractionData) -> anyhow::Result<InteractionData> {
912  use prost::Message;
913  InteractionData::decode(data.encode_to_vec().as_slice())
914    .map_err(|err| anyhow!("Failed to convert V2 InteractionData to V1 - {}", err))
915}
916
917/// Converts request/response metadata to a Lua table. Each value is either a plain Lua value
918/// (JSON-like, for a non-binary `MetadataValue`) or a `{ binary = <lua string> }` wrapper table
919/// (for a binary `MetadataValue`), so a Lua script can tell the two apart.
920fn metadata_to_lua(lua: &Lua, metadata: &HashMap<String, MetadataValue>) -> mlua::Result<Table> {
921  let table = lua.create_table()?;
922  for (key, value) in metadata {
923    let lua_value = match &value.value {
924      Some(metadata_value::Value::NonBinaryValue(value)) => lua.to_value(&proto_value_to_json(value))?,
925      Some(metadata_value::Value::BinaryValue(bytes)) => {
926        let wrapper = lua.create_table()?;
927        wrapper.set("binary", lua.create_string(bytes)?)?;
928        Value::Table(wrapper)
929      }
930      None => Value::Nil,
931    };
932    table.set(key.clone(), lua_value)?;
933  }
934  Ok(table)
935}
936
937/// Converts a Lua metadata table (see [`metadata_to_lua`]) back into `MetadataValue`s.
938fn lua_to_metadata(lua: &Lua, table: Option<Table>) -> anyhow::Result<HashMap<String, MetadataValue>> {
939  let mut metadata = HashMap::new();
940  if let Some(table) = table {
941    for pair in table.pairs::<String, Value>() {
942      let (key, value) = pair?;
943      let binary: Option<mlua::String> = match &value {
944        Value::Table(wrapper) => wrapper.get("binary")?,
945        _ => None,
946      };
947      let metadata_value = if let Some(binary) = binary {
948        metadata_value::Value::BinaryValue(binary.as_bytes().to_vec())
949      } else {
950        let json: serde_json::Value = lua.from_value(value)?;
951        metadata_value::Value::NonBinaryValue(to_proto_value(&json))
952      };
953      metadata.insert(key, MetadataValue { value: Some(metadata_value) });
954    }
955  }
956  Ok(metadata)
957}
958
959/// Converts `InteractionData` (a request/response body plus metadata) to a Lua table shaped as
960/// `{ body = <body table>, metadata = <metadata table> }`, or `nil` if not set.
961fn interaction_data_to_lua(lua: &Lua, data: &Option<InteractionData>) -> mlua::Result<Value> {
962  match data {
963    None => Ok(Value::Nil),
964    Some(data) => {
965      let table = lua.create_table()?;
966      table.set("body", body_to_lua(lua, &data.body)?)?;
967      table.set("metadata", metadata_to_lua(lua, &data.metadata)?)?;
968      Ok(Value::Table(table))
969    }
970  }
971}
972
973/// Converts a Lua interaction-data table (see [`interaction_data_to_lua`]) back into
974/// `InteractionData`, or `None` if the Lua value was `nil`.
975fn lua_to_interaction_data(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<InteractionData>> {
976  match value {
977    None | Some(Value::Nil) => Ok(None),
978    Some(Value::Table(table)) => {
979      let body: Option<Value> = table.get("body")?;
980      let body = match body {
981        Some(value) => lua_to_body(value)?,
982        None => None,
983      };
984      let metadata_table: Option<Table> = table.get("metadata")?;
985      Ok(Some(InteractionData {
986        body,
987        metadata: lua_to_metadata(lua, metadata_table)?,
988      }))
989    }
990    Some(other) => Err(anyhow!("Expected an interaction data table or nil from Lua, got {}", other.type_name())),
991  }
992}
993
994/// Converts the table returned by the Lua `start_mock_server` function, shaped as either
995/// `{ error = "..." }` or `{ details = { key, port, address } }`, into a `StartMockServerResponse`.
996fn lua_to_start_mock_server_response(table: Table) -> anyhow::Result<StartMockServerResponse> {
997  let error: Option<String> = table.get("error")?;
998  if let Some(error) = error {
999    return Ok(StartMockServerResponse {
1000      response: Some(start_mock_server_response::Response::Error(error)),
1001    });
1002  }
1003
1004  let details: Option<Table> = table.get("details")?;
1005  let details = details.ok_or_else(|| {
1006    anyhow!("Lua start_mock_server() must return either an 'error' or 'details' field")
1007  })?;
1008  Ok(StartMockServerResponse {
1009    response: Some(start_mock_server_response::Response::Details(MockServerDetails {
1010      key: details.get("key")?,
1011      port: details.get("port")?,
1012      address: details.get("address")?,
1013    })),
1014  })
1015}
1016
1017/// Converts the table returned by the Lua `shutdown_mock_server`/`get_mock_server_results`
1018/// functions, shaped as `{ ok = bool, results = { { path, error, mismatches = { ... } }, ... } }`,
1019/// into `MockServerResults`. Reuses [`lua_value_to_content_mismatches`] for each result's
1020/// `mismatches` field, the same helper `match_contents` responses use.
1021fn lua_to_mock_server_results(table: Table) -> anyhow::Result<MockServerResults> {
1022  let ok: bool = table.get::<Option<bool>>("ok")?.unwrap_or(true);
1023  let mut results = vec![];
1024  let results_table: Option<Table> = table.get("results")?;
1025  if let Some(results_table) = results_table {
1026    for entry in results_table.sequence_values::<Table>() {
1027      let entry = entry?;
1028      let path: String = entry.get::<Option<String>>("path")?.unwrap_or_default();
1029      let error: String = entry.get::<Option<String>>("error")?.unwrap_or_default();
1030      let mismatches_value: Value = entry.get("mismatches")?;
1031      results.push(MockServerResult {
1032        path: path.clone(),
1033        error,
1034        mismatches: lua_value_to_content_mismatches(&path, mismatches_value)?,
1035      });
1036    }
1037  }
1038  Ok(MockServerResults { ok, results })
1039}
1040
1041/// Converts the table returned by the Lua `prepare_interaction_for_verification` function,
1042/// shaped as either `{ error = "..." }` or `{ interaction_data = { body, metadata } }`, into a
1043/// `VerificationPreparationResponse`.
1044fn lua_to_verification_preparation_response(
1045  lua: &Lua,
1046  table: Table,
1047) -> anyhow::Result<VerificationPreparationResponse> {
1048  let error: Option<String> = table.get("error")?;
1049  if let Some(error) = error {
1050    return Ok(VerificationPreparationResponse {
1051      response: Some(verification_preparation_response::Response::Error(error)),
1052    });
1053  }
1054
1055  let data: Option<Value> = table.get("interaction_data")?;
1056  let data = data.ok_or_else(|| {
1057    anyhow!("Lua prepare_interaction_for_verification() must return either an 'error' or 'interaction_data' field")
1058  })?;
1059  let interaction_data = lua_to_interaction_data(lua, Some(data))?
1060    .unwrap_or_else(|| InteractionData { body: None, metadata: HashMap::new() });
1061  Ok(VerificationPreparationResponse {
1062    response: Some(verification_preparation_response::Response::InteractionData(interaction_data)),
1063  })
1064}
1065
1066/// Converts a single Lua verification mismatch (a plain error string, or a mismatch table shaped
1067/// like a `match_contents` mismatch) into a `VerificationResultItem`.
1068fn lua_to_verification_result_item(value: Value) -> anyhow::Result<VerificationResultItem> {
1069  match value {
1070    Value::String(s) => Ok(VerificationResultItem {
1071      result: Some(verification_result_item::Result::Error(s.to_str()?.to_string())),
1072    }),
1073    Value::Table(table) => {
1074      let mismatch: Option<String> = table.get("mismatch")?;
1075      let path: Option<String> = table.get("path")?;
1076      let expected = lua_scalar_to_string(table.get("expected")?)?;
1077      let actual = lua_scalar_to_string(table.get("actual")?)?;
1078      let diff: Option<String> = table.get("diff")?;
1079      let mismatch_type: Option<String> = table.get("mismatch_type")?;
1080      Ok(VerificationResultItem {
1081        result: Some(verification_result_item::Result::Mismatch(ContentMismatch {
1082          expected: expected.map(|s| s.into_bytes()),
1083          actual: actual.map(|s| s.into_bytes()),
1084          mismatch: mismatch.unwrap_or_default(),
1085          path: path.unwrap_or_default(),
1086          diff: diff.unwrap_or_default(),
1087          mismatch_type: mismatch_type.unwrap_or_default(),
1088        })),
1089      })
1090    }
1091    other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
1092  }
1093}
1094
1095/// Converts the table returned by the Lua `verify_interaction` function, shaped as either
1096/// `{ error = "..." }` or
1097/// `{ result = { success, response_data, mismatches = { ... }, output = { ... } } }`, into a
1098/// `VerifyInteractionResponse`.
1099fn lua_to_verify_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<VerifyInteractionResponse> {
1100  let error: Option<String> = table.get("error")?;
1101  if let Some(error) = error {
1102    return Ok(VerifyInteractionResponse {
1103      response: Some(verify_interaction_response::Response::Error(error)),
1104    });
1105  }
1106
1107  let result_table: Option<Table> = table.get("result")?;
1108  let result_table = result_table
1109    .ok_or_else(|| anyhow!("Lua verify_interaction() must return either an 'error' or 'result' field"))?;
1110
1111  let success: bool = result_table.get::<Option<bool>>("success")?.unwrap_or(false);
1112  let response_data: Option<Value> = result_table.get("response_data")?;
1113  let response_data = lua_to_interaction_data(lua, response_data)?;
1114
1115  let mut mismatches = vec![];
1116  let mismatches_value: Option<Value> = result_table.get("mismatches")?;
1117  if let Some(Value::Table(mismatches_table)) = mismatches_value {
1118    for entry in mismatches_table.sequence_values::<Value>() {
1119      mismatches.push(lua_to_verification_result_item(entry?)?);
1120    }
1121  }
1122
1123  let output: Option<Vec<String>> = result_table.get("output")?;
1124
1125  Ok(VerifyInteractionResponse {
1126    response: Some(verify_interaction_response::Response::Result(VerificationResult {
1127      success,
1128      response_data,
1129      mismatches,
1130      output: output.unwrap_or_default(),
1131    })),
1132  })
1133}
1134
1135#[async_trait]
1136impl PactPluginRpc for LuaPactPlugin {
1137  async fn init_plugin(&mut self, request: PluginInitRequest) -> anyhow::Result<PluginInitResponse> {
1138    let lua = self.runtime.lock().await;
1139    let catalogue = call_init(&lua, &request.implementation, &request.version)?;
1140    Ok(PluginInitResponse {
1141      catalogue,
1142      plugin_capabilities: vec![],
1143    })
1144  }
1145}
1146
1147#[async_trait]
1148impl PluginInstance for LuaPactPlugin {
1149  fn manifest(&self) -> &PactPluginManifest {
1150    &self.manifest
1151  }
1152
1153  fn instance_id(&self) -> &str {
1154    &self.instance_id
1155  }
1156
1157  fn has_capability(&self, capability: &str) -> bool {
1158    self.plugin_capabilities.iter().any(|c| c == capability)
1159  }
1160
1161  async fn compare_contents(
1162    &self,
1163    request: CompareContentsRequest,
1164  ) -> anyhow::Result<CompareContentsResponse> {
1165    let lua = self.runtime.lock().await;
1166    let match_fn: Function = lua
1167      .globals()
1168      .get("match_contents")
1169      .map_err(|_| anyhow!("Lua plugin does not define a global 'match_contents' function"))?;
1170    let request_table = compare_request_to_lua(&lua, &request)?;
1171    let result: Table = match_fn
1172      .call_async(request_table)
1173      .await
1174      .map_err(|err| anyhow!("Lua match_contents() function failed - {}", err))?;
1175    lua_to_compare_response(result)
1176  }
1177
1178  async fn configure_interaction(
1179    &self,
1180    request: ConfigureInteractionRequest,
1181  ) -> anyhow::Result<ConfigureInteractionResponse> {
1182    let lua = self.runtime.lock().await;
1183    let configure_fn: Function = lua
1184      .globals()
1185      .get("configure_interaction")
1186      .map_err(|_| anyhow!("Lua plugin does not define a global 'configure_interaction' function"))?;
1187    let config: Value = match &request.contents_config {
1188      Some(config) => lua.to_value(&proto_struct_to_json(config))?,
1189      None => Value::Nil,
1190    };
1191    let result: Table = configure_fn
1192      .call((request.content_type.clone(), config))
1193      .map_err(|err| anyhow!("Lua configure_interaction() function failed - {}", err))?;
1194    lua_to_configure_response(&lua, result)
1195  }
1196
1197  async fn generate_content(
1198    &self,
1199    request: GenerateContentRequest,
1200  ) -> anyhow::Result<GenerateContentResponse> {
1201    let lua = self.runtime.lock().await;
1202    let generate_fn: Option<Function> = lua.globals().get("generate_content")?;
1203    match generate_fn {
1204      None => Ok(GenerateContentResponse {
1205        contents: request.contents,
1206      }),
1207      Some(generate_fn) => {
1208        let contents = body_to_lua(&lua, &request.contents)?;
1209        let generators = lua.create_table()?;
1210        for (path, generator) in &request.generators {
1211          let generator_table = lua.create_table()?;
1212          generator_table.set("type", generator.r#type.clone())?;
1213          if let Some(values) = &generator.values {
1214            generator_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
1215          }
1216          generators.set(path.clone(), generator_table)?;
1217        }
1218        let test_mode = match generate_content_request::TestMode::try_from(request.test_mode)
1219          .unwrap_or(generate_content_request::TestMode::Unknown)
1220        {
1221          generate_content_request::TestMode::Consumer => "Consumer",
1222          generate_content_request::TestMode::Provider => "Provider",
1223          generate_content_request::TestMode::Unknown => "Unknown",
1224        };
1225        let result: Value = generate_fn
1226          .call_async((contents, generators, test_mode))
1227          .await
1228          .map_err(|err| anyhow!("Lua generate_content() function failed - {}", err))?;
1229        Ok(GenerateContentResponse {
1230          contents: lua_to_body(result)?,
1231        })
1232      }
1233    }
1234  }
1235
1236  async fn start_mock_server(
1237    &self,
1238    request: StartMockServerRequest,
1239  ) -> anyhow::Result<StartMockServerResponse> {
1240    let lua = self.runtime.lock().await;
1241    let start_fn: Function = lua
1242      .globals()
1243      .get("start_mock_server")
1244      .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1245    let request_table = lua.create_table()?;
1246    request_table.set("host_interface", request.host_interface)?;
1247    request_table.set("port", request.port)?;
1248    request_table.set("tls", request.tls)?;
1249    request_table.set("pact", request.pact)?;
1250    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1251    let result: Table = start_fn
1252      .call(request_table)
1253      .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1254    lua_to_start_mock_server_response(result)
1255  }
1256
1257  async fn start_mock_server_v2(
1258    &self,
1259    request: proto_v2::StartMockServerRequest,
1260  ) -> anyhow::Result<StartMockServerResponse> {
1261    let lua = self.runtime.lock().await;
1262    let start_fn: Function = lua
1263      .globals()
1264      .get("start_mock_server")
1265      .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1266    let request_table = lua.create_table()?;
1267    request_table.set("host_interface", request.host_interface)?;
1268    request_table.set("port", request.port)?;
1269    request_table.set("tls", request.tls)?;
1270    let interactions_table = lua.create_table()?;
1271    for interaction in &request.interactions {
1272      interactions_table.push(interaction_contents_to_lua(&lua, interaction)?)?;
1273    }
1274    request_table.set("interactions", interactions_table)?;
1275    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1276    let result: Table = start_fn
1277      .call(request_table)
1278      .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1279    lua_to_start_mock_server_response(result)
1280  }
1281
1282  async fn shutdown_mock_server(
1283    &self,
1284    request: ShutdownMockServerRequest,
1285  ) -> anyhow::Result<ShutdownMockServerResponse> {
1286    let lua = self.runtime.lock().await;
1287    let shutdown_fn: Function = lua
1288      .globals()
1289      .get("shutdown_mock_server")
1290      .map_err(|_| anyhow!("Lua plugin does not define a global 'shutdown_mock_server' function"))?;
1291    let result: Table = shutdown_fn
1292      .call(request.server_key)
1293      .map_err(|err| anyhow!("Lua shutdown_mock_server() function failed - {}", err))?;
1294    let results = lua_to_mock_server_results(result)?;
1295    Ok(ShutdownMockServerResponse {
1296      ok: results.ok,
1297      results: results.results,
1298    })
1299  }
1300
1301  async fn get_mock_server_results(
1302    &self,
1303    request: MockServerRequest,
1304  ) -> anyhow::Result<MockServerResults> {
1305    let lua = self.runtime.lock().await;
1306    let results_fn: Function = lua
1307      .globals()
1308      .get("get_mock_server_results")
1309      .map_err(|_| anyhow!("Lua plugin does not define a global 'get_mock_server_results' function"))?;
1310    let result: Table = results_fn
1311      .call(request.server_key)
1312      .map_err(|err| anyhow!("Lua get_mock_server_results() function failed - {}", err))?;
1313    lua_to_mock_server_results(result)
1314  }
1315
1316  async fn prepare_interaction_for_verification(
1317    &self,
1318    request: VerificationPreparationRequest,
1319  ) -> anyhow::Result<VerificationPreparationResponse> {
1320    let lua = self.runtime.lock().await;
1321    let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1322      anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1323    })?;
1324    let request_table = lua.create_table()?;
1325    request_table.set("pact", request.pact)?;
1326    request_table.set("interaction_key", request.interaction_key)?;
1327    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1328    let result: Table = prepare_fn
1329      .call(request_table)
1330      .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1331    lua_to_verification_preparation_response(&lua, result)
1332  }
1333
1334  async fn prepare_interaction_for_verification_v2(
1335    &self,
1336    request: proto_v2::VerificationPreparationRequest,
1337  ) -> anyhow::Result<VerificationPreparationResponse> {
1338    let lua = self.runtime.lock().await;
1339    let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1340      anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1341    })?;
1342    let request_table = lua.create_table()?;
1343    if let Some(interaction_contents) = &request.interaction_contents {
1344      request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1345    }
1346    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1347    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1348    let result: Table = prepare_fn
1349      .call(request_table)
1350      .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1351    lua_to_verification_preparation_response(&lua, result)
1352  }
1353
1354  async fn verify_interaction(
1355    &self,
1356    request: VerifyInteractionRequest,
1357  ) -> anyhow::Result<VerifyInteractionResponse> {
1358    let lua = self.runtime.lock().await;
1359    let verify_fn: Function = lua
1360      .globals()
1361      .get("verify_interaction")
1362      .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1363    let request_table = lua.create_table()?;
1364    request_table.set("interaction_data", interaction_data_to_lua(&lua, &request.interaction_data)?)?;
1365    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1366    request_table.set("pact", request.pact)?;
1367    request_table.set("interaction_key", request.interaction_key)?;
1368    let result: Table = verify_fn
1369      .call(request_table)
1370      .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1371    lua_to_verify_interaction_response(&lua, result)
1372  }
1373
1374  async fn verify_interaction_v2(
1375    &self,
1376    request: proto_v2::VerifyInteractionRequest,
1377  ) -> anyhow::Result<VerifyInteractionResponse> {
1378    let lua = self.runtime.lock().await;
1379    let verify_fn: Function = lua
1380      .globals()
1381      .get("verify_interaction")
1382      .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1383    let request_table = lua.create_table()?;
1384    let interaction_data = request.interaction_data.as_ref()
1385      .map(v2_interaction_data_to_v1)
1386      .transpose()?;
1387    request_table.set("interaction_data", interaction_data_to_lua(&lua, &interaction_data)?)?;
1388    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1389    if let Some(interaction_contents) = &request.interaction_contents {
1390      request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1391    }
1392    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1393    let result: Table = verify_fn
1394      .call(request_table)
1395      .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1396    lua_to_verify_interaction_response(&lua, result)
1397  }
1398
1399  async fn update_catalogue(&self, request: Catalogue) -> anyhow::Result<()> {
1400    let lua = self.runtime.lock().await;
1401    let update_fn: Option<Function> = lua.globals().get("update_catalogue")?;
1402    if let Some(update_fn) = update_fn {
1403      let table = lua.create_table()?;
1404      for entry in &request.catalogue {
1405        let entry_table = lua.create_table()?;
1406        let entry_type = catalogue_entry::EntryType::try_from(entry.r#type)
1407          .unwrap_or(catalogue_entry::EntryType::ContentMatcher);
1408        entry_table.set("entryType", entry_type.as_str_name())?;
1409        entry_table.set("key", entry.key.clone())?;
1410        entry_table.set("values", entry.values.clone())?;
1411        table.push(entry_table)?;
1412      }
1413      update_fn
1414        .call::<()>(table)
1415        .map_err(|err| anyhow!("Lua update_catalogue() function failed - {}", err))?;
1416    }
1417    Ok(())
1418  }
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423  use std::collections::HashMap;
1424
1425  use super::*;
1426
1427  fn jwt_manifest() -> PactPluginManifest {
1428    // Deliberately not `.canonicalize()`d: on Windows that returns a `\\?\`-prefixed verbatim
1429    // path, and the forward slashes `set_package_path`/`add_luarocks_path` append to build
1430    // Lua's `package.path` aren't auto-translated to `\` under that prefix (unlike a normal
1431    // path), breaking `require` for every sibling .lua file. A plain absolute path (with
1432    // unresolved `..` components) resolves fine without it.
1433    let plugin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../plugins/jwt");
1434    assert!(plugin_dir.exists(), "plugins/jwt directory should exist at {:?}", plugin_dir);
1435    PactPluginManifest {
1436      plugin_dir: plugin_dir.to_string_lossy().to_string(),
1437      plugin_interface_version: 1,
1438      name: "jwt".to_string(),
1439      version: "0.0.0".to_string(),
1440      executable_type: "lua".to_string(),
1441      minimum_required_version: None,
1442      entry_point: "plugin.lua".to_string(),
1443      entry_points: HashMap::new(),
1444      args: None,
1445      dependencies: None,
1446      plugin_config: HashMap::new(),
1447    }
1448  }
1449
1450  const PRIVATE_KEY: &str = include_str!("../tests/fixtures/jwt-test-key.pem");
1451
1452  #[test]
1453  fn loads_pure_lua_packages_from_a_configured_luarocks_directory() {
1454    let rocks_root = tempdir::TempDir::new("luarocks-test").unwrap();
1455    let lua_dir = rocks_root.path().join("share").join("lua").join(LUAROCKS_LUA_VERSION);
1456    std::fs::create_dir_all(&lua_dir).unwrap();
1457    std::fs::write(
1458      lua_dir.join("greeter.lua"),
1459      r#"return { hello = function() return "hello from luarocks" end }"#,
1460    ).unwrap();
1461
1462    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1463    std::fs::write(
1464      plugin_dir.path().join("entry.lua"),
1465      r#"
1466        local greeter = require "greeter"
1467        GREETER_RESULT = greeter.hello()
1468      "#,
1469    ).unwrap();
1470
1471    let mut plugin_config = HashMap::new();
1472    plugin_config.insert(
1473      "luaRocksDir".to_string(),
1474      serde_json::Value::String(rocks_root.path().to_string_lossy().to_string()),
1475    );
1476
1477    let manifest = PactPluginManifest {
1478      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1479      plugin_interface_version: 1,
1480      name: "luarocks-test".to_string(),
1481      version: "0.0.0".to_string(),
1482      executable_type: "lua".to_string(),
1483      minimum_required_version: None,
1484      entry_point: "entry.lua".to_string(),
1485      entry_points: HashMap::new(),
1486      args: None,
1487      dependencies: None,
1488      plugin_config,
1489    };
1490
1491    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1492    let lua = plugin.runtime.blocking_lock();
1493    let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1494    assert_eq!(result, "hello from luarocks");
1495  }
1496
1497  #[test]
1498  fn loads_a_vendored_directory_style_module_from_the_plugin_directory() {
1499    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1500    let module_dir = plugin_dir.path().join("greeter");
1501    std::fs::create_dir_all(&module_dir).unwrap();
1502    std::fs::write(
1503      module_dir.join("init.lua"),
1504      r#"return { hello = function() return "hello from a vendored module" end }"#,
1505    ).unwrap();
1506    std::fs::write(
1507      plugin_dir.path().join("entry.lua"),
1508      r#"
1509        local greeter = require "greeter"
1510        GREETER_RESULT = greeter.hello()
1511      "#,
1512    ).unwrap();
1513
1514    let manifest = PactPluginManifest {
1515      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1516      plugin_interface_version: 1,
1517      name: "vendored-module-test".to_string(),
1518      version: "0.0.0".to_string(),
1519      executable_type: "lua".to_string(),
1520      minimum_required_version: None,
1521      entry_point: "entry.lua".to_string(),
1522      entry_points: HashMap::new(),
1523      args: None,
1524      dependencies: None,
1525      plugin_config: HashMap::new(),
1526    };
1527
1528    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1529    let lua = plugin.runtime.blocking_lock();
1530    let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1531    assert_eq!(result, "hello from a vendored module");
1532  }
1533
1534  #[test]
1535  fn loads_a_vendored_module_when_the_entry_point_is_in_a_subdirectory() {
1536    // package.path must be rooted at the plugin directory, not the entry point script's own
1537    // directory, so a vendored module sitting next to a nested entry point still resolves -
1538    // matching the JVM driver, which always uses `manifest.pluginDir`.
1539    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1540    std::fs::write(
1541      plugin_dir.path().join("greeter.lua"),
1542      r#"return { hello = function() return "hello from the plugin root" end }"#,
1543    ).unwrap();
1544    let src_dir = plugin_dir.path().join("src");
1545    std::fs::create_dir_all(&src_dir).unwrap();
1546    std::fs::write(
1547      src_dir.join("entry.lua"),
1548      r#"
1549        local greeter = require "greeter"
1550        GREETER_RESULT = greeter.hello()
1551      "#,
1552    ).unwrap();
1553
1554    let manifest = PactPluginManifest {
1555      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1556      plugin_interface_version: 1,
1557      name: "nested-entry-point-test".to_string(),
1558      version: "0.0.0".to_string(),
1559      executable_type: "lua".to_string(),
1560      minimum_required_version: None,
1561      entry_point: "src/entry.lua".to_string(),
1562      entry_points: HashMap::new(),
1563      args: None,
1564      dependencies: None,
1565      plugin_config: HashMap::new(),
1566    };
1567
1568    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1569    let lua = plugin.runtime.blocking_lock();
1570    let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1571    assert_eq!(result, "hello from the plugin root");
1572  }
1573
1574  #[test]
1575  fn ignores_a_missing_luarocks_directory_instead_of_failing() {
1576    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1577    std::fs::write(plugin_dir.path().join("entry.lua"), "-- no-op").unwrap();
1578
1579    let mut plugin_config = HashMap::new();
1580    plugin_config.insert(
1581      "luaRocksDir".to_string(),
1582      serde_json::Value::String("/no/such/directory".to_string()),
1583    );
1584
1585    let manifest = PactPluginManifest {
1586      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1587      plugin_interface_version: 1,
1588      name: "luarocks-test".to_string(),
1589      version: "0.0.0".to_string(),
1590      executable_type: "lua".to_string(),
1591      minimum_required_version: None,
1592      entry_point: "entry.lua".to_string(),
1593      entry_points: HashMap::new(),
1594      args: None,
1595      dependencies: None,
1596      plugin_config,
1597    };
1598
1599    start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1600  }
1601
1602  #[test]
1603  fn captures_print_and_logger_output_into_the_per_instance_log_file() {
1604    let output_dir = tempdir::TempDir::new("lua-plugin-log-test").unwrap();
1605    // SAFETY: no other test reads/writes PACT_OUTPUT_DIR; matches existing test conventions
1606    // in this crate for env-var-configured global state (see plugin_manager.rs tests).
1607    unsafe { std::env::set_var("PACT_OUTPUT_DIR", output_dir.path()); }
1608
1609    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1610    std::fs::write(
1611      plugin_dir.path().join("entry.lua"),
1612      r#"
1613        print("hello", "world", 42)
1614        logger("a logger message")
1615      "#,
1616    ).unwrap();
1617
1618    let manifest = PactPluginManifest {
1619      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1620      plugin_interface_version: 1,
1621      name: "log-test".to_string(),
1622      version: "0.0.0".to_string(),
1623      executable_type: "lua".to_string(),
1624      minimum_required_version: None,
1625      entry_point: "entry.lua".to_string(),
1626      entry_points: HashMap::new(),
1627      args: None,
1628      dependencies: None,
1629      plugin_config: HashMap::new(),
1630    };
1631
1632    start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1633    unsafe { std::env::remove_var("PACT_OUTPUT_DIR"); }
1634
1635    let log_path = output_dir.path().join("logs").join("pact-plugin-log-test-test-instance.log");
1636    let contents = std::fs::read_to_string(&log_path)
1637      .unwrap_or_else(|err| panic!("Expected a log file at {:?} - {}", log_path, err));
1638    assert_eq!(contents, "hello\tworld\t42\na logger message\n");
1639  }
1640
1641  #[tokio::test]
1642  async fn loads_the_jwt_plugin_and_runs_the_init_function() {
1643    let manifest = jwt_manifest();
1644    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1645    let lua = plugin.runtime.lock().await;
1646    let entries = call_init(&lua, "test", "0.0.0").unwrap();
1647    assert_eq!(entries.len(), 2);
1648    assert_eq!(entries[0].key, "jwt");
1649    assert_eq!(entries[0].r#type, catalogue_entry::EntryType::ContentMatcher as i32);
1650    assert_eq!(entries[1].r#type, catalogue_entry::EntryType::ContentGenerator as i32);
1651  }
1652
1653  #[tokio::test]
1654  async fn configure_interaction_then_match_contents_round_trip() {
1655    let manifest = jwt_manifest();
1656    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1657
1658    let mut config_fields = HashMap::new();
1659    config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
1660    config_fields.insert("subject".to_string(), serde_json::Value::String("test-subject".to_string()));
1661    config_fields.insert("issuer".to_string(), serde_json::Value::String("test-issuer".to_string()));
1662    config_fields.insert("audience".to_string(), serde_json::Value::String("test-audience".to_string()));
1663    config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
1664
1665    let configure_request = ConfigureInteractionRequest {
1666      content_type: "application/jwt+json".to_string(),
1667      contents_config: Some(to_proto_struct(&config_fields)),
1668    };
1669    let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
1670    assert_eq!(configure_response.error, "");
1671    assert_eq!(configure_response.interaction.len(), 1);
1672
1673    let interaction = &configure_response.interaction[0];
1674    let body = interaction.contents.clone().expect("expected a body");
1675    assert_eq!(body.content_type, "application/jwt+json");
1676    let token = String::from_utf8(body.content.clone().unwrap()).unwrap();
1677    assert_eq!(token.split('.').count(), 3);
1678
1679    let compare_request = CompareContentsRequest {
1680      expected: Some(body.clone()),
1681      actual: Some(body),
1682      allow_unexpected_keys: false,
1683      rules: HashMap::new(),
1684      plugin_configuration: interaction.plugin_configuration.clone(),
1685    };
1686    let compare_response = plugin.compare_contents(compare_request).await.unwrap();
1687    assert_eq!(compare_response.error, "");
1688    assert!(compare_response.type_mismatch.is_none());
1689    assert!(
1690      compare_response.results.is_empty(),
1691      "expected no mismatches, got {:?}",
1692      compare_response.results
1693    );
1694  }
1695
1696  #[tokio::test]
1697  async fn match_contents_detects_a_tampered_token() {
1698    let manifest = jwt_manifest();
1699    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1700
1701    let mut config_fields = HashMap::new();
1702    config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
1703    config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
1704
1705    let configure_request = ConfigureInteractionRequest {
1706      content_type: "application/jwt+json".to_string(),
1707      contents_config: Some(to_proto_struct(&config_fields)),
1708    };
1709    let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
1710    let interaction = &configure_response.interaction[0];
1711    let expected_body = interaction.contents.clone().unwrap();
1712
1713    let mut actual_body = expected_body.clone();
1714    let mut token = String::from_utf8(actual_body.content.clone().unwrap()).unwrap();
1715    token.push('x'); // tamper with the signature
1716    actual_body.content = Some(token.into_bytes());
1717
1718    let compare_request = CompareContentsRequest {
1719      expected: Some(expected_body),
1720      actual: Some(actual_body),
1721      allow_unexpected_keys: false,
1722      rules: HashMap::new(),
1723      plugin_configuration: interaction.plugin_configuration.clone(),
1724    };
1725    let compare_response = plugin.compare_contents(compare_request).await.unwrap();
1726    assert!(!compare_response.results.is_empty(), "expected a mismatch to be detected");
1727  }
1728
1729  #[tokio::test]
1730  async fn compare_contents_handles_non_string_mismatch_values() {
1731    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1732    std::fs::write(
1733      plugin_dir.path().join("entry.lua"),
1734      r#"
1735        function match_contents(request)
1736          return {
1737            mismatches = {
1738              ["claims:exp"] = { expected = 123, actual = 456, mismatch = "exp differs", path = "claims:exp" },
1739              ["claims:verified"] = { expected = true, actual = false, mismatch = "verified differs", path = "claims:verified" }
1740            }
1741          }
1742        end
1743      "#,
1744    ).unwrap();
1745
1746    let manifest = PactPluginManifest {
1747      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1748      plugin_interface_version: 1,
1749      name: "scalar-mismatch-test".to_string(),
1750      version: "0.0.0".to_string(),
1751      executable_type: "lua".to_string(),
1752      minimum_required_version: None,
1753      entry_point: "entry.lua".to_string(),
1754      entry_points: HashMap::new(),
1755      args: None,
1756      dependencies: None,
1757      plugin_config: HashMap::new(),
1758    };
1759    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1760
1761    let compare_request = CompareContentsRequest {
1762      expected: None,
1763      actual: None,
1764      allow_unexpected_keys: false,
1765      rules: HashMap::new(),
1766      plugin_configuration: None,
1767    };
1768    let response = plugin.compare_contents(compare_request).await.unwrap();
1769
1770    let exp_mismatch = &response.results["claims:exp"].mismatches[0];
1771    assert_eq!(exp_mismatch.expected.as_deref(), Some("123".as_bytes()));
1772    assert_eq!(exp_mismatch.actual.as_deref(), Some("456".as_bytes()));
1773
1774    let verified_mismatch = &response.results["claims:verified"].mismatches[0];
1775    assert_eq!(verified_mismatch.expected.as_deref(), Some("true".as_bytes()));
1776    assert_eq!(verified_mismatch.actual.as_deref(), Some("false".as_bytes()));
1777  }
1778
1779  fn lua_manifest(plugin_dir: &std::path::Path, name: &str) -> PactPluginManifest {
1780    PactPluginManifest {
1781      plugin_dir: plugin_dir.to_string_lossy().to_string(),
1782      plugin_interface_version: 1,
1783      name: name.to_string(),
1784      version: "0.0.0".to_string(),
1785      executable_type: "lua".to_string(),
1786      minimum_required_version: None,
1787      entry_point: "entry.lua".to_string(),
1788      entry_points: HashMap::new(),
1789      args: None,
1790      dependencies: None,
1791      plugin_config: HashMap::new(),
1792    }
1793  }
1794
1795  fn core_matcher_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
1796    crate::catalogue_manager::CatalogueEntry {
1797      entry_type: crate::catalogue_manager::CatalogueEntryType::CONTENT_MATCHER,
1798      provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
1799      plugin: None,
1800      key: key.to_string(),
1801      values: HashMap::new()
1802    }
1803  }
1804
1805  fn core_generator_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
1806    crate::catalogue_manager::CatalogueEntry {
1807      entry_type: crate::catalogue_manager::CatalogueEntryType::CONTENT_GENERATOR,
1808      provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
1809      plugin: None,
1810      key: key.to_string(),
1811      values: HashMap::new()
1812    }
1813  }
1814
1815  struct FixedErrorCoreMatcher;
1816
1817  #[async_trait]
1818  impl crate::core_capabilities::CoreContentMatcher for FixedErrorCoreMatcher {
1819    async fn compare_contents(&self, _request: CompareContentsRequest) -> anyhow::Result<CompareContentsResponse> {
1820      Ok(CompareContentsResponse {
1821        error: "core matcher says no".to_string(),
1822        type_mismatch: None,
1823        results: HashMap::new(),
1824      })
1825    }
1826  }
1827
1828  #[tokio::test]
1829  async fn match_contents_calls_host_compare_contents_for_a_registered_core_capability() {
1830    let key = "match_contents_calls_host_compare_contents_for_a_registered_core_capability";
1831    crate::catalogue_manager::register_core_entries(&vec![core_matcher_entry(key)]);
1832    crate::core_capabilities::register_core_content_matcher(key, Arc::new(FixedErrorCoreMatcher));
1833
1834    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1835    std::fs::write(
1836      plugin_dir.path().join("entry.lua"),
1837      format!(r#"
1838        function match_contents(request)
1839          return host_compare_contents("{key}", request)
1840        end
1841      "#, key = key),
1842    ).unwrap();
1843    let manifest = lua_manifest(plugin_dir.path(), "host-compare-contents-test");
1844    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1845
1846    let compare_request = CompareContentsRequest {
1847      expected: None,
1848      actual: None,
1849      allow_unexpected_keys: false,
1850      rules: HashMap::new(),
1851      plugin_configuration: None,
1852    };
1853    let response = plugin.compare_contents(compare_request).await.unwrap();
1854
1855    crate::core_capabilities::deregister_core_content_matcher(key);
1856
1857    assert_eq!(response.error, "core matcher says no");
1858  }
1859
1860  #[tokio::test]
1861  async fn match_contents_surfaces_a_clear_error_when_host_compare_contents_targets_an_unregistered_entry() {
1862    let key = "match_contents_surfaces_a_clear_error_when_host_compare_contents_targets_an_unregistered_entry";
1863    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1864    std::fs::write(
1865      plugin_dir.path().join("entry.lua"),
1866      format!(r#"
1867        function match_contents(request)
1868          return host_compare_contents("{key}", request)
1869        end
1870      "#, key = key),
1871    ).unwrap();
1872    let manifest = lua_manifest(plugin_dir.path(), "host-compare-contents-missing-test");
1873    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1874
1875    let compare_request = CompareContentsRequest {
1876      expected: None,
1877      actual: None,
1878      allow_unexpected_keys: false,
1879      rules: HashMap::new(),
1880      plugin_configuration: None,
1881    };
1882    let err = plugin.compare_contents(compare_request).await
1883      .expect_err("expected an error when the target entry is not registered");
1884    assert!(
1885      err.to_string().contains("No catalogue entry found"),
1886      "unexpected error message: {}", err
1887    );
1888  }
1889
1890  struct FixedCoreGenerator;
1891
1892  #[async_trait]
1893  impl crate::core_capabilities::CoreContentGenerator for FixedCoreGenerator {
1894    async fn generate_content(&self, _request: GenerateContentRequest) -> anyhow::Result<GenerateContentResponse> {
1895      Ok(GenerateContentResponse {
1896        contents: Some(Body {
1897          content_type: "text/plain".to_string(),
1898          content: Some(b"generated by the host".to_vec()),
1899          content_type_hint: body::ContentTypeHint::Default as i32,
1900        }),
1901      })
1902    }
1903  }
1904
1905  #[tokio::test]
1906  async fn generate_content_calls_host_generate_content_for_a_registered_core_capability() {
1907    let key = "generate_content_calls_host_generate_content_for_a_registered_core_capability";
1908    crate::catalogue_manager::register_core_entries(&vec![core_generator_entry(key)]);
1909    crate::core_capabilities::register_core_content_generator(key, Arc::new(FixedCoreGenerator));
1910
1911    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1912    std::fs::write(
1913      plugin_dir.path().join("entry.lua"),
1914      format!(r#"
1915        function generate_content(contents, generators, test_mode)
1916          return host_generate_content("{key}", contents, generators, test_mode)
1917        end
1918      "#, key = key),
1919    ).unwrap();
1920    let manifest = lua_manifest(plugin_dir.path(), "host-generate-content-test");
1921    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1922
1923    let request = GenerateContentRequest {
1924      contents: Some(Body {
1925        content_type: "text/plain".to_string(),
1926        content: Some(b"original".to_vec()),
1927        content_type_hint: body::ContentTypeHint::Default as i32,
1928      }),
1929      .. GenerateContentRequest::default()
1930    };
1931    let response = plugin.generate_content(request).await.unwrap();
1932
1933    crate::core_capabilities::deregister_core_content_generator(key);
1934
1935    assert_eq!(response.contents.unwrap().content, Some(b"generated by the host".to_vec()));
1936  }
1937
1938  fn transport_manifest(plugin_dir: &std::path::Path, plugin_interface_version: u8) -> PactPluginManifest {
1939    PactPluginManifest {
1940      plugin_dir: plugin_dir.to_string_lossy().to_string(),
1941      plugin_interface_version,
1942      name: "transport-test".to_string(),
1943      version: "0.0.0".to_string(),
1944      executable_type: "lua".to_string(),
1945      minimum_required_version: None,
1946      entry_point: "entry.lua".to_string(),
1947      entry_points: HashMap::new(),
1948      args: None,
1949      dependencies: None,
1950      plugin_config: HashMap::new(),
1951    }
1952  }
1953
1954  const TRANSPORT_PLUGIN_SCRIPT: &str = r#"
1955    function start_mock_server(request)
1956      START_MOCK_SERVER_REQUEST = request
1957      if request.port == 0 then
1958        return { error = "could not bind a mock server" }
1959      end
1960      return { details = { key = "mock-server-1", port = 12345, address = "127.0.0.1:12345" } }
1961    end
1962
1963    function shutdown_mock_server(server_key)
1964      SHUTDOWN_SERVER_KEY = server_key
1965      return {
1966        ok = false,
1967        results = { { path = "/foo", error = "did not match", mismatches = { "simple string mismatch" } } }
1968      }
1969    end
1970
1971    function get_mock_server_results(server_key)
1972      GET_RESULTS_SERVER_KEY = server_key
1973      return { ok = true, results = {} }
1974    end
1975
1976    function prepare_interaction_for_verification(request)
1977      PREPARE_REQUEST = request
1978      return {
1979        interaction_data = {
1980          body = { content_type = "application/json", contents = "prepared-body", content_type_hint = "TEXT" },
1981          metadata = { path = "/foo", tag = { binary = "raw-bytes" } }
1982        }
1983      }
1984    end
1985
1986    function verify_interaction(request)
1987      VERIFY_REQUEST = request
1988      if request.config ~= nil and request.config.fail == true then
1989        return { error = "verification failed" }
1990      end
1991      return {
1992        result = {
1993          success = true,
1994          response_data = { body = { content_type = "application/json", contents = "response-body" }, metadata = {} },
1995          mismatches = { "a plain mismatch", { mismatch = "a table mismatch", path = "$.foo", expected = 1, actual = 2 } },
1996          output = { "POST /foo", "200 OK" }
1997        }
1998      }
1999    end
2000  "#;
2001
2002  fn start_transport_plugin(plugin_interface_version: u8) -> LuaPactPlugin {
2003    let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2004    std::fs::write(plugin_dir.path().join("entry.lua"), TRANSPORT_PLUGIN_SCRIPT).unwrap();
2005    let manifest = transport_manifest(plugin_dir.path(), plugin_interface_version);
2006    // The script is fully read into the Lua VM by `start_lua_plugin`, so the tempdir doesn't
2007    // need to outlive this call.
2008    start_lua_plugin(&manifest, "test-instance".to_string()).unwrap()
2009  }
2010
2011  #[tokio::test]
2012  async fn start_mock_server_v1_round_trip() {
2013    let plugin = start_transport_plugin(1);
2014    let response = plugin.start_mock_server(StartMockServerRequest {
2015      host_interface: "127.0.0.1".to_string(),
2016      port: 8080,
2017      tls: false,
2018      pact: "{\"consumer\":{}}".to_string(),
2019      test_context: None,
2020    }).await.unwrap();
2021    match response.response.unwrap() {
2022      start_mock_server_response::Response::Details(details) => {
2023        assert_eq!(details.key, "mock-server-1");
2024        assert_eq!(details.port, 12345);
2025        assert_eq!(details.address, "127.0.0.1:12345");
2026      }
2027      other => panic!("expected mock server details, got {:?}", other),
2028    }
2029  }
2030
2031  #[tokio::test]
2032  async fn start_mock_server_v1_returns_the_lua_error() {
2033    let plugin = start_transport_plugin(1);
2034    let response = plugin.start_mock_server(StartMockServerRequest {
2035      host_interface: "127.0.0.1".to_string(),
2036      port: 0,
2037      tls: false,
2038      pact: "{}".to_string(),
2039      test_context: None,
2040    }).await.unwrap();
2041    match response.response.unwrap() {
2042      start_mock_server_response::Response::Error(err) => assert_eq!(err, "could not bind a mock server"),
2043      other => panic!("expected an error response, got {:?}", other),
2044    }
2045  }
2046
2047  #[tokio::test]
2048  async fn start_mock_server_v2_passes_structured_interactions() {
2049    let plugin = start_transport_plugin(2);
2050    let request = proto_v2::StartMockServerRequest {
2051      host_interface: "127.0.0.1".to_string(),
2052      port: 8080,
2053      tls: false,
2054      interactions: vec![proto_v2::InteractionContents {
2055        interaction_type: "Synchronous/HTTP".to_string(),
2056        plugin_configuration: None,
2057        consumer: "test-consumer".to_string(),
2058        provider: "test-provider".to_string(),
2059      }],
2060      test_context: None,
2061    };
2062    let response = plugin.start_mock_server_v2(request).await.unwrap();
2063    assert!(matches!(response.response.unwrap(), start_mock_server_response::Response::Details(_)));
2064
2065    let lua = plugin.runtime.lock().await;
2066    let captured: Table = lua.globals().get("START_MOCK_SERVER_REQUEST").unwrap();
2067    let interactions: Table = captured.get("interactions").unwrap();
2068    let first: Table = interactions.get(1).unwrap();
2069    assert_eq!(first.get::<String>("interaction_type").unwrap(), "Synchronous/HTTP");
2070    assert_eq!(first.get::<String>("consumer").unwrap(), "test-consumer");
2071  }
2072
2073  #[tokio::test]
2074  async fn shutdown_and_get_mock_server_results_parse_mismatches() {
2075    let plugin = start_transport_plugin(1);
2076
2077    let shutdown_response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2078      server_key: "mock-server-1".to_string(),
2079    }).await.unwrap();
2080    assert!(!shutdown_response.ok);
2081    assert_eq!(shutdown_response.results.len(), 1);
2082    assert_eq!(shutdown_response.results[0].path, "/foo");
2083    assert_eq!(shutdown_response.results[0].mismatches[0].mismatch, "simple string mismatch");
2084
2085    let results_response = plugin.get_mock_server_results(MockServerRequest {
2086      server_key: "mock-server-1".to_string(),
2087    }).await.unwrap();
2088    assert!(results_response.ok);
2089    assert!(results_response.results.is_empty());
2090  }
2091
2092  #[tokio::test]
2093  async fn prepare_interaction_for_verification_v1_round_trip() {
2094    let plugin = start_transport_plugin(1);
2095    let response = plugin.prepare_interaction_for_verification(VerificationPreparationRequest {
2096      pact: "{}".to_string(),
2097      interaction_key: "interaction-1".to_string(),
2098      config: None,
2099    }).await.unwrap();
2100
2101    match response.response.unwrap() {
2102      verification_preparation_response::Response::InteractionData(data) => {
2103        let body = data.body.unwrap();
2104        assert_eq!(body.content, Some("prepared-body".as_bytes().to_vec()));
2105        let metadata = data.metadata;
2106        assert!(matches!(
2107          metadata["path"].value,
2108          Some(metadata_value::Value::NonBinaryValue(_))
2109        ));
2110        match &metadata["tag"].value {
2111          Some(metadata_value::Value::BinaryValue(bytes)) => assert_eq!(bytes, b"raw-bytes"),
2112          other => panic!("expected a binary metadata value, got {:?}", other),
2113        }
2114      }
2115      other => panic!("expected interaction data, got {:?}", other),
2116    }
2117  }
2118
2119  #[tokio::test]
2120  async fn prepare_interaction_for_verification_v2_passes_interaction_contents() {
2121    let plugin = start_transport_plugin(2);
2122    let request = proto_v2::VerificationPreparationRequest {
2123      interaction_contents: Some(proto_v2::InteractionContents {
2124        interaction_type: "Synchronous/HTTP".to_string(),
2125        plugin_configuration: None,
2126        consumer: "test-consumer".to_string(),
2127        provider: "test-provider".to_string(),
2128      }),
2129      config: None,
2130      test_context: None,
2131    };
2132    let response = plugin.prepare_interaction_for_verification_v2(request).await.unwrap();
2133    assert!(matches!(
2134      response.response.unwrap(),
2135      verification_preparation_response::Response::InteractionData(_)
2136    ));
2137
2138    let lua = plugin.runtime.lock().await;
2139    let captured: Table = lua.globals().get("PREPARE_REQUEST").unwrap();
2140    let interaction_contents: Table = captured.get("interaction_contents").unwrap();
2141    assert_eq!(interaction_contents.get::<String>("provider").unwrap(), "test-provider");
2142  }
2143
2144  #[tokio::test]
2145  async fn verify_interaction_v1_round_trip() {
2146    let plugin = start_transport_plugin(1);
2147    let mut metadata = HashMap::new();
2148    metadata.insert("path".to_string(), MetadataValue {
2149      value: Some(metadata_value::Value::NonBinaryValue(prost_types::Value {
2150        kind: Some(prost_types::value::Kind::StringValue("/foo".to_string())),
2151      })),
2152    });
2153    let response = plugin.verify_interaction(VerifyInteractionRequest {
2154      interaction_data: Some(InteractionData {
2155        body: Some(Body {
2156          content_type: "application/json".to_string(),
2157          content: Some("request-body".as_bytes().to_vec()),
2158          content_type_hint: body::ContentTypeHint::Text as i32,
2159        }),
2160        metadata,
2161      }),
2162      config: None,
2163      pact: "{}".to_string(),
2164      interaction_key: "interaction-1".to_string(),
2165    }).await.unwrap();
2166
2167    match response.response.unwrap() {
2168      verify_interaction_response::Response::Result(result) => {
2169        assert!(result.success);
2170        assert_eq!(result.output, vec!["POST /foo".to_string(), "200 OK".to_string()]);
2171        assert_eq!(result.mismatches.len(), 2);
2172        match &result.mismatches[0].result {
2173          Some(verification_result_item::Result::Error(err)) => assert_eq!(err, "a plain mismatch"),
2174          other => panic!("expected an error mismatch, got {:?}", other),
2175        }
2176        match &result.mismatches[1].result {
2177          Some(verification_result_item::Result::Mismatch(mismatch)) => {
2178            assert_eq!(mismatch.mismatch, "a table mismatch");
2179            assert_eq!(mismatch.expected, Some(b"1".to_vec()));
2180          }
2181          other => panic!("expected a mismatch, got {:?}", other),
2182        }
2183      }
2184      other => panic!("expected a verification result, got {:?}", other),
2185    }
2186
2187    let lua = plugin.runtime.lock().await;
2188    let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
2189    let interaction_data: Table = captured.get("interaction_data").unwrap();
2190    let metadata: Table = interaction_data.get("metadata").unwrap();
2191    assert_eq!(metadata.get::<String>("path").unwrap(), "/foo");
2192  }
2193
2194  #[tokio::test]
2195  async fn verify_interaction_v1_returns_the_lua_error() {
2196    let plugin = start_transport_plugin(1);
2197    let mut config = HashMap::new();
2198    config.insert("fail".to_string(), serde_json::Value::Bool(true));
2199    let response = plugin.verify_interaction(VerifyInteractionRequest {
2200      interaction_data: None,
2201      config: Some(to_proto_struct(&config)),
2202      pact: "{}".to_string(),
2203      interaction_key: "interaction-1".to_string(),
2204    }).await.unwrap();
2205    match response.response.unwrap() {
2206      verify_interaction_response::Response::Error(err) => assert_eq!(err, "verification failed"),
2207      other => panic!("expected an error response, got {:?}", other),
2208    }
2209  }
2210
2211  #[tokio::test]
2212  async fn verify_interaction_v2_converts_the_v2_interaction_data_and_contents() {
2213    let plugin = start_transport_plugin(2);
2214    let request = proto_v2::VerifyInteractionRequest {
2215      interaction_data: Some(proto_v2::InteractionData {
2216        body: Some(proto_v2::Body {
2217          content_type: "application/json".to_string(),
2218          content: Some("request-body".as_bytes().to_vec()),
2219          content_type_hint: 0,
2220        }),
2221        metadata: HashMap::new(),
2222      }),
2223      config: None,
2224      interaction_contents: Some(proto_v2::InteractionContents {
2225        interaction_type: "Synchronous/HTTP".to_string(),
2226        plugin_configuration: None,
2227        consumer: "test-consumer".to_string(),
2228        provider: "test-provider".to_string(),
2229      }),
2230      test_context: None,
2231    };
2232    let response = plugin.verify_interaction_v2(request).await.unwrap();
2233    assert!(matches!(
2234      response.response.unwrap(),
2235      verify_interaction_response::Response::Result(_)
2236    ));
2237
2238    let lua = plugin.runtime.lock().await;
2239    let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
2240    let interaction_data: Table = captured.get("interaction_data").unwrap();
2241    let body: Table = interaction_data.get("body").unwrap();
2242    assert_eq!(body.get::<mlua::String>("contents").unwrap().to_str().unwrap(), "request-body");
2243    let interaction_contents: Table = captured.get("interaction_contents").unwrap();
2244    assert_eq!(interaction_contents.get::<String>("consumer").unwrap(), "test-consumer");
2245  }
2246
2247  #[tokio::test]
2248  async fn shutdown_mock_server_defaults_ok_to_true_when_the_field_is_absent() {
2249    // Regression test: `Table::get::<bool>("ok")` converts a *missing* key's Lua nil straight
2250    // to `false` (mlua's bool conversion, matching Lua's own nil-is-falsy semantics) rather than
2251    // erroring - so a plain `.unwrap_or(true)` fallback was never reached, and an `ok`-less
2252    // response used to silently report `ok = false` instead of the documented default of
2253    // `true`. Reading as `Option<bool>` first lets a missing key correctly fall through to the
2254    // `unwrap_or(true)` default, since `Option<T>` intercepts Lua nil before the inner
2255    // conversion happens.
2256    let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2257    std::fs::write(
2258      plugin_dir.path().join("entry.lua"),
2259      r#"
2260        function shutdown_mock_server(server_key)
2261          return { results = {} }
2262        end
2263      "#,
2264    ).unwrap();
2265    let manifest = transport_manifest(plugin_dir.path(), 1);
2266    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2267
2268    let response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2269      server_key: "mock-server-1".to_string(),
2270    }).await.unwrap();
2271    assert!(response.ok, "expected 'ok' to default to true when the Lua script doesn't set it");
2272  }
2273
2274  #[tokio::test]
2275  async fn shutdown_mock_server_errors_on_a_wrong_typed_path_field() {
2276    let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2277    std::fs::write(
2278      plugin_dir.path().join("entry.lua"),
2279      r#"
2280        function shutdown_mock_server(server_key)
2281          return { ok = false, results = { { path = {}, error = "boom", mismatches = {} } } }
2282        end
2283      "#,
2284    ).unwrap();
2285    let manifest = transport_manifest(plugin_dir.path(), 1);
2286    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2287
2288    let result = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2289      server_key: "mock-server-1".to_string(),
2290    }).await;
2291    assert!(
2292      result.is_err(),
2293      "expected a wrong-typed 'path' field (a table, not a string) to be a hard error, not silently default, got {:?}",
2294      result
2295    );
2296  }
2297}