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, warn};
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::LuaString, 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  // The field-level equivalents (proposal 006, "Lua transport"): let a plugin that owns a content
369  // type delegate one value inside it to a standard Pact rule - or to another plugin's rule -
370  // instead of reimplementing it. Async and cycle-free for the same reasons as the two above.
371  globals.set(
372    "host_match_field",
373    lua.create_async_function(move |lua, (entry_key, request): (String, Table)| async move {
374      let request = lua_to_match_field_request(&lua, request).map_err(mlua::Error::external)?;
375      let response = call_host_match_field(&entry_key, request).await.map_err(mlua::Error::external)?;
376      match_field_response_to_lua(&lua, &response)
377    })?,
378  )?;
379
380  globals.set(
381    "host_generate_field",
382    lua.create_async_function(move |lua, (entry_key, request): (String, Table)| async move {
383      let request = lua_to_generate_field_request(&lua, request).map_err(mlua::Error::external)?;
384      let response = call_host_generate_field(&entry_key, request).await.map_err(mlua::Error::external)?;
385      generate_field_response_to_lua(&lua, &response)
386    })?,
387  )?;
388
389  Ok(())
390}
391
392/// Resolve `entry_key` to a content matcher capability and dispatch to it - a host-registered
393/// [`crate::core_capabilities::CoreContentMatcher`] called in-process, or another running plugin
394/// called via a freshly-started call chain (see [`crate::call_chain`]), matching the same
395/// resolver [`crate::plugin_host`] uses for the gRPC callback path. Backs the `host_compare_contents`
396/// Lua host function.
397async fn call_host_compare_contents(
398  entry_key: &str,
399  request: CompareContentsRequest
400) -> anyhow::Result<CompareContentsResponse> {
401  match resolve_capability(entry_key, CatalogueEntryType::CONTENT_MATCHER)? {
402    ResolvedCapability::Core(core_key) => {
403      let handler = crate::core_capabilities::lookup_core_content_matcher(&core_key)
404        .ok_or_else(|| anyhow!("No core content matcher registered for '{}'", core_key))?;
405      handler.compare_contents(request).await
406    }
407    ResolvedCapability::Plugin(manifest) => {
408      let plugin = lookup_plugin(&manifest.as_dependency())
409        .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
410      let chain_id = call_chain::new_call_chain_id();
411      let deadline_ms = call_chain::default_deadline_ms();
412      plugin.compare_contents_with_chain(request, &chain_id, deadline_ms).await
413    }
414  }
415}
416
417/// Resolve `entry_key` to a content generator capability and dispatch to it. See
418/// [`call_host_compare_contents`]; backs the `host_generate_content` Lua host function.
419async fn call_host_generate_content(
420  entry_key: &str,
421  request: GenerateContentRequest
422) -> anyhow::Result<GenerateContentResponse> {
423  match resolve_capability(entry_key, CatalogueEntryType::CONTENT_GENERATOR)? {
424    ResolvedCapability::Core(core_key) => {
425      let handler = crate::core_capabilities::lookup_core_content_generator(&core_key)
426        .ok_or_else(|| anyhow!("No core content generator registered for '{}'", core_key))?;
427      handler.generate_content(request).await
428    }
429    ResolvedCapability::Plugin(manifest) => {
430      let plugin = lookup_plugin(&manifest.as_dependency())
431        .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
432      let chain_id = call_chain::new_call_chain_id();
433      let deadline_ms = call_chain::default_deadline_ms();
434      plugin.generate_content_with_chain(request, &chain_id, deadline_ms).await
435    }
436  }
437}
438
439/// Resolve `entry_key` to a field-level matching rule and dispatch to it. See
440/// [`call_host_compare_contents`]; backs the `host_match_field` Lua host function.
441async fn call_host_match_field(
442  entry_key: &str,
443  request: proto_v2::MatchFieldRequest
444) -> anyhow::Result<proto_v2::MatchFieldResponse> {
445  match resolve_capability(entry_key, CatalogueEntryType::MATCHER)? {
446    ResolvedCapability::Core(core_key) => {
447      let handler = crate::core_capabilities::lookup_core_field_matcher(&core_key)
448        .ok_or_else(|| anyhow!("No core field matcher registered for '{}'", core_key))?;
449      handler.match_field(request).await
450    }
451    ResolvedCapability::Plugin(manifest) => {
452      let plugin = lookup_plugin(&manifest.as_dependency())
453        .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
454      let chain_id = call_chain::new_call_chain_id();
455      let deadline_ms = call_chain::default_deadline_ms();
456      plugin.match_field_with_chain(request, &chain_id, deadline_ms).await
457    }
458  }
459}
460
461/// Resolve `entry_key` to a field-level generator and dispatch to it. See
462/// [`call_host_compare_contents`]; backs the `host_generate_field` Lua host function.
463async fn call_host_generate_field(
464  entry_key: &str,
465  request: proto_v2::GenerateFieldRequest
466) -> anyhow::Result<proto_v2::GenerateFieldResponse> {
467  match resolve_capability(entry_key, CatalogueEntryType::GENERATOR)? {
468    ResolvedCapability::Core(core_key) => {
469      let handler = crate::core_capabilities::lookup_core_field_generator(&core_key)
470        .ok_or_else(|| anyhow!("No core field generator registered for '{}'", core_key))?;
471      handler.generate_field(request).await
472    }
473    ResolvedCapability::Plugin(manifest) => {
474      let plugin = lookup_plugin(&manifest.as_dependency())
475        .ok_or_else(|| anyhow!("Plugin '{}' for entry '{}' is not currently running", manifest.name, entry_key))?;
476      let chain_id = call_chain::new_call_chain_id();
477      let deadline_ms = call_chain::default_deadline_ms();
478      plugin.generate_field_with_chain(request, &chain_id, deadline_ms).await
479    }
480  }
481}
482
483/// Decode base64 (URL-safe), trying the padded then the un-padded alphabet.
484fn decode_base64_lenient(data: &str) -> anyhow::Result<Vec<u8>> {
485  use base64::Engine;
486  base64::engine::general_purpose::URL_SAFE
487    .decode(data)
488    .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(data))
489    .map_err(|err| anyhow!("Failed to base64 decode value - {}", err))
490}
491
492fn call_init(
493  lua: &Lua,
494  implementation: &str,
495  version: &str,
496) -> anyhow::Result<Vec<CatalogueEntry>> {
497  let init_fn: Function = lua
498    .globals()
499    .get("init")
500    .map_err(|_| anyhow!("Lua plugin does not define a global 'init' function"))?;
501  let result: Table = init_fn
502    .call((implementation.to_string(), version.to_string()))
503    .map_err(|err| anyhow!("Lua init() function failed - {}", err))?;
504  lua_table_to_catalogue_entries(result)
505}
506
507fn lua_table_to_catalogue_entries(table: Table) -> anyhow::Result<Vec<CatalogueEntry>> {
508  let mut entries = vec![];
509  for entry in table.sequence_values::<Table>() {
510    let entry = entry?;
511    let entry_type_str: String = entry.get("entryType")?;
512    let key: String = entry.get("key")?;
513    let values: Option<HashMap<String, String>> = entry.get("values")?;
514    let entry_type = CatalogueEntryType::from_proto_name(&entry_type_str)
515      .ok_or_else(|| anyhow!("Unknown catalogue entry type '{}'", entry_type_str))?;
516    entries.push(CatalogueEntry {
517      r#type: entry_type.to_proto_value(),
518      key,
519      values: values.unwrap_or_default(),
520    });
521  }
522  Ok(entries)
523}
524
525// ---- Body <-> Lua ----
526
527fn content_type_hint_to_str(hint: i32) -> &'static str {
528  match body::ContentTypeHint::try_from(hint).unwrap_or(body::ContentTypeHint::Default) {
529    body::ContentTypeHint::Default => "DEFAULT",
530    body::ContentTypeHint::Text => "TEXT",
531    body::ContentTypeHint::Binary => "BINARY",
532  }
533}
534
535fn str_to_content_type_hint(hint: &str) -> i32 {
536  match hint {
537    "TEXT" => body::ContentTypeHint::Text as i32,
538    "BINARY" => body::ContentTypeHint::Binary as i32,
539    _ => body::ContentTypeHint::Default as i32,
540  }
541}
542
543fn body_to_lua(lua: &Lua, body: &Option<Body>) -> mlua::Result<Value> {
544  match body {
545    None => Ok(Value::Nil),
546    Some(body) => {
547      let table = lua.create_table()?;
548      table.set("content_type", body.content_type.clone())?;
549      match &body.content {
550        Some(bytes) => table.set("contents", lua.create_string(bytes)?)?,
551        None => table.set("contents", Value::Nil)?,
552      }
553      table.set("content_type_hint", content_type_hint_to_str(body.content_type_hint))?;
554      Ok(Value::Table(table))
555    }
556  }
557}
558
559fn lua_to_body(value: Value) -> anyhow::Result<Option<Body>> {
560  match value {
561    Value::Nil => Ok(None),
562    Value::Table(table) => {
563      let content_type: String = table.get("content_type")?;
564      let contents: Option<mlua::LuaString> = table.get("contents")?;
565      let content_type_hint: Option<String> = table.get("content_type_hint")?;
566      Ok(Some(Body {
567        content_type,
568        content: contents.map(|s| s.as_bytes().to_vec()),
569        content_type_hint: content_type_hint
570          .map(|h| str_to_content_type_hint(&h))
571          .unwrap_or(body::ContentTypeHint::Default as i32),
572      }))
573    }
574    _ => Err(anyhow!("Expected a body table or nil from Lua, got {}", value.type_name())),
575  }
576}
577
578// ---- Matching rules / generators / plugin configuration <-> Lua ----
579
580fn matching_rules_to_lua(lua: &Lua, rules: &HashMap<String, MatchingRules>) -> mlua::Result<Table> {
581  let table = lua.create_table()?;
582  for (path, rule_list) in rules {
583    let rules_table = lua.create_table()?;
584    for rule in &rule_list.rule {
585      let rule_table = lua.create_table()?;
586      rule_table.set("type", rule.r#type.clone())?;
587      if let Some(values) = &rule.values {
588        rule_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
589      }
590      rules_table.push(rule_table)?;
591    }
592    table.set(path.clone(), rules_table)?;
593  }
594  Ok(table)
595}
596
597/// Reverse of [`matching_rules_to_lua`] - used by `host_compare_contents` to convert the rules a
598/// plugin script builds when calling back into a host-provided or another plugin's matcher.
599fn lua_to_matching_rules(lua: &Lua, table: Option<Table>) -> anyhow::Result<HashMap<String, MatchingRules>> {
600  let mut result = HashMap::new();
601  if let Some(table) = table {
602    for pair in table.pairs::<String, Table>() {
603      let (path, rules_table) = pair?;
604      let mut rule = vec![];
605      for rule_value in rules_table.sequence_values::<Table>() {
606        let rule_table = rule_value?;
607        let r#type: String = rule_table.get("type")?;
608        let values: Option<Value> = rule_table.get("values")?;
609        let values = match values {
610          Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
611          None => None,
612        };
613        rule.push(MatchingRule { r#type, values });
614      }
615      result.insert(path, MatchingRules { rule });
616    }
617  }
618  Ok(result)
619}
620
621fn plugin_configuration_to_lua(lua: &Lua, config: &Option<PluginConfiguration>) -> mlua::Result<Value> {
622  match config {
623    None => Ok(Value::Nil),
624    Some(config) => {
625      let table = lua.create_table()?;
626      if let Some(interaction_configuration) = &config.interaction_configuration {
627        table.set(
628          "interaction_configuration",
629          lua.to_value(&proto_struct_to_json(interaction_configuration))?,
630        )?;
631      }
632      if let Some(pact_configuration) = &config.pact_configuration {
633        table.set(
634          "pact_configuration",
635          lua.to_value(&proto_struct_to_json(pact_configuration))?,
636        )?;
637      }
638      Ok(Value::Table(table))
639    }
640  }
641}
642
643fn lua_to_plugin_configuration(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<PluginConfiguration>> {
644  match value {
645    None | Some(Value::Nil) => Ok(None),
646    Some(Value::Table(table)) => {
647      let interaction_configuration: Option<serde_json::Value> =
648        table.get::<Option<Value>>("interaction_configuration")?
649          .map(|v| lua.from_value(v))
650          .transpose()?;
651      let pact_configuration: Option<serde_json::Value> =
652        table.get::<Option<Value>>("pact_configuration")?
653          .map(|v| lua.from_value(v))
654          .transpose()?;
655      Ok(Some(PluginConfiguration {
656        interaction_configuration: interaction_configuration.map(|v| to_proto_struct(&as_json_map(v))),
657        pact_configuration: pact_configuration.map(|v| to_proto_struct(&as_json_map(v))),
658      }))
659    }
660    Some(other) => Err(anyhow!("Expected a plugin_config table or nil from Lua, got {}", other.type_name())),
661  }
662}
663
664fn as_json_map(value: serde_json::Value) -> HashMap<String, serde_json::Value> {
665  match value {
666    serde_json::Value::Object(map) => map.into_iter().collect(),
667    _ => HashMap::new(),
668  }
669}
670
671// ---- CompareContents <-> Lua ----
672
673fn compare_request_to_lua(lua: &Lua, request: &CompareContentsRequest) -> mlua::Result<Table> {
674  let table = lua.create_table()?;
675  table.set("expected", body_to_lua(lua, &request.expected)?)?;
676  table.set("actual", body_to_lua(lua, &request.actual)?)?;
677  table.set("allow_unexpected_keys", request.allow_unexpected_keys)?;
678  table.set("rules", matching_rules_to_lua(lua, &request.rules)?)?;
679  table.set(
680    "plugin_configuration",
681    plugin_configuration_to_lua(lua, &request.plugin_configuration)?,
682  )?;
683  Ok(table)
684}
685
686/// Reverse of [`compare_request_to_lua`] - the request table shape a plugin script builds when
687/// calling `host_compare_contents(entry_key, request)` (see [`register_host_functions`]) is the
688/// same shape its own `match_contents(request)` function receives.
689fn lua_to_compare_request(lua: &Lua, table: Table) -> anyhow::Result<CompareContentsRequest> {
690  let expected: Value = table.get("expected")?;
691  let actual: Value = table.get("actual")?;
692  let allow_unexpected_keys: Option<bool> = table.get("allow_unexpected_keys")?;
693  let rules: Option<Table> = table.get("rules")?;
694  let plugin_configuration: Option<Value> = table.get("plugin_configuration")?;
695  Ok(CompareContentsRequest {
696    expected: lua_to_body(expected)?,
697    actual: lua_to_body(actual)?,
698    allow_unexpected_keys: allow_unexpected_keys.unwrap_or(false),
699    rules: lua_to_matching_rules(lua, rules)?,
700    plugin_configuration: lua_to_plugin_configuration(lua, plugin_configuration)?,
701  })
702}
703
704fn lua_to_compare_response(table: Table) -> anyhow::Result<CompareContentsResponse> {
705  let error: Option<String> = table.get("error")?;
706  if let Some(error) = error {
707    return Ok(CompareContentsResponse {
708      error,
709      type_mismatch: None,
710      results: HashMap::new(),
711    });
712  }
713
714  let type_mismatch: Option<Table> = table.get("type-mismatch")?;
715  if let Some(type_mismatch) = type_mismatch {
716    let expected: String = type_mismatch.get("expected")?;
717    let actual: String = type_mismatch.get("actual")?;
718    return Ok(CompareContentsResponse {
719      error: String::new(),
720      type_mismatch: Some(ContentTypeMismatch { expected, actual }),
721      results: HashMap::new(),
722    });
723  }
724
725  let mismatches: Option<Table> = table.get("mismatches")?;
726  let mut results = HashMap::new();
727  if let Some(mismatches) = mismatches {
728    for pair in mismatches.pairs::<String, Value>() {
729      let (path, value) = pair?;
730      let mismatch_list = lua_value_to_content_mismatches(&path, value)?;
731      if !mismatch_list.is_empty() {
732        results.insert(path, ContentMismatches { mismatches: mismatch_list });
733      }
734    }
735  }
736
737  Ok(CompareContentsResponse {
738    error: String::new(),
739    type_mismatch: None,
740    results,
741  })
742}
743
744/// Stringifies a scalar Lua value (used for the `expected`/`actual` fields of a mismatch
745/// table), rather than requiring exactly a Lua string - a claim/header value being compared
746/// could just as easily be a number or boolean.
747fn lua_scalar_to_string(value: Value) -> anyhow::Result<Option<String>> {
748  match value {
749    Value::Nil => Ok(None),
750    Value::Boolean(b) => Ok(Some(b.to_string())),
751    Value::Integer(i) => Ok(Some(i.to_string())),
752    Value::Number(n) => Ok(Some(n.to_string())),
753    Value::String(s) => Ok(Some(s.to_str()?.to_string())),
754    other => Err(anyhow!("Expected a scalar mismatch value from Lua, got {}", other.type_name())),
755  }
756}
757
758fn lua_value_to_content_mismatches(path: &str, value: Value) -> anyhow::Result<Vec<ContentMismatch>> {
759  match value {
760    Value::String(s) => Ok(vec![ContentMismatch {
761      expected: None,
762      actual: None,
763      mismatch: s.to_str()?.to_string(),
764      path: path.to_string(),
765      diff: String::new(),
766      mismatch_type: String::new(),
767    }]),
768    Value::Table(table) => {
769      // Either a single mismatch table ({mismatch=..., expected=..., ...}), or a sequence of them / plain strings
770      let mismatch_field: Option<String> = table.get("mismatch")?;
771      if let Some(mismatch) = mismatch_field {
772        // expected/actual can reasonably be non-string Lua values (e.g. a numeric or boolean
773        // claim value), so stringify whatever's there rather than requiring exactly a string.
774        let expected = lua_scalar_to_string(table.get("expected")?)?;
775        let actual = lua_scalar_to_string(table.get("actual")?)?;
776        let path_override: Option<String> = table.get("path")?;
777        let diff: Option<String> = table.get("diff")?;
778        let mismatch_type: Option<String> = table.get("mismatch_type")?;
779        Ok(vec![ContentMismatch {
780          expected: expected.map(|s| s.into_bytes()),
781          actual: actual.map(|s| s.into_bytes()),
782          mismatch,
783          path: path_override.unwrap_or_else(|| path.to_string()),
784          diff: diff.unwrap_or_default(),
785          mismatch_type: mismatch_type.unwrap_or_default(),
786        }])
787      } else {
788        let mut result = vec![];
789        for entry in table.sequence_values::<Value>() {
790          result.extend(lua_value_to_content_mismatches(path, entry?)?);
791        }
792        Ok(result)
793      }
794    }
795    Value::Nil => Ok(vec![]),
796    other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
797  }
798}
799
800/// Converts a path's mismatches into the sequence-of-tables shape
801/// [`lua_value_to_content_mismatches`] parses, so a `host_compare_contents` response can be
802/// passed straight through as part of the calling plugin's own `match_contents` response.
803fn content_mismatches_to_lua(lua: &Lua, mismatches: &[ContentMismatch]) -> mlua::Result<Table> {
804  let list = lua.create_table()?;
805  for mismatch in mismatches {
806    let table = lua.create_table()?;
807    table.set("mismatch", mismatch.mismatch.clone())?;
808    if let Some(expected) = &mismatch.expected {
809      table.set("expected", lua.create_string(expected)?)?;
810    }
811    if let Some(actual) = &mismatch.actual {
812      table.set("actual", lua.create_string(actual)?)?;
813    }
814    table.set("path", mismatch.path.clone())?;
815    if !mismatch.diff.is_empty() {
816      table.set("diff", mismatch.diff.clone())?;
817    }
818    if !mismatch.mismatch_type.is_empty() {
819      table.set("mismatch_type", mismatch.mismatch_type.clone())?;
820    }
821    list.push(table)?;
822  }
823  Ok(list)
824}
825
826/// Reverse of [`lua_to_compare_response`] - the table `host_compare_contents` returns is shaped
827/// exactly like what a plugin's own `match_contents` function is expected to return, so a plugin
828/// can pass a host/forwarded comparison's result straight through as its own response.
829fn compare_response_to_lua(lua: &Lua, response: &CompareContentsResponse) -> mlua::Result<Table> {
830  let table = lua.create_table()?;
831  if !response.error.is_empty() {
832    table.set("error", response.error.clone())?;
833    return Ok(table);
834  }
835  if let Some(type_mismatch) = &response.type_mismatch {
836    let mismatch_table = lua.create_table()?;
837    mismatch_table.set("expected", type_mismatch.expected.clone())?;
838    mismatch_table.set("actual", type_mismatch.actual.clone())?;
839    table.set("type-mismatch", mismatch_table)?;
840    return Ok(table);
841  }
842  if !response.results.is_empty() {
843    let mismatches_table = lua.create_table()?;
844    for (path, content_mismatches) in &response.results {
845      mismatches_table.set(path.clone(), content_mismatches_to_lua(lua, &content_mismatches.mismatches)?)?;
846    }
847    table.set("mismatches", mismatches_table)?;
848  }
849  Ok(table)
850}
851
852// ---- GenerateContent <-> Lua ----
853
854/// Converts the `(entry_key, contents, generators, test_mode)` arguments a plugin script passes
855/// to `host_generate_content` (see [`register_host_functions`]) into a `GenerateContentRequest` -
856/// the same three trailing arguments its own `generate_content(contents, generators, test_mode)`
857/// function receives.
858fn lua_to_generate_request(
859  lua: &Lua,
860  contents: Value,
861  generators: Option<Table>,
862  test_mode: Option<String>
863) -> anyhow::Result<GenerateContentRequest> {
864  let mut generator_map = HashMap::new();
865  if let Some(generators) = generators {
866    for pair in generators.pairs::<String, Table>() {
867      let (path, generator_table) = pair?;
868      let r#type: String = generator_table.get("type")?;
869      let values: Option<Value> = generator_table.get("values")?;
870      let values = match values {
871        Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
872        None => None,
873      };
874      generator_map.insert(path, Generator { r#type, values });
875    }
876  }
877  Ok(GenerateContentRequest {
878    contents: lua_to_body(contents)?,
879    generators: generator_map,
880    test_mode: str_to_test_mode(test_mode.as_deref()),
881    .. GenerateContentRequest::default()
882  })
883}
884
885/// The test mode name a Lua script sees. The V1 and V2 `GenerateContentRequest.TestMode` enums
886/// have the same values, so one pair of helpers covers content generation on either interface as
887/// well as field-level generation (which is V2-only).
888fn test_mode_to_str(test_mode: i32) -> &'static str {
889  match generate_content_request::TestMode::try_from(test_mode)
890    .unwrap_or(generate_content_request::TestMode::Unknown)
891  {
892    generate_content_request::TestMode::Consumer => "Consumer",
893    generate_content_request::TestMode::Provider => "Provider",
894    generate_content_request::TestMode::Unknown => "Unknown",
895  }
896}
897
898/// Reverse of [`test_mode_to_str`]. Anything unrecognised (including a missing value) is
899/// `Unknown`, rather than an error - the mode is context for the plugin, not a contract.
900fn str_to_test_mode(test_mode: Option<&str>) -> i32 {
901  match test_mode {
902    Some("Consumer") => generate_content_request::TestMode::Consumer as i32,
903    Some("Provider") => generate_content_request::TestMode::Provider as i32,
904    _ => generate_content_request::TestMode::Unknown as i32,
905  }
906}
907
908// ---- MatchField / GenerateField <-> Lua ----
909
910/// Converts a single field value to a plain Lua value, following the convention message metadata
911/// values already use (see [`metadata_to_lua`]): everything crosses as a plain Lua value except
912/// binary data, which arrives as a `{ binary = <lua string> }` wrapper table so a script can tell
913/// a string of text from a blob of bytes.
914///
915/// Lua 5.4 has separate integer and float subtypes, so a whole number stays whole across the
916/// boundary. That distinction is what the `integer`, `decimal` and `type` rules are built on, and
917/// is the reason `FieldValue` has an arm per scalar type in the first place - see proposal 006,
918/// section 5.
919fn field_value_to_lua(lua: &Lua, value: &Option<proto_v2::FieldValue>) -> mlua::Result<Value> {
920  use proto_v2::field_value::Value as FieldValue;
921  match value.as_ref().and_then(|value| value.value.as_ref()) {
922    None | Some(FieldValue::NullValue(_)) => Ok(Value::Nil),
923    Some(FieldValue::BooleanValue(value)) => Ok(Value::Boolean(*value)),
924    Some(FieldValue::StringValue(value)) => Ok(Value::String(lua.create_string(value)?)),
925    Some(FieldValue::IntegerValue(value)) => Ok(Value::Integer(*value)),
926    Some(FieldValue::DecimalValue(value)) => Ok(Value::Number(*value)),
927    Some(FieldValue::BinaryValue(bytes)) => {
928      let wrapper = lua.create_table()?;
929      wrapper.set("binary", lua.create_string(bytes)?)?;
930      Ok(Value::Table(wrapper))
931    }
932    Some(FieldValue::StructuredValue(value)) => lua.to_value(&proto_value_to_json(value)),
933  }
934}
935
936/// Reverse of [`field_value_to_lua`]. A table is a binary wrapper if it has a `binary` key, and a
937/// map or list otherwise - the same test [`lua_to_metadata`] applies.
938fn lua_to_field_value(lua: &Lua, value: Value) -> anyhow::Result<proto_v2::FieldValue> {
939  use proto_v2::field_value::Value as FieldValue;
940  let value = match value {
941    Value::Nil => FieldValue::NullValue(0),
942    Value::Boolean(value) => FieldValue::BooleanValue(value),
943    Value::Integer(value) => FieldValue::IntegerValue(value),
944    Value::Number(value) => FieldValue::DecimalValue(value),
945    Value::String(value) => FieldValue::StringValue(value.to_str()?.to_string()),
946    Value::Table(table) => match table.get::<Option<mlua::LuaString>>("binary")? {
947      Some(binary) => FieldValue::BinaryValue(binary.as_bytes().to_vec()),
948      None => FieldValue::StructuredValue(to_proto_value(&lua.from_value(Value::Table(table))?)),
949    },
950    other => return Err(anyhow!("Expected a field value from Lua, got {}", other.type_name())),
951  };
952  Ok(proto_v2::FieldValue { value: Some(value) })
953}
954
955/// Converts a matching rule or a generator - each is a name plus an optional struct of configured
956/// values - into the `{ type = "...", values = { ... } }` table a script already sees for the
957/// rules and generators passed to `match_contents`/`generate_content`. Always a table, even with
958/// no values, so a script can read `rule.values` without checking `rule` first.
959fn typed_values_to_lua(
960  lua: &Lua,
961  r#type: &str,
962  values: &Option<prost_types::Struct>
963) -> mlua::Result<Table> {
964  let table = lua.create_table()?;
965  table.set("type", r#type.to_string())?;
966  if let Some(values) = values {
967    table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
968  }
969  Ok(table)
970}
971
972/// Reverse of [`typed_values_to_lua`], returning the name and values separately so the caller can
973/// build either a `MatchingRule` or a `Generator` from them.
974fn lua_to_typed_values(
975  lua: &Lua,
976  table: Option<Table>,
977  what: &str
978) -> anyhow::Result<(String, Option<prost_types::Struct>)> {
979  let table = table.ok_or_else(|| anyhow!("Expected a '{}' table from Lua", what))?;
980  let r#type: String = table.get("type")
981    .map_err(|_| anyhow!("Expected the '{}' table from Lua to have a 'type'", what))?;
982  let values: Option<Value> = table.get("values")?;
983  let values = match values {
984    Some(Value::Nil) | None => None,
985    Some(value) => Some(to_proto_struct(&as_json_map(lua.from_value(value)?))),
986  };
987  Ok((r#type, values))
988}
989
990/// V2's `PluginConfiguration` carries the same two `Struct` fields as the V1 message the rest of
991/// this module converts, so the field-level requests reuse those conversions rather than
992/// duplicating them. The conversion is needed at all only because field-level operations exist
993/// solely on the V2 interface (proposal 006).
994fn v2_plugin_configuration_to_v1(
995  config: &Option<proto_v2::PluginConfiguration>
996) -> Option<PluginConfiguration> {
997  config.as_ref().map(|config| PluginConfiguration {
998    interaction_configuration: config.interaction_configuration.clone(),
999    pact_configuration: config.pact_configuration.clone(),
1000  })
1001}
1002
1003/// Reverse of [`v2_plugin_configuration_to_v1`].
1004fn v1_plugin_configuration_to_v2(
1005  config: Option<PluginConfiguration>
1006) -> Option<proto_v2::PluginConfiguration> {
1007  config.map(|config| proto_v2::PluginConfiguration {
1008    interaction_configuration: config.interaction_configuration,
1009    pact_configuration: config.pact_configuration,
1010  })
1011}
1012
1013/// See [`v2_plugin_configuration_to_v1`] - `ContentMismatch` is likewise identical between the two
1014/// interfaces, so the existing mismatch conversions are reused for the V2-only field messages.
1015fn v1_content_mismatch_to_v2(mismatch: ContentMismatch) -> proto_v2::ContentMismatch {
1016  proto_v2::ContentMismatch {
1017    expected: mismatch.expected,
1018    actual: mismatch.actual,
1019    mismatch: mismatch.mismatch,
1020    path: mismatch.path,
1021    diff: mismatch.diff,
1022    mismatch_type: mismatch.mismatch_type,
1023  }
1024}
1025
1026/// Reverse of [`v1_content_mismatch_to_v2`].
1027fn v2_content_mismatch_to_v1(mismatch: &proto_v2::ContentMismatch) -> ContentMismatch {
1028  ContentMismatch {
1029    expected: mismatch.expected.clone(),
1030    actual: mismatch.actual.clone(),
1031    mismatch: mismatch.mismatch.clone(),
1032    path: mismatch.path.clone(),
1033    diff: mismatch.diff.clone(),
1034    mismatch_type: mismatch.mismatch_type.clone(),
1035  }
1036}
1037
1038/// Builds the request table a plugin's own `match_field(request)` function receives.
1039fn match_field_request_to_lua(lua: &Lua, request: &proto_v2::MatchFieldRequest) -> mlua::Result<Table> {
1040  let table = lua.create_table()?;
1041  table.set("key", request.key.clone())?;
1042  let rule = request.rule.clone().unwrap_or_default();
1043  table.set("rule", typed_values_to_lua(lua, &rule.r#type, &rule.values)?)?;
1044  table.set("path", request.path.clone())?;
1045  table.set("mismatch_type", request.mismatch_type.clone())?;
1046  table.set("expected", field_value_to_lua(lua, &request.expected)?)?;
1047  table.set("actual", field_value_to_lua(lua, &request.actual)?)?;
1048  table.set(
1049    "plugin_configuration",
1050    plugin_configuration_to_lua(lua, &v2_plugin_configuration_to_v1(&request.plugin_configuration))?,
1051  )?;
1052  table.set("test_context", struct_to_lua(lua, &request.test_context)?)?;
1053  Ok(table)
1054}
1055
1056/// Reverse of [`match_field_request_to_lua`] - the table a script builds when calling
1057/// `host_match_field(entry_key, request)` is the same shape its own `match_field` receives, so it
1058/// can forward the request it was given after adjusting whatever it needs to.
1059fn lua_to_match_field_request(lua: &Lua, table: Table) -> anyhow::Result<proto_v2::MatchFieldRequest> {
1060  let (r#type, values) = lua_to_typed_values(lua, table.get("rule")?, "rule")?;
1061  let plugin_configuration: Option<Value> = table.get("plugin_configuration")?;
1062  let test_context: Option<Value> = table.get("test_context")?;
1063  Ok(proto_v2::MatchFieldRequest {
1064    key: table.get::<Option<String>>("key")?.unwrap_or_default(),
1065    rule: Some(proto_v2::MatchingRule { r#type, values }),
1066    path: table.get::<Option<String>>("path")?.unwrap_or_default(),
1067    mismatch_type: table.get::<Option<String>>("mismatch_type")?.unwrap_or_default(),
1068    expected: Some(lua_to_field_value(lua, table.get("expected")?)?),
1069    actual: Some(lua_to_field_value(lua, table.get("actual")?)?),
1070    plugin_configuration: v1_plugin_configuration_to_v2(
1071      lua_to_plugin_configuration(lua, plugin_configuration)?
1072    ),
1073    test_context: lua_to_struct(lua, test_context)?,
1074  })
1075}
1076
1077/// Parses the table a plugin's `match_field` function returns: `{ error = "..." }`, or
1078/// `{ mismatches = { ... } }` where each entry is a mismatch table or a bare description string
1079/// (the same leniency `match_contents` responses get - see [`lua_value_to_content_mismatches`]).
1080/// An absent or empty list means the value matched.
1081fn lua_to_match_field_response(table: Table, path: &str) -> anyhow::Result<proto_v2::MatchFieldResponse> {
1082  let error: Option<String> = table.get("error")?;
1083  if let Some(error) = error {
1084    return Ok(proto_v2::MatchFieldResponse { error, mismatches: vec![] });
1085  }
1086
1087  let mismatches: Option<Value> = table.get("mismatches")?;
1088  let mismatches = match mismatches {
1089    Some(value) => lua_value_to_content_mismatches(path, value)?
1090      .into_iter()
1091      .map(v1_content_mismatch_to_v2)
1092      .collect(),
1093    None => vec![],
1094  };
1095  Ok(proto_v2::MatchFieldResponse { error: String::new(), mismatches })
1096}
1097
1098/// Reverse of [`lua_to_match_field_response`], so a script can return the result of a
1099/// `host_match_field` call straight through as its own response.
1100fn match_field_response_to_lua(lua: &Lua, response: &proto_v2::MatchFieldResponse) -> mlua::Result<Table> {
1101  let table = lua.create_table()?;
1102  if !response.error.is_empty() {
1103    table.set("error", response.error.clone())?;
1104    return Ok(table);
1105  }
1106  let mismatches: Vec<ContentMismatch> = response.mismatches.iter()
1107    .map(v2_content_mismatch_to_v1)
1108    .collect();
1109  table.set("mismatches", content_mismatches_to_lua(lua, &mismatches)?)?;
1110  Ok(table)
1111}
1112
1113/// Builds the request table a plugin's own `generate_field(request)` function receives.
1114fn generate_field_request_to_lua(lua: &Lua, request: &proto_v2::GenerateFieldRequest) -> mlua::Result<Table> {
1115  let table = lua.create_table()?;
1116  table.set("key", request.key.clone())?;
1117  let generator = request.generator.clone().unwrap_or_default();
1118  table.set("generator", typed_values_to_lua(lua, &generator.r#type, &generator.values)?)?;
1119  table.set("path", request.path.clone())?;
1120  table.set("example_value", field_value_to_lua(lua, &request.example_value)?)?;
1121  table.set(
1122    "plugin_configuration",
1123    plugin_configuration_to_lua(lua, &v2_plugin_configuration_to_v1(&request.plugin_configuration))?,
1124  )?;
1125  table.set("test_context", struct_to_lua(lua, &request.test_context)?)?;
1126  table.set("test_mode", test_mode_to_str(request.test_mode))?;
1127  Ok(table)
1128}
1129
1130/// Reverse of [`generate_field_request_to_lua`] - the table a script passes to
1131/// `host_generate_field(entry_key, request)`.
1132fn lua_to_generate_field_request(lua: &Lua, table: Table) -> anyhow::Result<proto_v2::GenerateFieldRequest> {
1133  let (r#type, values) = lua_to_typed_values(lua, table.get("generator")?, "generator")?;
1134  let plugin_configuration: Option<Value> = table.get("plugin_configuration")?;
1135  let test_context: Option<Value> = table.get("test_context")?;
1136  let test_mode: Option<String> = table.get("test_mode")?;
1137  Ok(proto_v2::GenerateFieldRequest {
1138    key: table.get::<Option<String>>("key")?.unwrap_or_default(),
1139    generator: Some(proto_v2::Generator { r#type, values }),
1140    path: table.get::<Option<String>>("path")?.unwrap_or_default(),
1141    example_value: Some(lua_to_field_value(lua, table.get("example_value")?)?),
1142    plugin_configuration: v1_plugin_configuration_to_v2(
1143      lua_to_plugin_configuration(lua, plugin_configuration)?
1144    ),
1145    test_context: lua_to_struct(lua, test_context)?,
1146    test_mode: str_to_test_mode(test_mode.as_deref()),
1147  })
1148}
1149
1150/// Parses the table a plugin's `generate_field` function returns: `{ value = ... }` or
1151/// `{ error = "..." }`.
1152fn lua_to_generate_field_response(lua: &Lua, table: Table) -> anyhow::Result<proto_v2::GenerateFieldResponse> {
1153  let error: Option<String> = table.get("error")?;
1154  if let Some(error) = error {
1155    return Ok(proto_v2::GenerateFieldResponse { error, value: None });
1156  }
1157  Ok(proto_v2::GenerateFieldResponse {
1158    error: String::new(),
1159    value: Some(lua_to_field_value(lua, table.get("value")?)?),
1160  })
1161}
1162
1163/// Reverse of [`lua_to_generate_field_response`], so a script can return the result of a
1164/// `host_generate_field` call straight through as its own response.
1165fn generate_field_response_to_lua(lua: &Lua, response: &proto_v2::GenerateFieldResponse) -> mlua::Result<Table> {
1166  let table = lua.create_table()?;
1167  if !response.error.is_empty() {
1168    table.set("error", response.error.clone())?;
1169  } else {
1170    table.set("value", field_value_to_lua(lua, &response.value)?)?;
1171  }
1172  Ok(table)
1173}
1174
1175/// Converts a plain Lua value back into a `google.protobuf.Struct`, the reverse of
1176/// [`struct_to_lua`]. A `nil` (or a non-map value, which a `Struct` cannot represent) becomes no
1177/// struct at all rather than an error.
1178fn lua_to_struct(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<prost_types::Struct>> {
1179  match value {
1180    None | Some(Value::Nil) => Ok(None),
1181    Some(value) => Ok(Some(to_proto_struct(&as_json_map(lua.from_value(value)?)))),
1182  }
1183}
1184
1185// ---- ConfigureInteraction <-> Lua ----
1186
1187/// Converts a single Lua interaction-contents table (shaped as
1188/// `{ contents = <body>, part_name = "...", plugin_config = <table> }`) into an
1189/// `InteractionResponse`.
1190fn lua_to_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<InteractionResponse> {
1191  let contents: Option<Value> = table.get("contents")?;
1192  let body = match contents {
1193    Some(value) => lua_to_body(value)?,
1194    None => None,
1195  };
1196  let plugin_config: Option<Value> = table.get("plugin_config")?;
1197  let part_name: Option<String> = table.get("part_name")?;
1198  Ok(InteractionResponse {
1199    contents: body,
1200    rules: HashMap::new(),
1201    generators: HashMap::new(),
1202    message_metadata: None,
1203    plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
1204    interaction_markup: String::new(),
1205    interaction_markup_type: 0,
1206    part_name: part_name.unwrap_or_default(),
1207    metadata_rules: HashMap::new(),
1208    metadata_generators: HashMap::new(),
1209  })
1210}
1211
1212/// Converts the table returned by the Lua `configure_interaction` function, shaped as
1213/// `{ interactions = { { contents = <body>, part_name = "..." }, ... }, plugin_config = <table> }`,
1214/// into a `ConfigureInteractionResponse`. `interactions` is always a sequence, even when there
1215/// is only one interaction (as is the case for a plain body content-matcher like JWT).
1216fn lua_to_configure_response(lua: &Lua, table: Table) -> anyhow::Result<ConfigureInteractionResponse> {
1217  let mut interactions = vec![];
1218  let items: Option<Table> = table.get("interactions")?;
1219  if let Some(items) = items {
1220    for entry in items.sequence_values::<Table>() {
1221      interactions.push(lua_to_interaction_response(lua, entry?)?);
1222    }
1223  }
1224
1225  let plugin_config: Option<Value> = table.get("plugin_config")?;
1226  Ok(ConfigureInteractionResponse {
1227    error: String::new(),
1228    interaction: interactions,
1229    plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
1230  })
1231}
1232
1233// ---- TRANSPORT plugin support: mock server / verification <-> Lua ----
1234
1235/// Converts a `google.protobuf.Struct` to a plain Lua value, `nil` if not set.
1236fn struct_to_lua(lua: &Lua, value: &Option<prost_types::Struct>) -> mlua::Result<Value> {
1237  match value {
1238    Some(value) => lua.to_value(&proto_struct_to_json(value)),
1239    None => Ok(Value::Nil),
1240  }
1241}
1242
1243/// Converts V2 `InteractionContents` (structured per-interaction data sent in place of a whole
1244/// Pact JSON document) into a Lua table shaped as
1245/// `{ interaction_type, consumer, provider, plugin_configuration = { interaction_configuration, pact_configuration } }`.
1246fn interaction_contents_to_lua(lua: &Lua, contents: &proto_v2::InteractionContents) -> mlua::Result<Table> {
1247  let table = lua.create_table()?;
1248  table.set("interaction_type", contents.interaction_type.clone())?;
1249  table.set("consumer", contents.consumer.clone())?;
1250  table.set("provider", contents.provider.clone())?;
1251  if let Some(plugin_configuration) = &contents.plugin_configuration {
1252    let config_table = lua.create_table()?;
1253    if let Some(interaction_configuration) = &plugin_configuration.interaction_configuration {
1254      config_table.set("interaction_configuration", lua.to_value(&proto_struct_to_json(interaction_configuration))?)?;
1255    }
1256    if let Some(pact_configuration) = &plugin_configuration.pact_configuration {
1257      config_table.set("pact_configuration", lua.to_value(&proto_struct_to_json(pact_configuration))?)?;
1258    }
1259    table.set("plugin_configuration", config_table)?;
1260  }
1261  Ok(table)
1262}
1263
1264/// V1 `InteractionData` and V2 `InteractionData` are structurally identical (same wire format);
1265/// converting via an encode/decode round trip lets the rest of this module deal with a single
1266/// (V1) type, matching the approach `plugin_manager.rs` uses in the other direction (see
1267/// `to_proto_v2_interaction_data`). Returns an error rather than panicking if the round trip
1268/// ever fails (it shouldn't, given the identical wire format, but this data originates from a
1269/// caller-supplied gRPC request, so a decode failure should be a recoverable error, not a
1270/// crash).
1271fn v2_interaction_data_to_v1(data: &proto_v2::InteractionData) -> anyhow::Result<InteractionData> {
1272  use prost::Message;
1273  InteractionData::decode(data.encode_to_vec().as_slice())
1274    .map_err(|err| anyhow!("Failed to convert V2 InteractionData to V1 - {}", err))
1275}
1276
1277/// Converts request/response metadata to a Lua table. Each value is either a plain Lua value
1278/// (JSON-like, for a non-binary `MetadataValue`) or a `{ binary = <lua string> }` wrapper table
1279/// (for a binary `MetadataValue`), so a Lua script can tell the two apart.
1280fn metadata_to_lua(lua: &Lua, metadata: &HashMap<String, MetadataValue>) -> mlua::Result<Table> {
1281  let table = lua.create_table()?;
1282  for (key, value) in metadata {
1283    let lua_value = match &value.value {
1284      Some(metadata_value::Value::NonBinaryValue(value)) => lua.to_value(&proto_value_to_json(value))?,
1285      Some(metadata_value::Value::BinaryValue(bytes)) => {
1286        let wrapper = lua.create_table()?;
1287        wrapper.set("binary", lua.create_string(bytes)?)?;
1288        Value::Table(wrapper)
1289      }
1290      None => Value::Nil,
1291    };
1292    table.set(key.clone(), lua_value)?;
1293  }
1294  Ok(table)
1295}
1296
1297/// Converts a Lua metadata table (see [`metadata_to_lua`]) back into `MetadataValue`s.
1298fn lua_to_metadata(lua: &Lua, table: Option<Table>) -> anyhow::Result<HashMap<String, MetadataValue>> {
1299  let mut metadata = HashMap::new();
1300  if let Some(table) = table {
1301    for pair in table.pairs::<String, Value>() {
1302      let (key, value) = pair?;
1303      let binary: Option<mlua::LuaString> = match &value {
1304        Value::Table(wrapper) => wrapper.get("binary")?,
1305        _ => None,
1306      };
1307      let metadata_value = if let Some(binary) = binary {
1308        metadata_value::Value::BinaryValue(binary.as_bytes().to_vec())
1309      } else {
1310        let json: serde_json::Value = lua.from_value(value)?;
1311        metadata_value::Value::NonBinaryValue(to_proto_value(&json))
1312      };
1313      metadata.insert(key, MetadataValue { value: Some(metadata_value) });
1314    }
1315  }
1316  Ok(metadata)
1317}
1318
1319/// Converts `InteractionData` (a request/response body plus metadata) to a Lua table shaped as
1320/// `{ body = <body table>, metadata = <metadata table> }`, or `nil` if not set.
1321fn interaction_data_to_lua(lua: &Lua, data: &Option<InteractionData>) -> mlua::Result<Value> {
1322  match data {
1323    None => Ok(Value::Nil),
1324    Some(data) => {
1325      let table = lua.create_table()?;
1326      table.set("body", body_to_lua(lua, &data.body)?)?;
1327      table.set("metadata", metadata_to_lua(lua, &data.metadata)?)?;
1328      Ok(Value::Table(table))
1329    }
1330  }
1331}
1332
1333/// Converts a Lua interaction-data table (see [`interaction_data_to_lua`]) back into
1334/// `InteractionData`, or `None` if the Lua value was `nil`.
1335fn lua_to_interaction_data(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<InteractionData>> {
1336  match value {
1337    None | Some(Value::Nil) => Ok(None),
1338    Some(Value::Table(table)) => {
1339      let body: Option<Value> = table.get("body")?;
1340      let body = match body {
1341        Some(value) => lua_to_body(value)?,
1342        None => None,
1343      };
1344      let metadata_table: Option<Table> = table.get("metadata")?;
1345      Ok(Some(InteractionData {
1346        body,
1347        metadata: lua_to_metadata(lua, metadata_table)?,
1348      }))
1349    }
1350    Some(other) => Err(anyhow!("Expected an interaction data table or nil from Lua, got {}", other.type_name())),
1351  }
1352}
1353
1354/// Converts the table returned by the Lua `start_mock_server` function, shaped as either
1355/// `{ error = "..." }` or `{ details = { key, port, address } }`, into a `StartMockServerResponse`.
1356fn lua_to_start_mock_server_response(table: Table) -> anyhow::Result<StartMockServerResponse> {
1357  let error: Option<String> = table.get("error")?;
1358  if let Some(error) = error {
1359    return Ok(StartMockServerResponse {
1360      response: Some(start_mock_server_response::Response::Error(error)),
1361    });
1362  }
1363
1364  let details: Option<Table> = table.get("details")?;
1365  let details = details.ok_or_else(|| {
1366    anyhow!("Lua start_mock_server() must return either an 'error' or 'details' field")
1367  })?;
1368  Ok(StartMockServerResponse {
1369    response: Some(start_mock_server_response::Response::Details(MockServerDetails {
1370      key: details.get("key")?,
1371      port: details.get("port")?,
1372      address: details.get("address")?,
1373    })),
1374  })
1375}
1376
1377/// Converts the table returned by the Lua `shutdown_mock_server`/`get_mock_server_results`
1378/// functions, shaped as `{ ok = bool, results = { { path, error, mismatches = { ... } }, ... } }`,
1379/// into `MockServerResults`. Reuses [`lua_value_to_content_mismatches`] for each result's
1380/// `mismatches` field, the same helper `match_contents` responses use.
1381fn lua_to_mock_server_results(table: Table) -> anyhow::Result<MockServerResults> {
1382  let ok: bool = table.get::<Option<bool>>("ok")?.unwrap_or(true);
1383  let mut results = vec![];
1384  let results_table: Option<Table> = table.get("results")?;
1385  if let Some(results_table) = results_table {
1386    for entry in results_table.sequence_values::<Table>() {
1387      let entry = entry?;
1388      let path: String = entry.get::<Option<String>>("path")?.unwrap_or_default();
1389      let error: String = entry.get::<Option<String>>("error")?.unwrap_or_default();
1390      let mismatches_value: Value = entry.get("mismatches")?;
1391      results.push(MockServerResult {
1392        path: path.clone(),
1393        error,
1394        mismatches: lua_value_to_content_mismatches(&path, mismatches_value)?,
1395      });
1396    }
1397  }
1398  Ok(MockServerResults { ok, results })
1399}
1400
1401/// Converts the table returned by the Lua `prepare_interaction_for_verification` function,
1402/// shaped as either `{ error = "..." }` or `{ interaction_data = { body, metadata } }`, into a
1403/// `VerificationPreparationResponse`.
1404fn lua_to_verification_preparation_response(
1405  lua: &Lua,
1406  table: Table,
1407) -> anyhow::Result<VerificationPreparationResponse> {
1408  let error: Option<String> = table.get("error")?;
1409  if let Some(error) = error {
1410    return Ok(VerificationPreparationResponse {
1411      response: Some(verification_preparation_response::Response::Error(error)),
1412    });
1413  }
1414
1415  let data: Option<Value> = table.get("interaction_data")?;
1416  let data = data.ok_or_else(|| {
1417    anyhow!("Lua prepare_interaction_for_verification() must return either an 'error' or 'interaction_data' field")
1418  })?;
1419  let interaction_data = lua_to_interaction_data(lua, Some(data))?
1420    .unwrap_or_else(|| InteractionData { body: None, metadata: HashMap::new() });
1421  Ok(VerificationPreparationResponse {
1422    response: Some(verification_preparation_response::Response::InteractionData(interaction_data)),
1423  })
1424}
1425
1426/// Converts a single Lua verification mismatch (a plain error string, or a mismatch table shaped
1427/// like a `match_contents` mismatch) into a `VerificationResultItem`.
1428fn lua_to_verification_result_item(value: Value) -> anyhow::Result<VerificationResultItem> {
1429  match value {
1430    Value::String(s) => Ok(VerificationResultItem {
1431      result: Some(verification_result_item::Result::Error(s.to_str()?.to_string())),
1432    }),
1433    Value::Table(table) => {
1434      let mismatch: Option<String> = table.get("mismatch")?;
1435      let path: Option<String> = table.get("path")?;
1436      let expected = lua_scalar_to_string(table.get("expected")?)?;
1437      let actual = lua_scalar_to_string(table.get("actual")?)?;
1438      let diff: Option<String> = table.get("diff")?;
1439      let mismatch_type: Option<String> = table.get("mismatch_type")?;
1440      Ok(VerificationResultItem {
1441        result: Some(verification_result_item::Result::Mismatch(ContentMismatch {
1442          expected: expected.map(|s| s.into_bytes()),
1443          actual: actual.map(|s| s.into_bytes()),
1444          mismatch: mismatch.unwrap_or_default(),
1445          path: path.unwrap_or_default(),
1446          diff: diff.unwrap_or_default(),
1447          mismatch_type: mismatch_type.unwrap_or_default(),
1448        })),
1449      })
1450    }
1451    other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
1452  }
1453}
1454
1455/// Converts the table returned by the Lua `verify_interaction` function, shaped as either
1456/// `{ error = "..." }` or
1457/// `{ result = { success, response_data, mismatches = { ... }, output = { ... } } }`, into a
1458/// `VerifyInteractionResponse`.
1459fn lua_to_verify_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<VerifyInteractionResponse> {
1460  let error: Option<String> = table.get("error")?;
1461  if let Some(error) = error {
1462    return Ok(VerifyInteractionResponse {
1463      response: Some(verify_interaction_response::Response::Error(error)),
1464    });
1465  }
1466
1467  let result_table: Option<Table> = table.get("result")?;
1468  let result_table = result_table
1469    .ok_or_else(|| anyhow!("Lua verify_interaction() must return either an 'error' or 'result' field"))?;
1470
1471  let success: bool = result_table.get::<Option<bool>>("success")?.unwrap_or(false);
1472  let response_data: Option<Value> = result_table.get("response_data")?;
1473  let response_data = lua_to_interaction_data(lua, response_data)?;
1474
1475  let mut mismatches = vec![];
1476  let mismatches_value: Option<Value> = result_table.get("mismatches")?;
1477  if let Some(Value::Table(mismatches_table)) = mismatches_value {
1478    for entry in mismatches_table.sequence_values::<Value>() {
1479      mismatches.push(lua_to_verification_result_item(entry?)?);
1480    }
1481  }
1482
1483  let output: Option<Vec<String>> = result_table.get("output")?;
1484
1485  Ok(VerifyInteractionResponse {
1486    response: Some(verify_interaction_response::Response::Result(VerificationResult {
1487      success,
1488      response_data,
1489      mismatches,
1490      output: output.unwrap_or_default(),
1491    })),
1492  })
1493}
1494
1495#[async_trait]
1496impl PactPluginRpc for LuaPactPlugin {
1497  async fn init_plugin(&mut self, request: PluginInitRequest) -> anyhow::Result<PluginInitResponse> {
1498    let lua = self.runtime.lock().await;
1499    let catalogue = call_init(&lua, &request.implementation, &request.version)?;
1500    Ok(PluginInitResponse {
1501      catalogue,
1502      plugin_capabilities: vec![],
1503    })
1504  }
1505}
1506
1507#[async_trait]
1508impl PluginInstance for LuaPactPlugin {
1509  fn manifest(&self) -> &PactPluginManifest {
1510    &self.manifest
1511  }
1512
1513  fn instance_id(&self) -> &str {
1514    &self.instance_id
1515  }
1516
1517  fn has_capability(&self, capability: &str) -> bool {
1518    self.plugin_capabilities.iter().any(|c| c == capability)
1519  }
1520
1521  async fn compare_contents(
1522    &self,
1523    request: CompareContentsRequest,
1524  ) -> anyhow::Result<CompareContentsResponse> {
1525    let lua = self.runtime.lock().await;
1526    let match_fn: Function = lua
1527      .globals()
1528      .get("match_contents")
1529      .map_err(|_| anyhow!("Lua plugin does not define a global 'match_contents' function"))?;
1530    let request_table = compare_request_to_lua(&lua, &request)?;
1531    let result: Table = match_fn
1532      .call_async(request_table)
1533      .await
1534      .map_err(|err| anyhow!("Lua match_contents() function failed - {}", err))?;
1535    lua_to_compare_response(result)
1536  }
1537
1538  async fn configure_interaction(
1539    &self,
1540    request: ConfigureInteractionRequest,
1541  ) -> anyhow::Result<ConfigureInteractionResponse> {
1542    let lua = self.runtime.lock().await;
1543    let configure_fn: Function = lua
1544      .globals()
1545      .get("configure_interaction")
1546      .map_err(|_| anyhow!("Lua plugin does not define a global 'configure_interaction' function"))?;
1547    let config: Value = match &request.contents_config {
1548      Some(config) => lua.to_value(&proto_struct_to_json(config))?,
1549      None => Value::Nil,
1550    };
1551    let result: Table = configure_fn
1552      .call((request.content_type.clone(), config))
1553      .map_err(|err| anyhow!("Lua configure_interaction() function failed - {}", err))?;
1554    lua_to_configure_response(&lua, result)
1555  }
1556
1557  async fn generate_content(
1558    &self,
1559    request: GenerateContentRequest,
1560  ) -> anyhow::Result<GenerateContentResponse> {
1561    let lua = self.runtime.lock().await;
1562    let generate_fn: Option<Function> = lua.globals().get("generate_content")?;
1563    match generate_fn {
1564      None => Ok(GenerateContentResponse {
1565        contents: request.contents,
1566      }),
1567      Some(generate_fn) => {
1568        let contents = body_to_lua(&lua, &request.contents)?;
1569        let generators = lua.create_table()?;
1570        for (path, generator) in &request.generators {
1571          let generator_table = lua.create_table()?;
1572          generator_table.set("type", generator.r#type.clone())?;
1573          if let Some(values) = &generator.values {
1574            generator_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
1575          }
1576          generators.set(path.clone(), generator_table)?;
1577        }
1578        let test_mode = test_mode_to_str(request.test_mode);
1579        let result: Value = generate_fn
1580          .call_async((contents, generators, test_mode))
1581          .await
1582          .map_err(|err| anyhow!("Lua generate_content() function failed - {}", err))?;
1583        Ok(GenerateContentResponse {
1584          contents: lua_to_body(result)?,
1585        })
1586      }
1587    }
1588  }
1589
1590  /// `match_field` and `generate_field` are optional globals - a plugin only defines them if it
1591  /// registered a `MATCHER` or `GENERATOR` catalogue entry. Reaching here without one is a real
1592  /// error (the driver resolved an entry the plugin registered), so it is reported rather than
1593  /// silently treated as a match.
1594  ///
1595  /// Called via `call_async` for the same reason as `match_contents`: the script may reach a host
1596  /// function, and mlua only allows an async host function to be called from a chain started with
1597  /// `call_async`.
1598  async fn match_field(
1599    &self,
1600    request: proto_v2::MatchFieldRequest,
1601  ) -> anyhow::Result<proto_v2::MatchFieldResponse> {
1602    let lua = self.runtime.lock().await;
1603    let match_fn: Function = lua
1604      .globals()
1605      .get("match_field")
1606      .map_err(|_| anyhow!("Lua plugin does not define a global 'match_field' function"))?;
1607    let path = request.path.clone();
1608    let request_table = match_field_request_to_lua(&lua, &request)?;
1609    let result: Table = match_fn
1610      .call_async(request_table)
1611      .await
1612      .map_err(|err| anyhow!("Lua match_field() function failed - {}", err))?;
1613    lua_to_match_field_response(result, &path)
1614  }
1615
1616  /// See [`LuaPactPlugin::match_field`].
1617  async fn generate_field(
1618    &self,
1619    request: proto_v2::GenerateFieldRequest,
1620  ) -> anyhow::Result<proto_v2::GenerateFieldResponse> {
1621    let lua = self.runtime.lock().await;
1622    let generate_fn: Function = lua
1623      .globals()
1624      .get("generate_field")
1625      .map_err(|_| anyhow!("Lua plugin does not define a global 'generate_field' function"))?;
1626    let request_table = generate_field_request_to_lua(&lua, &request)?;
1627    let result: Table = generate_fn
1628      .call_async(request_table)
1629      .await
1630      .map_err(|err| anyhow!("Lua generate_field() function failed - {}", err))?;
1631    lua_to_generate_field_response(&lua, result)
1632  }
1633
1634  async fn start_mock_server(
1635    &self,
1636    request: StartMockServerRequest,
1637  ) -> anyhow::Result<StartMockServerResponse> {
1638    let lua = self.runtime.lock().await;
1639    let start_fn: Function = lua
1640      .globals()
1641      .get("start_mock_server")
1642      .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1643    let request_table = lua.create_table()?;
1644    request_table.set("host_interface", request.host_interface)?;
1645    request_table.set("port", request.port)?;
1646    request_table.set("tls", request.tls)?;
1647    request_table.set("pact", request.pact)?;
1648    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1649    let result: Table = start_fn
1650      .call(request_table)
1651      .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1652    lua_to_start_mock_server_response(result)
1653  }
1654
1655  async fn start_mock_server_v2(
1656    &self,
1657    request: proto_v2::StartMockServerRequest,
1658  ) -> anyhow::Result<StartMockServerResponse> {
1659    let lua = self.runtime.lock().await;
1660    let start_fn: Function = lua
1661      .globals()
1662      .get("start_mock_server")
1663      .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1664    let request_table = lua.create_table()?;
1665    request_table.set("host_interface", request.host_interface)?;
1666    request_table.set("port", request.port)?;
1667    request_table.set("tls", request.tls)?;
1668    let interactions_table = lua.create_table()?;
1669    for interaction in &request.interactions {
1670      interactions_table.push(interaction_contents_to_lua(&lua, interaction)?)?;
1671    }
1672    request_table.set("interactions", interactions_table)?;
1673    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1674    let result: Table = start_fn
1675      .call(request_table)
1676      .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1677    lua_to_start_mock_server_response(result)
1678  }
1679
1680  async fn shutdown_mock_server(
1681    &self,
1682    request: ShutdownMockServerRequest,
1683  ) -> anyhow::Result<ShutdownMockServerResponse> {
1684    let lua = self.runtime.lock().await;
1685    let shutdown_fn: Function = lua
1686      .globals()
1687      .get("shutdown_mock_server")
1688      .map_err(|_| anyhow!("Lua plugin does not define a global 'shutdown_mock_server' function"))?;
1689    let result: Table = shutdown_fn
1690      .call(request.server_key)
1691      .map_err(|err| anyhow!("Lua shutdown_mock_server() function failed - {}", err))?;
1692    let results = lua_to_mock_server_results(result)?;
1693    Ok(ShutdownMockServerResponse {
1694      ok: results.ok,
1695      results: results.results,
1696    })
1697  }
1698
1699  async fn get_mock_server_results(
1700    &self,
1701    request: MockServerRequest,
1702  ) -> anyhow::Result<MockServerResults> {
1703    let lua = self.runtime.lock().await;
1704    let results_fn: Function = lua
1705      .globals()
1706      .get("get_mock_server_results")
1707      .map_err(|_| anyhow!("Lua plugin does not define a global 'get_mock_server_results' function"))?;
1708    let result: Table = results_fn
1709      .call(request.server_key)
1710      .map_err(|err| anyhow!("Lua get_mock_server_results() function failed - {}", err))?;
1711    lua_to_mock_server_results(result)
1712  }
1713
1714  async fn prepare_interaction_for_verification(
1715    &self,
1716    request: VerificationPreparationRequest,
1717  ) -> anyhow::Result<VerificationPreparationResponse> {
1718    let lua = self.runtime.lock().await;
1719    let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1720      anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1721    })?;
1722    let request_table = lua.create_table()?;
1723    request_table.set("pact", request.pact)?;
1724    request_table.set("interaction_key", request.interaction_key)?;
1725    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1726    let result: Table = prepare_fn
1727      .call(request_table)
1728      .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1729    lua_to_verification_preparation_response(&lua, result)
1730  }
1731
1732  async fn prepare_interaction_for_verification_v2(
1733    &self,
1734    request: proto_v2::VerificationPreparationRequest,
1735  ) -> anyhow::Result<VerificationPreparationResponse> {
1736    let lua = self.runtime.lock().await;
1737    let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1738      anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1739    })?;
1740    let request_table = lua.create_table()?;
1741    if let Some(interaction_contents) = &request.interaction_contents {
1742      request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1743    }
1744    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1745    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1746    let result: Table = prepare_fn
1747      .call(request_table)
1748      .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1749    lua_to_verification_preparation_response(&lua, result)
1750  }
1751
1752  async fn verify_interaction(
1753    &self,
1754    request: VerifyInteractionRequest,
1755  ) -> anyhow::Result<VerifyInteractionResponse> {
1756    let lua = self.runtime.lock().await;
1757    let verify_fn: Function = lua
1758      .globals()
1759      .get("verify_interaction")
1760      .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1761    let request_table = lua.create_table()?;
1762    request_table.set("interaction_data", interaction_data_to_lua(&lua, &request.interaction_data)?)?;
1763    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1764    request_table.set("pact", request.pact)?;
1765    request_table.set("interaction_key", request.interaction_key)?;
1766    let result: Table = verify_fn
1767      .call(request_table)
1768      .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1769    lua_to_verify_interaction_response(&lua, result)
1770  }
1771
1772  async fn verify_interaction_v2(
1773    &self,
1774    request: proto_v2::VerifyInteractionRequest,
1775  ) -> anyhow::Result<VerifyInteractionResponse> {
1776    let lua = self.runtime.lock().await;
1777    let verify_fn: Function = lua
1778      .globals()
1779      .get("verify_interaction")
1780      .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1781    let request_table = lua.create_table()?;
1782    let interaction_data = request.interaction_data.as_ref()
1783      .map(v2_interaction_data_to_v1)
1784      .transpose()?;
1785    request_table.set("interaction_data", interaction_data_to_lua(&lua, &interaction_data)?)?;
1786    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1787    if let Some(interaction_contents) = &request.interaction_contents {
1788      request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1789    }
1790    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1791    let result: Table = verify_fn
1792      .call(request_table)
1793      .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1794    lua_to_verify_interaction_response(&lua, result)
1795  }
1796
1797  async fn update_catalogue(&self, request: Catalogue) -> anyhow::Result<()> {
1798    let lua = self.runtime.lock().await;
1799    let update_fn: Option<Function> = lua.globals().get("update_catalogue")?;
1800    if let Some(update_fn) = update_fn {
1801      let table = lua.create_table()?;
1802      for entry in &request.catalogue {
1803        let entry_table = lua.create_table()?;
1804        // An entry type this driver doesn't understand is skipped rather than passed to the
1805        // script as some other type it isn't - see `register_plugin_entries`.
1806        let Some(entry_type) = CatalogueEntryType::from_proto_value(entry.r#type) else {
1807          warn!("Not passing catalogue entry '{}' to the plugin: {} is not a catalogue entry type this driver understands",
1808            entry.key, entry.r#type);
1809          continue;
1810        };
1811        entry_table.set("entryType", entry_type.as_proto_name())?;
1812        entry_table.set("key", entry.key.clone())?;
1813        entry_table.set("values", entry.values.clone())?;
1814        table.push(entry_table)?;
1815      }
1816      update_fn
1817        .call::<()>(table)
1818        .map_err(|err| anyhow!("Lua update_catalogue() function failed - {}", err))?;
1819    }
1820    Ok(())
1821  }
1822}
1823
1824#[cfg(test)]
1825mod tests {
1826  use std::collections::HashMap;
1827
1828  use super::*;
1829
1830  fn jwt_manifest() -> PactPluginManifest {
1831    // Deliberately not `.canonicalize()`d: on Windows that returns a `\\?\`-prefixed verbatim
1832    // path, and the forward slashes `set_package_path`/`add_luarocks_path` append to build
1833    // Lua's `package.path` aren't auto-translated to `\` under that prefix (unlike a normal
1834    // path), breaking `require` for every sibling .lua file. A plain absolute path (with
1835    // unresolved `..` components) resolves fine without it.
1836    let plugin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../plugins/jwt");
1837    assert!(plugin_dir.exists(), "plugins/jwt directory should exist at {:?}", plugin_dir);
1838    PactPluginManifest {
1839      plugin_dir: plugin_dir.to_string_lossy().to_string(),
1840      plugin_interface_version: 1,
1841      name: "jwt".to_string(),
1842      version: "0.0.0".to_string(),
1843      executable_type: "lua".to_string(),
1844      minimum_required_version: None,
1845      entry_point: "plugin.lua".to_string(),
1846      entry_points: HashMap::new(),
1847      args: None,
1848      dependencies: None,
1849      plugin_config: HashMap::new(),
1850    }
1851  }
1852
1853  const PRIVATE_KEY: &str = include_str!("../tests/fixtures/jwt-test-key.pem");
1854
1855  #[test]
1856  fn loads_pure_lua_packages_from_a_configured_luarocks_directory() {
1857    let rocks_root = tempdir::TempDir::new("luarocks-test").unwrap();
1858    let lua_dir = rocks_root.path().join("share").join("lua").join(LUAROCKS_LUA_VERSION);
1859    std::fs::create_dir_all(&lua_dir).unwrap();
1860    std::fs::write(
1861      lua_dir.join("greeter.lua"),
1862      r#"return { hello = function() return "hello from luarocks" end }"#,
1863    ).unwrap();
1864
1865    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1866    std::fs::write(
1867      plugin_dir.path().join("entry.lua"),
1868      r#"
1869        local greeter = require "greeter"
1870        GREETER_RESULT = greeter.hello()
1871      "#,
1872    ).unwrap();
1873
1874    let mut plugin_config = HashMap::new();
1875    plugin_config.insert(
1876      "luaRocksDir".to_string(),
1877      serde_json::Value::String(rocks_root.path().to_string_lossy().to_string()),
1878    );
1879
1880    let manifest = PactPluginManifest {
1881      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1882      plugin_interface_version: 1,
1883      name: "luarocks-test".to_string(),
1884      version: "0.0.0".to_string(),
1885      executable_type: "lua".to_string(),
1886      minimum_required_version: None,
1887      entry_point: "entry.lua".to_string(),
1888      entry_points: HashMap::new(),
1889      args: None,
1890      dependencies: None,
1891      plugin_config,
1892    };
1893
1894    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1895    let lua = plugin.runtime.blocking_lock();
1896    let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1897    assert_eq!(result, "hello from luarocks");
1898  }
1899
1900  #[test]
1901  fn loads_a_vendored_directory_style_module_from_the_plugin_directory() {
1902    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1903    let module_dir = plugin_dir.path().join("greeter");
1904    std::fs::create_dir_all(&module_dir).unwrap();
1905    std::fs::write(
1906      module_dir.join("init.lua"),
1907      r#"return { hello = function() return "hello from a vendored module" end }"#,
1908    ).unwrap();
1909    std::fs::write(
1910      plugin_dir.path().join("entry.lua"),
1911      r#"
1912        local greeter = require "greeter"
1913        GREETER_RESULT = greeter.hello()
1914      "#,
1915    ).unwrap();
1916
1917    let manifest = PactPluginManifest {
1918      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1919      plugin_interface_version: 1,
1920      name: "vendored-module-test".to_string(),
1921      version: "0.0.0".to_string(),
1922      executable_type: "lua".to_string(),
1923      minimum_required_version: None,
1924      entry_point: "entry.lua".to_string(),
1925      entry_points: HashMap::new(),
1926      args: None,
1927      dependencies: None,
1928      plugin_config: HashMap::new(),
1929    };
1930
1931    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1932    let lua = plugin.runtime.blocking_lock();
1933    let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1934    assert_eq!(result, "hello from a vendored module");
1935  }
1936
1937  #[test]
1938  fn loads_a_vendored_module_when_the_entry_point_is_in_a_subdirectory() {
1939    // package.path must be rooted at the plugin directory, not the entry point script's own
1940    // directory, so a vendored module sitting next to a nested entry point still resolves -
1941    // matching the JVM driver, which always uses `manifest.pluginDir`.
1942    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1943    std::fs::write(
1944      plugin_dir.path().join("greeter.lua"),
1945      r#"return { hello = function() return "hello from the plugin root" end }"#,
1946    ).unwrap();
1947    let src_dir = plugin_dir.path().join("src");
1948    std::fs::create_dir_all(&src_dir).unwrap();
1949    std::fs::write(
1950      src_dir.join("entry.lua"),
1951      r#"
1952        local greeter = require "greeter"
1953        GREETER_RESULT = greeter.hello()
1954      "#,
1955    ).unwrap();
1956
1957    let manifest = PactPluginManifest {
1958      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1959      plugin_interface_version: 1,
1960      name: "nested-entry-point-test".to_string(),
1961      version: "0.0.0".to_string(),
1962      executable_type: "lua".to_string(),
1963      minimum_required_version: None,
1964      entry_point: "src/entry.lua".to_string(),
1965      entry_points: HashMap::new(),
1966      args: None,
1967      dependencies: None,
1968      plugin_config: HashMap::new(),
1969    };
1970
1971    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1972    let lua = plugin.runtime.blocking_lock();
1973    let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1974    assert_eq!(result, "hello from the plugin root");
1975  }
1976
1977  #[test]
1978  fn ignores_a_missing_luarocks_directory_instead_of_failing() {
1979    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1980    std::fs::write(plugin_dir.path().join("entry.lua"), "-- no-op").unwrap();
1981
1982    let mut plugin_config = HashMap::new();
1983    plugin_config.insert(
1984      "luaRocksDir".to_string(),
1985      serde_json::Value::String("/no/such/directory".to_string()),
1986    );
1987
1988    let manifest = PactPluginManifest {
1989      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1990      plugin_interface_version: 1,
1991      name: "luarocks-test".to_string(),
1992      version: "0.0.0".to_string(),
1993      executable_type: "lua".to_string(),
1994      minimum_required_version: None,
1995      entry_point: "entry.lua".to_string(),
1996      entry_points: HashMap::new(),
1997      args: None,
1998      dependencies: None,
1999      plugin_config,
2000    };
2001
2002    start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2003  }
2004
2005  #[test]
2006  fn captures_print_and_logger_output_into_the_per_instance_log_file() {
2007    let output_dir = tempdir::TempDir::new("lua-plugin-log-test").unwrap();
2008    // SAFETY: no other test reads/writes PACT_OUTPUT_DIR; matches existing test conventions
2009    // in this crate for env-var-configured global state (see plugin_manager.rs tests).
2010    unsafe { std::env::set_var("PACT_OUTPUT_DIR", output_dir.path()); }
2011
2012    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2013    std::fs::write(
2014      plugin_dir.path().join("entry.lua"),
2015      r#"
2016        print("hello", "world", 42)
2017        logger("a logger message")
2018      "#,
2019    ).unwrap();
2020
2021    let manifest = PactPluginManifest {
2022      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
2023      plugin_interface_version: 1,
2024      name: "log-test".to_string(),
2025      version: "0.0.0".to_string(),
2026      executable_type: "lua".to_string(),
2027      minimum_required_version: None,
2028      entry_point: "entry.lua".to_string(),
2029      entry_points: HashMap::new(),
2030      args: None,
2031      dependencies: None,
2032      plugin_config: HashMap::new(),
2033    };
2034
2035    start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2036    unsafe { std::env::remove_var("PACT_OUTPUT_DIR"); }
2037
2038    let log_path = output_dir.path().join("logs").join("pact-plugin-log-test-test-instance.log");
2039    let contents = std::fs::read_to_string(&log_path)
2040      .unwrap_or_else(|err| panic!("Expected a log file at {:?} - {}", log_path, err));
2041    assert_eq!(contents, "hello\tworld\t42\na logger message\n");
2042  }
2043
2044  #[tokio::test]
2045  async fn loads_the_jwt_plugin_and_runs_the_init_function() {
2046    let manifest = jwt_manifest();
2047    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2048    let lua = plugin.runtime.lock().await;
2049    let entries = call_init(&lua, "test", "0.0.0").unwrap();
2050    assert_eq!(entries.len(), 2);
2051    assert_eq!(entries[0].key, "jwt");
2052    assert_eq!(entries[0].r#type, catalogue_entry::EntryType::ContentMatcher as i32);
2053    assert_eq!(entries[1].r#type, catalogue_entry::EntryType::ContentGenerator as i32);
2054  }
2055
2056  #[tokio::test]
2057  async fn init_accepts_matcher_and_generator_catalogue_entries() {
2058    // A field-level plugin registers MATCHER/GENERATOR entries rather than content ones. GENERATOR
2059    // only exists on the V2 enum, so this also covers the entry type surviving the trip through
2060    // the V1-shaped CatalogueEntry message the driver uses internally.
2061    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2062    std::fs::write(
2063      plugin_dir.path().join("entry.lua"),
2064      r#"
2065        function init(implementation, version)
2066          return {
2067            { entryType = "MATCHER", key = "creditcard", values = { ["config-key"] = "brand" } },
2068            { entryType = "GENERATOR", key = "creditcard" }
2069          }
2070        end
2071      "#,
2072    ).unwrap();
2073
2074    let manifest = lua_manifest(plugin_dir.path(), "field-entries-test");
2075    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2076    let lua = plugin.runtime.lock().await;
2077    let entries = call_init(&lua, "test", "0.0.0").unwrap();
2078
2079    assert_eq!(entries.len(), 2);
2080    assert_eq!(entries[0].key, "creditcard");
2081    assert_eq!(entries[0].r#type, CatalogueEntryType::MATCHER.to_proto_value());
2082    assert_eq!(entries[0].values.get("config-key"), Some(&"brand".to_string()));
2083    assert_eq!(entries[1].r#type, CatalogueEntryType::GENERATOR.to_proto_value());
2084  }
2085
2086  #[tokio::test]
2087  async fn init_rejects_an_unknown_catalogue_entry_type() {
2088    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2089    std::fs::write(
2090      plugin_dir.path().join("entry.lua"),
2091      r#"
2092        function init(implementation, version)
2093          return { { entryType = "NOT_AN_ENTRY_TYPE", key = "nope" } }
2094        end
2095      "#,
2096    ).unwrap();
2097
2098    let manifest = lua_manifest(plugin_dir.path(), "bad-entry-type-test");
2099    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2100    let lua = plugin.runtime.lock().await;
2101
2102    let error = call_init(&lua, "test", "0.0.0").unwrap_err().to_string();
2103    assert!(error.contains("NOT_AN_ENTRY_TYPE"), "unexpected error: {}", error);
2104  }
2105
2106  #[tokio::test]
2107  async fn configure_interaction_then_match_contents_round_trip() {
2108    let manifest = jwt_manifest();
2109    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2110
2111    let mut config_fields = HashMap::new();
2112    config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
2113    config_fields.insert("subject".to_string(), serde_json::Value::String("test-subject".to_string()));
2114    config_fields.insert("issuer".to_string(), serde_json::Value::String("test-issuer".to_string()));
2115    config_fields.insert("audience".to_string(), serde_json::Value::String("test-audience".to_string()));
2116    config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
2117
2118    let configure_request = ConfigureInteractionRequest {
2119      content_type: "application/jwt+json".to_string(),
2120      contents_config: Some(to_proto_struct(&config_fields)),
2121    };
2122    let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
2123    assert_eq!(configure_response.error, "");
2124    assert_eq!(configure_response.interaction.len(), 1);
2125
2126    let interaction = &configure_response.interaction[0];
2127    let body = interaction.contents.clone().expect("expected a body");
2128    assert_eq!(body.content_type, "application/jwt+json");
2129    let token = String::from_utf8(body.content.clone().unwrap()).unwrap();
2130    assert_eq!(token.split('.').count(), 3);
2131
2132    let compare_request = CompareContentsRequest {
2133      expected: Some(body.clone()),
2134      actual: Some(body),
2135      allow_unexpected_keys: false,
2136      rules: HashMap::new(),
2137      plugin_configuration: interaction.plugin_configuration.clone(),
2138    };
2139    let compare_response = plugin.compare_contents(compare_request).await.unwrap();
2140    assert_eq!(compare_response.error, "");
2141    assert!(compare_response.type_mismatch.is_none());
2142    assert!(
2143      compare_response.results.is_empty(),
2144      "expected no mismatches, got {:?}",
2145      compare_response.results
2146    );
2147  }
2148
2149  #[tokio::test]
2150  async fn match_contents_detects_a_tampered_token() {
2151    let manifest = jwt_manifest();
2152    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2153
2154    let mut config_fields = HashMap::new();
2155    config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
2156    config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
2157
2158    let configure_request = ConfigureInteractionRequest {
2159      content_type: "application/jwt+json".to_string(),
2160      contents_config: Some(to_proto_struct(&config_fields)),
2161    };
2162    let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
2163    let interaction = &configure_response.interaction[0];
2164    let expected_body = interaction.contents.clone().unwrap();
2165
2166    let mut actual_body = expected_body.clone();
2167    let mut token = String::from_utf8(actual_body.content.clone().unwrap()).unwrap();
2168    token.push('x'); // tamper with the signature
2169    actual_body.content = Some(token.into_bytes());
2170
2171    let compare_request = CompareContentsRequest {
2172      expected: Some(expected_body),
2173      actual: Some(actual_body),
2174      allow_unexpected_keys: false,
2175      rules: HashMap::new(),
2176      plugin_configuration: interaction.plugin_configuration.clone(),
2177    };
2178    let compare_response = plugin.compare_contents(compare_request).await.unwrap();
2179    assert!(!compare_response.results.is_empty(), "expected a mismatch to be detected");
2180  }
2181
2182  #[tokio::test]
2183  async fn compare_contents_handles_non_string_mismatch_values() {
2184    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2185    std::fs::write(
2186      plugin_dir.path().join("entry.lua"),
2187      r#"
2188        function match_contents(request)
2189          return {
2190            mismatches = {
2191              ["claims:exp"] = { expected = 123, actual = 456, mismatch = "exp differs", path = "claims:exp" },
2192              ["claims:verified"] = { expected = true, actual = false, mismatch = "verified differs", path = "claims:verified" }
2193            }
2194          }
2195        end
2196      "#,
2197    ).unwrap();
2198
2199    let manifest = PactPluginManifest {
2200      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
2201      plugin_interface_version: 1,
2202      name: "scalar-mismatch-test".to_string(),
2203      version: "0.0.0".to_string(),
2204      executable_type: "lua".to_string(),
2205      minimum_required_version: None,
2206      entry_point: "entry.lua".to_string(),
2207      entry_points: HashMap::new(),
2208      args: None,
2209      dependencies: None,
2210      plugin_config: HashMap::new(),
2211    };
2212    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2213
2214    let compare_request = CompareContentsRequest {
2215      expected: None,
2216      actual: None,
2217      allow_unexpected_keys: false,
2218      rules: HashMap::new(),
2219      plugin_configuration: None,
2220    };
2221    let response = plugin.compare_contents(compare_request).await.unwrap();
2222
2223    let exp_mismatch = &response.results["claims:exp"].mismatches[0];
2224    assert_eq!(exp_mismatch.expected.as_deref(), Some("123".as_bytes()));
2225    assert_eq!(exp_mismatch.actual.as_deref(), Some("456".as_bytes()));
2226
2227    let verified_mismatch = &response.results["claims:verified"].mismatches[0];
2228    assert_eq!(verified_mismatch.expected.as_deref(), Some("true".as_bytes()));
2229    assert_eq!(verified_mismatch.actual.as_deref(), Some("false".as_bytes()));
2230  }
2231
2232  fn lua_manifest(plugin_dir: &std::path::Path, name: &str) -> PactPluginManifest {
2233    PactPluginManifest {
2234      plugin_dir: plugin_dir.to_string_lossy().to_string(),
2235      plugin_interface_version: 1,
2236      name: name.to_string(),
2237      version: "0.0.0".to_string(),
2238      executable_type: "lua".to_string(),
2239      minimum_required_version: None,
2240      entry_point: "entry.lua".to_string(),
2241      entry_points: HashMap::new(),
2242      args: None,
2243      dependencies: None,
2244      plugin_config: HashMap::new(),
2245    }
2246  }
2247
2248  fn core_matcher_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
2249    crate::catalogue_manager::CatalogueEntry {
2250      entry_type: crate::catalogue_manager::CatalogueEntryType::CONTENT_MATCHER,
2251      provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
2252      plugin: None,
2253      key: key.to_string(),
2254      values: HashMap::new()
2255    }
2256  }
2257
2258  fn core_generator_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
2259    crate::catalogue_manager::CatalogueEntry {
2260      entry_type: crate::catalogue_manager::CatalogueEntryType::CONTENT_GENERATOR,
2261      provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
2262      plugin: None,
2263      key: key.to_string(),
2264      values: HashMap::new()
2265    }
2266  }
2267
2268  struct FixedErrorCoreMatcher;
2269
2270  #[async_trait]
2271  impl crate::core_capabilities::CoreContentMatcher for FixedErrorCoreMatcher {
2272    async fn compare_contents(&self, _request: CompareContentsRequest) -> anyhow::Result<CompareContentsResponse> {
2273      Ok(CompareContentsResponse {
2274        error: "core matcher says no".to_string(),
2275        type_mismatch: None,
2276        results: HashMap::new(),
2277      })
2278    }
2279  }
2280
2281  #[tokio::test]
2282  async fn match_contents_calls_host_compare_contents_for_a_registered_core_capability() {
2283    let key = "match_contents_calls_host_compare_contents_for_a_registered_core_capability";
2284    crate::catalogue_manager::register_core_entries(&vec![core_matcher_entry(key)]);
2285    crate::core_capabilities::register_core_content_matcher(key, Arc::new(FixedErrorCoreMatcher));
2286
2287    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2288    std::fs::write(
2289      plugin_dir.path().join("entry.lua"),
2290      format!(r#"
2291        function match_contents(request)
2292          return host_compare_contents("{key}", request)
2293        end
2294      "#, key = key),
2295    ).unwrap();
2296    let manifest = lua_manifest(plugin_dir.path(), "host-compare-contents-test");
2297    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2298
2299    let compare_request = CompareContentsRequest {
2300      expected: None,
2301      actual: None,
2302      allow_unexpected_keys: false,
2303      rules: HashMap::new(),
2304      plugin_configuration: None,
2305    };
2306    let response = plugin.compare_contents(compare_request).await.unwrap();
2307
2308    crate::core_capabilities::deregister_core_content_matcher(key);
2309
2310    assert_eq!(response.error, "core matcher says no");
2311  }
2312
2313  #[tokio::test]
2314  async fn match_contents_surfaces_a_clear_error_when_host_compare_contents_targets_an_unregistered_entry() {
2315    let key = "match_contents_surfaces_a_clear_error_when_host_compare_contents_targets_an_unregistered_entry";
2316    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2317    std::fs::write(
2318      plugin_dir.path().join("entry.lua"),
2319      format!(r#"
2320        function match_contents(request)
2321          return host_compare_contents("{key}", request)
2322        end
2323      "#, key = key),
2324    ).unwrap();
2325    let manifest = lua_manifest(plugin_dir.path(), "host-compare-contents-missing-test");
2326    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2327
2328    let compare_request = CompareContentsRequest {
2329      expected: None,
2330      actual: None,
2331      allow_unexpected_keys: false,
2332      rules: HashMap::new(),
2333      plugin_configuration: None,
2334    };
2335    let err = plugin.compare_contents(compare_request).await
2336      .expect_err("expected an error when the target entry is not registered");
2337    assert!(
2338      err.to_string().contains("No catalogue entry found"),
2339      "unexpected error message: {}", err
2340    );
2341  }
2342
2343  struct FixedCoreGenerator;
2344
2345  #[async_trait]
2346  impl crate::core_capabilities::CoreContentGenerator for FixedCoreGenerator {
2347    async fn generate_content(&self, _request: GenerateContentRequest) -> anyhow::Result<GenerateContentResponse> {
2348      Ok(GenerateContentResponse {
2349        contents: Some(Body {
2350          content_type: "text/plain".to_string(),
2351          content: Some(b"generated by the host".to_vec()),
2352          content_type_hint: body::ContentTypeHint::Default as i32,
2353        }),
2354      })
2355    }
2356  }
2357
2358  #[tokio::test]
2359  async fn generate_content_calls_host_generate_content_for_a_registered_core_capability() {
2360    let key = "generate_content_calls_host_generate_content_for_a_registered_core_capability";
2361    crate::catalogue_manager::register_core_entries(&vec![core_generator_entry(key)]);
2362    crate::core_capabilities::register_core_content_generator(key, Arc::new(FixedCoreGenerator));
2363
2364    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2365    std::fs::write(
2366      plugin_dir.path().join("entry.lua"),
2367      format!(r#"
2368        function generate_content(contents, generators, test_mode)
2369          return host_generate_content("{key}", contents, generators, test_mode)
2370        end
2371      "#, key = key),
2372    ).unwrap();
2373    let manifest = lua_manifest(plugin_dir.path(), "host-generate-content-test");
2374    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2375
2376    let request = GenerateContentRequest {
2377      contents: Some(Body {
2378        content_type: "text/plain".to_string(),
2379        content: Some(b"original".to_vec()),
2380        content_type_hint: body::ContentTypeHint::Default as i32,
2381      }),
2382      .. GenerateContentRequest::default()
2383    };
2384    let response = plugin.generate_content(request).await.unwrap();
2385
2386    crate::core_capabilities::deregister_core_content_generator(key);
2387
2388    assert_eq!(response.contents.unwrap().content, Some(b"generated by the host".to_vec()));
2389  }
2390
2391  fn transport_manifest(plugin_dir: &std::path::Path, plugin_interface_version: u8) -> PactPluginManifest {
2392    PactPluginManifest {
2393      plugin_dir: plugin_dir.to_string_lossy().to_string(),
2394      plugin_interface_version,
2395      name: "transport-test".to_string(),
2396      version: "0.0.0".to_string(),
2397      executable_type: "lua".to_string(),
2398      minimum_required_version: None,
2399      entry_point: "entry.lua".to_string(),
2400      entry_points: HashMap::new(),
2401      args: None,
2402      dependencies: None,
2403      plugin_config: HashMap::new(),
2404    }
2405  }
2406
2407  const TRANSPORT_PLUGIN_SCRIPT: &str = r#"
2408    function start_mock_server(request)
2409      START_MOCK_SERVER_REQUEST = request
2410      if request.port == 0 then
2411        return { error = "could not bind a mock server" }
2412      end
2413      return { details = { key = "mock-server-1", port = 12345, address = "127.0.0.1:12345" } }
2414    end
2415
2416    function shutdown_mock_server(server_key)
2417      SHUTDOWN_SERVER_KEY = server_key
2418      return {
2419        ok = false,
2420        results = { { path = "/foo", error = "did not match", mismatches = { "simple string mismatch" } } }
2421      }
2422    end
2423
2424    function get_mock_server_results(server_key)
2425      GET_RESULTS_SERVER_KEY = server_key
2426      return { ok = true, results = {} }
2427    end
2428
2429    function prepare_interaction_for_verification(request)
2430      PREPARE_REQUEST = request
2431      return {
2432        interaction_data = {
2433          body = { content_type = "application/json", contents = "prepared-body", content_type_hint = "TEXT" },
2434          metadata = { path = "/foo", tag = { binary = "raw-bytes" } }
2435        }
2436      }
2437    end
2438
2439    function verify_interaction(request)
2440      VERIFY_REQUEST = request
2441      if request.config ~= nil and request.config.fail == true then
2442        return { error = "verification failed" }
2443      end
2444      return {
2445        result = {
2446          success = true,
2447          response_data = { body = { content_type = "application/json", contents = "response-body" }, metadata = {} },
2448          mismatches = { "a plain mismatch", { mismatch = "a table mismatch", path = "$.foo", expected = 1, actual = 2 } },
2449          output = { "POST /foo", "200 OK" }
2450        }
2451      }
2452    end
2453  "#;
2454
2455  fn start_transport_plugin(plugin_interface_version: u8) -> LuaPactPlugin {
2456    let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2457    std::fs::write(plugin_dir.path().join("entry.lua"), TRANSPORT_PLUGIN_SCRIPT).unwrap();
2458    let manifest = transport_manifest(plugin_dir.path(), plugin_interface_version);
2459    // The script is fully read into the Lua VM by `start_lua_plugin`, so the tempdir doesn't
2460    // need to outlive this call.
2461    start_lua_plugin(&manifest, "test-instance".to_string()).unwrap()
2462  }
2463
2464  #[tokio::test]
2465  async fn start_mock_server_v1_round_trip() {
2466    let plugin = start_transport_plugin(1);
2467    let response = plugin.start_mock_server(StartMockServerRequest {
2468      host_interface: "127.0.0.1".to_string(),
2469      port: 8080,
2470      tls: false,
2471      pact: "{\"consumer\":{}}".to_string(),
2472      test_context: None,
2473    }).await.unwrap();
2474    match response.response.unwrap() {
2475      start_mock_server_response::Response::Details(details) => {
2476        assert_eq!(details.key, "mock-server-1");
2477        assert_eq!(details.port, 12345);
2478        assert_eq!(details.address, "127.0.0.1:12345");
2479      }
2480      other => panic!("expected mock server details, got {:?}", other),
2481    }
2482  }
2483
2484  #[tokio::test]
2485  async fn start_mock_server_v1_returns_the_lua_error() {
2486    let plugin = start_transport_plugin(1);
2487    let response = plugin.start_mock_server(StartMockServerRequest {
2488      host_interface: "127.0.0.1".to_string(),
2489      port: 0,
2490      tls: false,
2491      pact: "{}".to_string(),
2492      test_context: None,
2493    }).await.unwrap();
2494    match response.response.unwrap() {
2495      start_mock_server_response::Response::Error(err) => assert_eq!(err, "could not bind a mock server"),
2496      other => panic!("expected an error response, got {:?}", other),
2497    }
2498  }
2499
2500  #[tokio::test]
2501  async fn start_mock_server_v2_passes_structured_interactions() {
2502    let plugin = start_transport_plugin(2);
2503    let request = proto_v2::StartMockServerRequest {
2504      host_interface: "127.0.0.1".to_string(),
2505      port: 8080,
2506      tls: false,
2507      interactions: vec![proto_v2::InteractionContents {
2508        interaction_type: "Synchronous/HTTP".to_string(),
2509        plugin_configuration: None,
2510        consumer: "test-consumer".to_string(),
2511        provider: "test-provider".to_string(),
2512      }],
2513      test_context: None,
2514    };
2515    let response = plugin.start_mock_server_v2(request).await.unwrap();
2516    assert!(matches!(response.response.unwrap(), start_mock_server_response::Response::Details(_)));
2517
2518    let lua = plugin.runtime.lock().await;
2519    let captured: Table = lua.globals().get("START_MOCK_SERVER_REQUEST").unwrap();
2520    let interactions: Table = captured.get("interactions").unwrap();
2521    let first: Table = interactions.get(1).unwrap();
2522    assert_eq!(first.get::<String>("interaction_type").unwrap(), "Synchronous/HTTP");
2523    assert_eq!(first.get::<String>("consumer").unwrap(), "test-consumer");
2524  }
2525
2526  #[tokio::test]
2527  async fn shutdown_and_get_mock_server_results_parse_mismatches() {
2528    let plugin = start_transport_plugin(1);
2529
2530    let shutdown_response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2531      server_key: "mock-server-1".to_string(),
2532    }).await.unwrap();
2533    assert!(!shutdown_response.ok);
2534    assert_eq!(shutdown_response.results.len(), 1);
2535    assert_eq!(shutdown_response.results[0].path, "/foo");
2536    assert_eq!(shutdown_response.results[0].mismatches[0].mismatch, "simple string mismatch");
2537
2538    let results_response = plugin.get_mock_server_results(MockServerRequest {
2539      server_key: "mock-server-1".to_string(),
2540    }).await.unwrap();
2541    assert!(results_response.ok);
2542    assert!(results_response.results.is_empty());
2543  }
2544
2545  #[tokio::test]
2546  async fn prepare_interaction_for_verification_v1_round_trip() {
2547    let plugin = start_transport_plugin(1);
2548    let response = plugin.prepare_interaction_for_verification(VerificationPreparationRequest {
2549      pact: "{}".to_string(),
2550      interaction_key: "interaction-1".to_string(),
2551      config: None,
2552    }).await.unwrap();
2553
2554    match response.response.unwrap() {
2555      verification_preparation_response::Response::InteractionData(data) => {
2556        let body = data.body.unwrap();
2557        assert_eq!(body.content, Some("prepared-body".as_bytes().to_vec()));
2558        let metadata = data.metadata;
2559        assert!(matches!(
2560          metadata["path"].value,
2561          Some(metadata_value::Value::NonBinaryValue(_))
2562        ));
2563        match &metadata["tag"].value {
2564          Some(metadata_value::Value::BinaryValue(bytes)) => assert_eq!(bytes, b"raw-bytes"),
2565          other => panic!("expected a binary metadata value, got {:?}", other),
2566        }
2567      }
2568      other => panic!("expected interaction data, got {:?}", other),
2569    }
2570  }
2571
2572  #[tokio::test]
2573  async fn prepare_interaction_for_verification_v2_passes_interaction_contents() {
2574    let plugin = start_transport_plugin(2);
2575    let request = proto_v2::VerificationPreparationRequest {
2576      interaction_contents: Some(proto_v2::InteractionContents {
2577        interaction_type: "Synchronous/HTTP".to_string(),
2578        plugin_configuration: None,
2579        consumer: "test-consumer".to_string(),
2580        provider: "test-provider".to_string(),
2581      }),
2582      config: None,
2583      test_context: None,
2584    };
2585    let response = plugin.prepare_interaction_for_verification_v2(request).await.unwrap();
2586    assert!(matches!(
2587      response.response.unwrap(),
2588      verification_preparation_response::Response::InteractionData(_)
2589    ));
2590
2591    let lua = plugin.runtime.lock().await;
2592    let captured: Table = lua.globals().get("PREPARE_REQUEST").unwrap();
2593    let interaction_contents: Table = captured.get("interaction_contents").unwrap();
2594    assert_eq!(interaction_contents.get::<String>("provider").unwrap(), "test-provider");
2595  }
2596
2597  #[tokio::test]
2598  async fn verify_interaction_v1_round_trip() {
2599    let plugin = start_transport_plugin(1);
2600    let mut metadata = HashMap::new();
2601    metadata.insert("path".to_string(), MetadataValue {
2602      value: Some(metadata_value::Value::NonBinaryValue(prost_types::Value {
2603        kind: Some(prost_types::value::Kind::StringValue("/foo".to_string())),
2604      })),
2605    });
2606    let response = plugin.verify_interaction(VerifyInteractionRequest {
2607      interaction_data: Some(InteractionData {
2608        body: Some(Body {
2609          content_type: "application/json".to_string(),
2610          content: Some("request-body".as_bytes().to_vec()),
2611          content_type_hint: body::ContentTypeHint::Text as i32,
2612        }),
2613        metadata,
2614      }),
2615      config: None,
2616      pact: "{}".to_string(),
2617      interaction_key: "interaction-1".to_string(),
2618    }).await.unwrap();
2619
2620    match response.response.unwrap() {
2621      verify_interaction_response::Response::Result(result) => {
2622        assert!(result.success);
2623        assert_eq!(result.output, vec!["POST /foo".to_string(), "200 OK".to_string()]);
2624        assert_eq!(result.mismatches.len(), 2);
2625        match &result.mismatches[0].result {
2626          Some(verification_result_item::Result::Error(err)) => assert_eq!(err, "a plain mismatch"),
2627          other => panic!("expected an error mismatch, got {:?}", other),
2628        }
2629        match &result.mismatches[1].result {
2630          Some(verification_result_item::Result::Mismatch(mismatch)) => {
2631            assert_eq!(mismatch.mismatch, "a table mismatch");
2632            assert_eq!(mismatch.expected, Some(b"1".to_vec()));
2633          }
2634          other => panic!("expected a mismatch, got {:?}", other),
2635        }
2636      }
2637      other => panic!("expected a verification result, got {:?}", other),
2638    }
2639
2640    let lua = plugin.runtime.lock().await;
2641    let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
2642    let interaction_data: Table = captured.get("interaction_data").unwrap();
2643    let metadata: Table = interaction_data.get("metadata").unwrap();
2644    assert_eq!(metadata.get::<String>("path").unwrap(), "/foo");
2645  }
2646
2647  #[tokio::test]
2648  async fn verify_interaction_v1_returns_the_lua_error() {
2649    let plugin = start_transport_plugin(1);
2650    let mut config = HashMap::new();
2651    config.insert("fail".to_string(), serde_json::Value::Bool(true));
2652    let response = plugin.verify_interaction(VerifyInteractionRequest {
2653      interaction_data: None,
2654      config: Some(to_proto_struct(&config)),
2655      pact: "{}".to_string(),
2656      interaction_key: "interaction-1".to_string(),
2657    }).await.unwrap();
2658    match response.response.unwrap() {
2659      verify_interaction_response::Response::Error(err) => assert_eq!(err, "verification failed"),
2660      other => panic!("expected an error response, got {:?}", other),
2661    }
2662  }
2663
2664  #[tokio::test]
2665  async fn verify_interaction_v2_converts_the_v2_interaction_data_and_contents() {
2666    let plugin = start_transport_plugin(2);
2667    let request = proto_v2::VerifyInteractionRequest {
2668      interaction_data: Some(proto_v2::InteractionData {
2669        body: Some(proto_v2::Body {
2670          content_type: "application/json".to_string(),
2671          content: Some("request-body".as_bytes().to_vec()),
2672          content_type_hint: 0,
2673        }),
2674        metadata: HashMap::new(),
2675      }),
2676      config: None,
2677      interaction_contents: Some(proto_v2::InteractionContents {
2678        interaction_type: "Synchronous/HTTP".to_string(),
2679        plugin_configuration: None,
2680        consumer: "test-consumer".to_string(),
2681        provider: "test-provider".to_string(),
2682      }),
2683      test_context: None,
2684    };
2685    let response = plugin.verify_interaction_v2(request).await.unwrap();
2686    assert!(matches!(
2687      response.response.unwrap(),
2688      verify_interaction_response::Response::Result(_)
2689    ));
2690
2691    let lua = plugin.runtime.lock().await;
2692    let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
2693    let interaction_data: Table = captured.get("interaction_data").unwrap();
2694    let body: Table = interaction_data.get("body").unwrap();
2695    assert_eq!(body.get::<mlua::LuaString>("contents").unwrap().to_str().unwrap(), "request-body");
2696    let interaction_contents: Table = captured.get("interaction_contents").unwrap();
2697    assert_eq!(interaction_contents.get::<String>("consumer").unwrap(), "test-consumer");
2698  }
2699
2700  #[tokio::test]
2701  async fn shutdown_mock_server_defaults_ok_to_true_when_the_field_is_absent() {
2702    // Regression test: `Table::get::<bool>("ok")` converts a *missing* key's Lua nil straight
2703    // to `false` (mlua's bool conversion, matching Lua's own nil-is-falsy semantics) rather than
2704    // erroring - so a plain `.unwrap_or(true)` fallback was never reached, and an `ok`-less
2705    // response used to silently report `ok = false` instead of the documented default of
2706    // `true`. Reading as `Option<bool>` first lets a missing key correctly fall through to the
2707    // `unwrap_or(true)` default, since `Option<T>` intercepts Lua nil before the inner
2708    // conversion happens.
2709    let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2710    std::fs::write(
2711      plugin_dir.path().join("entry.lua"),
2712      r#"
2713        function shutdown_mock_server(server_key)
2714          return { results = {} }
2715        end
2716      "#,
2717    ).unwrap();
2718    let manifest = transport_manifest(plugin_dir.path(), 1);
2719    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2720
2721    let response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2722      server_key: "mock-server-1".to_string(),
2723    }).await.unwrap();
2724    assert!(response.ok, "expected 'ok' to default to true when the Lua script doesn't set it");
2725  }
2726
2727  #[tokio::test]
2728  async fn shutdown_mock_server_errors_on_a_wrong_typed_path_field() {
2729    let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
2730    std::fs::write(
2731      plugin_dir.path().join("entry.lua"),
2732      r#"
2733        function shutdown_mock_server(server_key)
2734          return { ok = false, results = { { path = {}, error = "boom", mismatches = {} } } }
2735        end
2736      "#,
2737    ).unwrap();
2738    let manifest = transport_manifest(plugin_dir.path(), 1);
2739    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2740
2741    let result = plugin.shutdown_mock_server(ShutdownMockServerRequest {
2742      server_key: "mock-server-1".to_string(),
2743    }).await;
2744    assert!(
2745      result.is_err(),
2746      "expected a wrong-typed 'path' field (a table, not a string) to be a hard error, not silently default, got {:?}",
2747      result
2748    );
2749  }
2750
2751  // ---- Field-level matchers and generators (proposal 006) ----
2752
2753  fn creditcard_manifest() -> PactPluginManifest {
2754    // See jwt_manifest() for why this path is deliberately not canonicalized.
2755    let plugin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../plugins/creditcard");
2756    assert!(plugin_dir.exists(), "plugins/creditcard directory should exist at {:?}", plugin_dir);
2757    PactPluginManifest {
2758      plugin_dir: plugin_dir.to_string_lossy().to_string(),
2759      plugin_interface_version: 2,
2760      name: "creditcard".to_string(),
2761      version: "0.0.0".to_string(),
2762      executable_type: "lua".to_string(),
2763      minimum_required_version: None,
2764      entry_point: "plugin.lua".to_string(),
2765      entry_points: HashMap::new(),
2766      args: None,
2767      dependencies: None,
2768      plugin_config: HashMap::new(),
2769    }
2770  }
2771
2772  fn text_field(value: &str) -> proto_v2::FieldValue {
2773    proto_v2::FieldValue {
2774      value: Some(proto_v2::field_value::Value::StringValue(value.to_string()))
2775    }
2776  }
2777
2778  /// A `MatchFieldRequest` for the `creditcard` rule, optionally configured with a brand.
2779  fn creditcard_match_request(brand: Option<&str>, expected: &str, actual: &str) -> proto_v2::MatchFieldRequest {
2780    let values = brand.map(|brand| {
2781      let mut fields = HashMap::new();
2782      fields.insert("brand".to_string(), serde_json::Value::String(brand.to_string()));
2783      to_proto_struct(&fields)
2784    });
2785    proto_v2::MatchFieldRequest {
2786      key: "creditcard".to_string(),
2787      rule: Some(proto_v2::MatchingRule { r#type: "creditcard".to_string(), values }),
2788      path: "$.card.number".to_string(),
2789      mismatch_type: "body".to_string(),
2790      expected: Some(text_field(expected)),
2791      actual: Some(text_field(actual)),
2792      plugin_configuration: None,
2793      test_context: None,
2794    }
2795  }
2796
2797  fn creditcard_generate_request(brand: Option<&str>, example: &str) -> proto_v2::GenerateFieldRequest {
2798    let values = brand.map(|brand| {
2799      let mut fields = HashMap::new();
2800      fields.insert("brand".to_string(), serde_json::Value::String(brand.to_string()));
2801      to_proto_struct(&fields)
2802    });
2803    proto_v2::GenerateFieldRequest {
2804      key: "creditcard".to_string(),
2805      generator: Some(proto_v2::Generator { r#type: "creditcard".to_string(), values }),
2806      path: "$.card.number".to_string(),
2807      example_value: Some(text_field(example)),
2808      plugin_configuration: None,
2809      test_context: None,
2810      test_mode: proto_v2::generate_content_request::TestMode::Consumer as i32,
2811    }
2812  }
2813
2814  #[tokio::test]
2815  async fn creditcard_plugin_registers_a_matcher_and_a_generator_under_the_same_key() {
2816    let manifest = creditcard_manifest();
2817    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2818    let lua = plugin.runtime.lock().await;
2819    let entries = call_init(&lua, "test", "0.0.0").unwrap();
2820
2821    assert_eq!(entries.len(), 2);
2822    assert_eq!(entries[0].key, "creditcard");
2823    assert_eq!(entries[0].r#type, proto_v2::catalogue_entry::EntryType::Matcher as i32);
2824    assert_eq!(entries[1].key, "creditcard");
2825    assert_eq!(entries[1].r#type, proto_v2::catalogue_entry::EntryType::Generator as i32);
2826    // The values key that maps a single positional config argument in a rule definition
2827    assert_eq!(entries[0].values.get("config-key"), Some(&"brand".to_string()));
2828  }
2829
2830  #[tokio::test]
2831  async fn creditcard_plugin_accepts_a_valid_card_number() {
2832    let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
2833
2834    let response = plugin
2835      .match_field(creditcard_match_request(Some("visa"), "4111111111111111", "4012888888881881"))
2836      .await
2837      .unwrap();
2838
2839    assert_eq!(response.error, "");
2840    assert!(response.mismatches.is_empty(), "expected no mismatches, got {:?}", response.mismatches);
2841  }
2842
2843  #[tokio::test]
2844  async fn creditcard_plugin_reports_a_number_that_fails_the_luhn_check() {
2845    let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
2846
2847    let response = plugin
2848      .match_field(creditcard_match_request(None, "4111111111111111", "4111111111111112"))
2849      .await
2850      .unwrap();
2851
2852    assert_eq!(response.error, "");
2853    assert_eq!(response.mismatches.len(), 1);
2854    let mismatch = &response.mismatches[0];
2855    assert!(
2856      mismatch.mismatch.contains("Luhn check"),
2857      "unexpected mismatch description: {}", mismatch.mismatch
2858    );
2859    // The plugin places its own mismatches, echoing back the path and part it was given
2860    assert_eq!(mismatch.path, "$.card.number");
2861    assert_eq!(mismatch.mismatch_type, "body");
2862    assert_eq!(mismatch.expected.as_deref(), Some("4111111111111111".as_bytes()));
2863    assert_eq!(mismatch.actual.as_deref(), Some("4111111111111112".as_bytes()));
2864  }
2865
2866  #[tokio::test]
2867  async fn creditcard_plugin_reports_a_number_from_the_wrong_brand() {
2868    let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
2869
2870    // A valid Visa number, but the rule asks for a Mastercard
2871    let response = plugin
2872      .match_field(creditcard_match_request(Some("mastercard"), "5555555555554444", "4012888888881881"))
2873      .await
2874      .unwrap();
2875
2876    assert_eq!(response.mismatches.len(), 1);
2877    assert!(
2878      response.mismatches[0].mismatch.contains("Mastercard"),
2879      "unexpected mismatch description: {}", response.mismatches[0].mismatch
2880    );
2881  }
2882
2883  #[tokio::test]
2884  async fn creditcard_plugin_reports_a_misconfigured_brand_as_an_error_not_a_mismatch() {
2885    // The test author's mistake, not the provider's - so it fails the test outright rather than
2886    // being reported as the provider sending the wrong value.
2887    let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
2888
2889    let response = plugin
2890      .match_field(creditcard_match_request(Some("amx"), "4111111111111111", "4111111111111111"))
2891      .await
2892      .unwrap();
2893
2894    assert!(
2895      response.error.contains("'amx' is not a credit card brand"),
2896      "unexpected error: {}", response.error
2897    );
2898    assert!(response.mismatches.is_empty());
2899  }
2900
2901  #[tokio::test]
2902  async fn creditcard_plugin_generates_a_number_for_the_configured_brand() {
2903    let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
2904
2905    let response = plugin
2906      .generate_field(creditcard_generate_request(Some("amex"), "4111111111111111"))
2907      .await
2908      .unwrap();
2909
2910    assert_eq!(response.error, "");
2911    let generated = match response.value.and_then(|value| value.value) {
2912      Some(proto_v2::field_value::Value::StringValue(value)) => value,
2913      other => panic!("expected a generated string value, got {:?}", other)
2914    };
2915    assert_eq!(generated.len(), 15, "an Amex number has 15 digits, got '{}'", generated);
2916    assert!(generated.starts_with("34") || generated.starts_with("37"), "got '{}'", generated);
2917
2918    // And the number it generated is one it will accept back
2919    let match_response = plugin
2920      .match_field(creditcard_match_request(Some("amex"), "371449635398431", &generated))
2921      .await
2922      .unwrap();
2923    assert!(
2924      match_response.mismatches.is_empty(),
2925      "the plugin should accept its own generated number, got {:?}", match_response.mismatches
2926    );
2927  }
2928
2929  #[tokio::test]
2930  async fn creditcard_plugin_reports_a_generator_error() {
2931    let plugin = start_lua_plugin(&creditcard_manifest(), "test-instance".to_string()).unwrap();
2932
2933    let response = plugin
2934      .generate_field(creditcard_generate_request(Some("amx"), "4111111111111111"))
2935      .await
2936      .unwrap();
2937
2938    assert!(response.error.contains("amx"), "unexpected error: {}", response.error);
2939    assert!(response.value.is_none());
2940  }
2941
2942  /// Starts a Lua plugin whose entry point is the given script source.
2943  fn start_field_plugin(name: &str, script: &str) -> (tempdir::TempDir, LuaPactPlugin) {
2944    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
2945    std::fs::write(plugin_dir.path().join("entry.lua"), script).unwrap();
2946    let manifest = lua_manifest(plugin_dir.path(), name);
2947    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
2948    // The temp dir is returned so it outlives the plugin - dropping it deletes the script
2949    (plugin_dir, plugin)
2950  }
2951
2952  #[tokio::test]
2953  async fn each_field_value_type_survives_the_round_trip_through_lua() {
2954    use proto_v2::field_value::Value as FieldValue;
2955
2956    let (_dir, plugin) = start_field_plugin(
2957      "field-value-round-trip-test",
2958      r#"
2959        function generate_field(request)
2960          return { value = request.example_value }
2961        end
2962      "#,
2963    );
2964
2965    let values = vec![
2966      FieldValue::NullValue(0),
2967      FieldValue::BooleanValue(true),
2968      FieldValue::StringValue("4111111111111111".to_string()),
2969      FieldValue::IntegerValue(100),
2970      FieldValue::DecimalValue(100.5),
2971      FieldValue::BinaryValue(vec![0, 159, 146, 150]),
2972    ];
2973
2974    for value in values {
2975      let request = proto_v2::GenerateFieldRequest {
2976        generator: Some(proto_v2::Generator::default()),
2977        example_value: Some(proto_v2::FieldValue { value: Some(value.clone()) }),
2978        .. proto_v2::GenerateFieldRequest::default()
2979      };
2980      let response = plugin.generate_field(request).await.unwrap();
2981      assert_eq!(
2982        response.value.and_then(|response| response.value), Some(value.clone()),
2983        "{:?} did not survive the round trip through Lua", value
2984      );
2985    }
2986  }
2987
2988  #[tokio::test]
2989  async fn a_whole_number_reaches_lua_as_an_integer_and_a_decimal_as_a_float() {
2990    // The distinction the integer, decimal and type rules are built on. Lua 5.4 has separate
2991    // integer and float subtypes, so it can be checked from inside the script itself.
2992    let (_dir, plugin) = start_field_plugin(
2993      "field-value-lua-type-test",
2994      r#"
2995        function generate_field(request)
2996          return { value = math.type(request.example_value) }
2997        end
2998      "#,
2999    );
3000
3001    let lua_type_of = async |value: proto_v2::field_value::Value| {
3002      let request = proto_v2::GenerateFieldRequest {
3003        example_value: Some(proto_v2::FieldValue { value: Some(value) }),
3004        .. proto_v2::GenerateFieldRequest::default()
3005      };
3006      match plugin.generate_field(request).await.unwrap().value.and_then(|value| value.value) {
3007        Some(proto_v2::field_value::Value::StringValue(value)) => value,
3008        other => panic!("expected math.type() to return a string, got {:?}", other)
3009      }
3010    };
3011
3012    assert_eq!(lua_type_of(proto_v2::field_value::Value::IntegerValue(100)).await, "integer");
3013    assert_eq!(lua_type_of(proto_v2::field_value::Value::DecimalValue(100.0)).await, "float");
3014  }
3015
3016  #[tokio::test]
3017  async fn a_mismatch_that_does_not_place_itself_is_reported_against_the_request_path() {
3018    let (_dir, plugin) = start_field_plugin(
3019      "field-mismatch-path-test",
3020      r#"
3021        function match_field(request)
3022          return { mismatches = { "not a card number" } }
3023        end
3024      "#,
3025    );
3026
3027    let response = plugin
3028      .match_field(creditcard_match_request(None, "4111111111111111", "nope"))
3029      .await
3030      .unwrap();
3031
3032    assert_eq!(response.mismatches.len(), 1);
3033    assert_eq!(response.mismatches[0].mismatch, "not a card number");
3034    assert_eq!(response.mismatches[0].path, "$.card.number");
3035  }
3036
3037  #[tokio::test]
3038  async fn a_plugin_that_does_not_define_the_field_functions_says_so() {
3039    let (_dir, plugin) = start_field_plugin("field-functions-missing-test", "-- nothing here");
3040
3041    let match_error = plugin.match_field(proto_v2::MatchFieldRequest::default()).await
3042      .expect_err("expected an error when the plugin does not define match_field");
3043    assert!(
3044      match_error.to_string().contains("does not define a global 'match_field' function"),
3045      "unexpected error: {}", match_error
3046    );
3047
3048    let generate_error = plugin.generate_field(proto_v2::GenerateFieldRequest::default()).await
3049      .expect_err("expected an error when the plugin does not define generate_field");
3050    assert!(
3051      generate_error.to_string().contains("does not define a global 'generate_field' function"),
3052      "unexpected error: {}", generate_error
3053    );
3054  }
3055
3056  fn core_field_matcher_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
3057    crate::catalogue_manager::CatalogueEntry {
3058      entry_type: crate::catalogue_manager::CatalogueEntryType::MATCHER,
3059      provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
3060      plugin: None,
3061      key: key.to_string(),
3062      values: HashMap::new()
3063    }
3064  }
3065
3066  fn core_field_generator_entry(key: &str) -> crate::catalogue_manager::CatalogueEntry {
3067    crate::catalogue_manager::CatalogueEntry {
3068      entry_type: crate::catalogue_manager::CatalogueEntryType::GENERATOR,
3069      provider_type: crate::catalogue_manager::CatalogueEntryProviderType::CORE,
3070      plugin: None,
3071      key: key.to_string(),
3072      values: HashMap::new()
3073    }
3074  }
3075
3076  /// A core field matcher that reports what it was handed, so the test can check the request the
3077  /// script built actually arrived intact.
3078  struct EchoCoreFieldMatcher;
3079
3080  #[async_trait]
3081  impl crate::core_capabilities::CoreFieldMatcher for EchoCoreFieldMatcher {
3082    async fn match_field(&self, request: proto_v2::MatchFieldRequest) -> anyhow::Result<proto_v2::MatchFieldResponse> {
3083      let rule = request.rule.unwrap_or_default();
3084      Ok(proto_v2::MatchFieldResponse {
3085        error: String::new(),
3086        mismatches: vec![proto_v2::ContentMismatch {
3087          expected: None,
3088          actual: None,
3089          mismatch: format!("core matcher saw rule '{}' at {}", rule.r#type, request.path),
3090          path: request.path,
3091          diff: String::new(),
3092          mismatch_type: request.mismatch_type,
3093        }]
3094      })
3095    }
3096  }
3097
3098  #[tokio::test]
3099  async fn match_field_calls_host_match_field_for_a_registered_core_capability() {
3100    // A plugin that owns a content type delegating one value inside it to a standard Pact rule,
3101    // rather than reimplementing it - the point of the host callbacks (proposal 006 section 7).
3102    let key = "match_field_calls_host_match_field_for_a_registered_core_capability";
3103    crate::catalogue_manager::register_core_entries(&vec![core_field_matcher_entry(key)]);
3104    crate::core_capabilities::register_core_field_matcher(key, Arc::new(EchoCoreFieldMatcher));
3105
3106    let (_dir, plugin) = start_field_plugin(
3107      "host-match-field-test",
3108      &format!(r#"
3109        function match_field(request)
3110          return host_match_field("{key}", request)
3111        end
3112      "#, key = key),
3113    );
3114
3115    let response = plugin
3116      .match_field(creditcard_match_request(Some("visa"), "4111111111111111", "4012888888881881"))
3117      .await
3118      .unwrap();
3119
3120    crate::core_capabilities::deregister_core_field_matcher(key);
3121
3122    assert_eq!(response.mismatches.len(), 1);
3123    assert_eq!(
3124      response.mismatches[0].mismatch,
3125      "core matcher saw rule 'creditcard' at $.card.number"
3126    );
3127    assert_eq!(response.mismatches[0].mismatch_type, "body");
3128  }
3129
3130  #[tokio::test]
3131  async fn host_match_field_surfaces_a_clear_error_when_the_entry_is_not_registered() {
3132    let key = "host_match_field_surfaces_a_clear_error_when_the_entry_is_not_registered";
3133    let (_dir, plugin) = start_field_plugin(
3134      "host-match-field-missing-test",
3135      &format!(r#"
3136        function match_field(request)
3137          return host_match_field("{key}", request)
3138        end
3139      "#, key = key),
3140    );
3141
3142    let err = plugin.match_field(creditcard_match_request(None, "4111111111111111", "4111111111111111"))
3143      .await
3144      .expect_err("expected an error when the target entry is not registered");
3145    assert!(
3146      err.to_string().contains("No catalogue entry found"),
3147      "unexpected error message: {}", err
3148    );
3149  }
3150
3151  struct FixedCoreFieldGenerator;
3152
3153  #[async_trait]
3154  impl crate::core_capabilities::CoreFieldGenerator for FixedCoreFieldGenerator {
3155    async fn generate_field(&self, _request: proto_v2::GenerateFieldRequest) -> anyhow::Result<proto_v2::GenerateFieldResponse> {
3156      Ok(proto_v2::GenerateFieldResponse {
3157        error: String::new(),
3158        value: Some(text_field("generated by the host"))
3159      })
3160    }
3161  }
3162
3163  #[tokio::test]
3164  async fn generate_field_calls_host_generate_field_for_a_registered_core_capability() {
3165    let key = "generate_field_calls_host_generate_field_for_a_registered_core_capability";
3166    crate::catalogue_manager::register_core_entries(&vec![core_field_generator_entry(key)]);
3167    crate::core_capabilities::register_core_field_generator(key, Arc::new(FixedCoreFieldGenerator));
3168
3169    let (_dir, plugin) = start_field_plugin(
3170      "host-generate-field-test",
3171      &format!(r#"
3172        function generate_field(request)
3173          return host_generate_field("{key}", request)
3174        end
3175      "#, key = key),
3176    );
3177
3178    let response = plugin
3179      .generate_field(creditcard_generate_request(Some("visa"), "4111111111111111"))
3180      .await
3181      .unwrap();
3182
3183    crate::core_capabilities::deregister_core_field_generator(key);
3184
3185    assert_eq!(response.error, "");
3186    assert_eq!(
3187      response.value.and_then(|value| value.value),
3188      Some(proto_v2::field_value::Value::StringValue("generated by the host".to_string()))
3189    );
3190  }
3191}