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
34use std::collections::HashMap;
35use std::fs::File;
36use std::io::Write;
37use std::path::{Path, PathBuf};
38use std::sync::{Arc, Mutex};
39
40use anyhow::anyhow;
41use async_trait::async_trait;
42use mlua::{Function, Lua, LuaSerdeExt, Table, Value, Variadic};
43use rsa::pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPublicKey, LineEnding};
44use rsa::{Pkcs1v15Sign, RsaPrivateKey, RsaPublicKey};
45use sha2::{Digest, Sha512};
46use tracing::debug;
47
48use crate::plugin_models::{
49  PactPluginManifest, PactPluginRpc, PluginInitRequest, PluginInitResponse, PluginInstance,
50};
51use crate::proto::*;
52use crate::proto_v2;
53use crate::utils::{proto_struct_to_json, proto_value_to_json, to_proto_struct, to_proto_value};
54
55/// A running Lua plugin instance. Each instance owns its own embedded Lua VM.
56pub struct LuaPactPlugin {
57  runtime: Arc<Mutex<Lua>>,
58  manifest: PactPluginManifest,
59  instance_id: String,
60  plugin_capabilities: Vec<String>,
61}
62
63impl std::fmt::Debug for LuaPactPlugin {
64  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65    f.debug_struct("LuaPactPlugin")
66      .field("manifest", &self.manifest)
67      .field("instance_id", &self.instance_id)
68      .field("plugin_capabilities", &self.plugin_capabilities)
69      .finish()
70  }
71}
72
73/// Start a Lua plugin: resolve the entry point script, create a Lua VM, register the host
74/// functions the plugin can call, and load (execute) the script.
75pub(crate) fn start_lua_plugin(
76  manifest: &PactPluginManifest,
77  instance_id: String,
78) -> anyhow::Result<LuaPactPlugin> {
79  let script_path = resolve_entry_point(manifest)?;
80  debug!("Loading Lua plugin {} from {:?}", manifest.name, script_path);
81
82  let log = Arc::new(LuaPluginLog::open(&manifest.name, &instance_id));
83  let lua = Lua::new();
84  set_package_path(&lua, &script_path)?;
85  add_luarocks_path(&lua, manifest)?;
86  register_host_functions(&lua, &manifest.name, &log)?;
87  load_script(&lua, &script_path)?;
88
89  Ok(LuaPactPlugin {
90    runtime: Arc::new(Mutex::new(lua)),
91    manifest: manifest.clone(),
92    instance_id,
93    plugin_capabilities: vec![],
94  })
95}
96
97impl LuaPactPlugin {
98  /// Set the capabilities negotiated for this plugin instance (called once, after the init
99  /// handshake, before the instance is shared behind an `Arc`).
100  pub(crate) fn set_plugin_capabilities(&mut self, capabilities: Vec<String>) {
101    self.plugin_capabilities = capabilities;
102  }
103}
104
105/// Captures a Lua plugin's diagnostic output (`print` and `logger()` calls) into the same
106/// per-instance log file a gRPC plugin's stderr is captured to -
107/// `<pact-dir>/logs/pact-plugin-<name>-<instance_id>.log` (see
108/// `child_process::open_plugin_log_file`) - so operators don't need to know which kind of
109/// plugin they're looking at to find its log. A Lua plugin runs embedded in the driver's own
110/// process, so without this its `print` output would otherwise go straight to the driver's
111/// own real stdout, mixed in with everything else.
112struct LuaPluginLog {
113  file: Mutex<Option<File>>,
114}
115
116impl LuaPluginLog {
117  fn open(plugin_name: &str, instance_id: &str) -> Self {
118    LuaPluginLog {
119      file: Mutex::new(crate::child_process::open_plugin_log_file(plugin_name, instance_id)),
120    }
121  }
122
123  fn write_line(&self, line: &str) {
124    if let Ok(mut guard) = self.file.lock()
125      && let Some(file) = guard.as_mut() {
126      let _ = writeln!(file, "{}", line);
127      let _ = file.flush();
128    }
129  }
130}
131
132fn resolve_entry_point(manifest: &PactPluginManifest) -> anyhow::Result<PathBuf> {
133  let entry_point = PathBuf::from(&manifest.entry_point);
134  let path = if entry_point.is_absolute() && entry_point.exists() {
135    entry_point
136  } else {
137    PathBuf::from(&manifest.plugin_dir).join(&manifest.entry_point)
138  };
139  if !path.exists() {
140    return Err(anyhow!("Lua plugin entry point {:?} does not exist", path));
141  }
142  Ok(path)
143}
144
145fn set_package_path(lua: &Lua, script_path: &Path) -> anyhow::Result<()> {
146  let script_dir = script_path.parent().unwrap_or_else(|| Path::new("."));
147  let package: Table = lua.globals().get("package")?;
148  let existing: String = package.get("path").unwrap_or_default();
149  let new_path = format!("{}/?.lua;{}", script_dir.to_string_lossy(), existing);
150  package.set("path", new_path)?;
151  Ok(())
152}
153
154/// The Lua version this driver embeds (fixed by mlua's `lua54` feature) - also the version
155/// segment LuaRocks uses in its per-version tree layout (e.g. `share/lua/5.4/`).
156const LUAROCKS_LUA_VERSION: &str = "5.4";
157
158/// Makes pure-Lua packages installed via `luarocks` available to `require`, so a plugin can
159/// depend on rocks instead of vendoring every third-party library it uses.
160///
161/// LuaRocks installs modules under `<rocks_dir>/share/lua/<version>/`, where `<rocks_dir>`
162/// defaults to `~/.luarocks` (its standard per-user tree) but can be a system tree or a
163/// custom prefix if the user configured LuaRocks differently. A plugin can override the
164/// directory this driver looks in via a `luaRocksDir` key in the manifest's `pluginConfig`.
165/// Only the `share/lua` (pure Lua) path is added - packages with compiled C extensions
166/// (under `lib/lua`) are not supported.
167fn add_luarocks_path(lua: &Lua, manifest: &PactPluginManifest) -> anyhow::Result<()> {
168  let configured = manifest.plugin_config.get("luaRocksDir").and_then(|v| v.as_str());
169  let rocks_dir = match configured {
170    Some(dir) => PathBuf::from(dir),
171    None => match home::home_dir() {
172      Some(home) => home.join(".luarocks"),
173      None => return Ok(()),
174    },
175  };
176
177  let lua_dir = rocks_dir.join("share").join("lua").join(LUAROCKS_LUA_VERSION);
178  if !lua_dir.exists() {
179    if configured.is_some() {
180      debug!(
181        "Configured luaRocksDir '{}' does not have a share/lua/{} directory, ignoring",
182        rocks_dir.display(), LUAROCKS_LUA_VERSION
183      );
184    }
185    return Ok(());
186  }
187
188  let package: Table = lua.globals().get("package")?;
189  let existing: String = package.get("path").unwrap_or_default();
190  let new_path = format!(
191    "{}/?.lua;{}/?/init.lua;{}",
192    lua_dir.to_string_lossy(), lua_dir.to_string_lossy(), existing
193  );
194  package.set("path", new_path)?;
195  debug!("Added LuaRocks path {:?} for plugin {}", lua_dir, manifest.name);
196  Ok(())
197}
198
199fn load_script(lua: &Lua, script_path: &Path) -> anyhow::Result<()> {
200  let script = std::fs::read_to_string(script_path)?;
201  lua
202    .load(script)
203    .set_name(script_path.to_string_lossy().to_string())
204    .exec()
205    .map_err(|err| anyhow!("Failed to load Lua plugin script {:?} - {}", script_path, err))?;
206  Ok(())
207}
208
209/// Registers the host (Rust) functions that a Lua plugin script can call: a logger, and the
210/// RSA/base64 primitives needed by the JWT plugin (Lua has no crypto standard library).
211fn register_host_functions(lua: &Lua, plugin_name: &str, log: &Arc<LuaPluginLog>) -> anyhow::Result<()> {
212  let globals = lua.globals();
213
214  let name = plugin_name.to_string();
215  let logger_log = log.clone();
216  globals.set(
217    "logger",
218    lua.create_function(move |_, message: String| {
219      debug!(plugin = name.as_str(), "{}", message);
220      logger_log.write_line(&message);
221      Ok(())
222    })?,
223  )?;
224
225  // Redirects Lua's built-in `print` (its "stdout") into the same per-instance log file, so
226  // it doesn't leak into the driver's own real stdout - see `LuaPluginLog`.
227  let print_log = log.clone();
228  globals.set(
229    "print",
230    lua.create_function(move |lua, args: Variadic<Value>| {
231      let tostring: Function = lua.globals().get("tostring")?;
232      let mut parts = Vec::with_capacity(args.len());
233      for arg in args.iter() {
234        parts.push(tostring.call::<String>(arg.clone())?);
235      }
236      print_log.write_line(&parts.join("\t"));
237      Ok(())
238    })?,
239  )?;
240
241  globals.set(
242    "rsa_sign",
243    lua.create_function(|_, (data, key): (mlua::String, String)| {
244      let private_key = RsaPrivateKey::from_pkcs1_pem(&key).map_err(mlua::Error::external)?;
245      let digest = Sha512::digest(data.as_bytes().as_ref());
246      let signature = private_key
247        .sign(Pkcs1v15Sign::new::<Sha512>(), &digest)
248        .map_err(mlua::Error::external)?;
249      Ok(base64::Engine::encode(
250        &base64::engine::general_purpose::URL_SAFE_NO_PAD,
251        signature,
252      ))
253    })?,
254  )?;
255
256  globals.set(
257    "rsa_public_key",
258    lua.create_function(|_, key: String| {
259      let private_key = RsaPrivateKey::from_pkcs1_pem(&key).map_err(mlua::Error::external)?;
260      let public_key = RsaPublicKey::from(&private_key);
261      let pem = public_key
262        .to_pkcs1_pem(LineEnding::LF)
263        .map_err(mlua::Error::external)?;
264      Ok(pem)
265    })?,
266  )?;
267
268  globals.set(
269    "rsa_validate",
270    lua.create_function(|_, (token_parts, algorithm, key): (Vec<String>, String, String)| {
271      if algorithm != "RS512" {
272        return Err(mlua::Error::RuntimeError(format!(
273          "Unsupported JWT algorithm '{}': only RS512 is supported",
274          algorithm
275        )));
276      }
277      if token_parts.len() != 3 {
278        return Err(mlua::Error::RuntimeError(
279          "Expected a 3 part JWT token (header, payload, signature)".to_string(),
280        ));
281      }
282
283      let public_key = match RsaPublicKey::from_pkcs1_pem(&key) {
284        Ok(key) => key,
285        Err(_) => return Ok(false),
286      };
287      let signature = match decode_base64_lenient(&token_parts[2]) {
288        Ok(bytes) => bytes,
289        Err(_) => return Ok(false),
290      };
291      let base_token = format!("{}.{}", token_parts[0], token_parts[1]);
292      let digest = Sha512::digest(base_token.as_bytes());
293      Ok(
294        public_key
295          .verify(Pkcs1v15Sign::new::<Sha512>(), &digest, &signature)
296          .is_ok(),
297      )
298    })?,
299  )?;
300
301  globals.set(
302    "b64_decode_no_pad",
303    lua.create_function(|lua, data: String| {
304      let bytes = decode_base64_lenient(&data).map_err(mlua::Error::external)?;
305      lua.create_string(&bytes)
306    })?,
307  )?;
308
309  Ok(())
310}
311
312/// Decode base64 (URL-safe), trying the padded then the un-padded alphabet.
313fn decode_base64_lenient(data: &str) -> anyhow::Result<Vec<u8>> {
314  use base64::Engine;
315  base64::engine::general_purpose::URL_SAFE
316    .decode(data)
317    .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(data))
318    .map_err(|err| anyhow!("Failed to base64 decode value - {}", err))
319}
320
321fn call_init(
322  lua: &Lua,
323  implementation: &str,
324  version: &str,
325) -> anyhow::Result<Vec<CatalogueEntry>> {
326  let init_fn: Function = lua
327    .globals()
328    .get("init")
329    .map_err(|_| anyhow!("Lua plugin does not define a global 'init' function"))?;
330  let result: Table = init_fn
331    .call((implementation.to_string(), version.to_string()))
332    .map_err(|err| anyhow!("Lua init() function failed - {}", err))?;
333  lua_table_to_catalogue_entries(result)
334}
335
336fn lua_table_to_catalogue_entries(table: Table) -> anyhow::Result<Vec<CatalogueEntry>> {
337  let mut entries = vec![];
338  for entry in table.sequence_values::<Table>() {
339    let entry = entry?;
340    let entry_type_str: String = entry.get("entryType")?;
341    let key: String = entry.get("key")?;
342    let values: Option<HashMap<String, String>> = entry.get("values")?;
343    let entry_type = catalogue_entry::EntryType::from_str_name(&entry_type_str)
344      .ok_or_else(|| anyhow!("Unknown catalogue entry type '{}'", entry_type_str))?;
345    entries.push(CatalogueEntry {
346      r#type: entry_type as i32,
347      key,
348      values: values.unwrap_or_default(),
349    });
350  }
351  Ok(entries)
352}
353
354// ---- Body <-> Lua ----
355
356fn content_type_hint_to_str(hint: i32) -> &'static str {
357  match body::ContentTypeHint::try_from(hint).unwrap_or(body::ContentTypeHint::Default) {
358    body::ContentTypeHint::Default => "DEFAULT",
359    body::ContentTypeHint::Text => "TEXT",
360    body::ContentTypeHint::Binary => "BINARY",
361  }
362}
363
364fn str_to_content_type_hint(hint: &str) -> i32 {
365  match hint {
366    "TEXT" => body::ContentTypeHint::Text as i32,
367    "BINARY" => body::ContentTypeHint::Binary as i32,
368    _ => body::ContentTypeHint::Default as i32,
369  }
370}
371
372fn body_to_lua(lua: &Lua, body: &Option<Body>) -> mlua::Result<Value> {
373  match body {
374    None => Ok(Value::Nil),
375    Some(body) => {
376      let table = lua.create_table()?;
377      table.set("content_type", body.content_type.clone())?;
378      match &body.content {
379        Some(bytes) => table.set("contents", lua.create_string(bytes)?)?,
380        None => table.set("contents", Value::Nil)?,
381      }
382      table.set("content_type_hint", content_type_hint_to_str(body.content_type_hint))?;
383      Ok(Value::Table(table))
384    }
385  }
386}
387
388fn lua_to_body(value: Value) -> anyhow::Result<Option<Body>> {
389  match value {
390    Value::Nil => Ok(None),
391    Value::Table(table) => {
392      let content_type: String = table.get("content_type")?;
393      let contents: Option<mlua::String> = table.get("contents")?;
394      let content_type_hint: Option<String> = table.get("content_type_hint")?;
395      Ok(Some(Body {
396        content_type,
397        content: contents.map(|s| s.as_bytes().to_vec()),
398        content_type_hint: content_type_hint
399          .map(|h| str_to_content_type_hint(&h))
400          .unwrap_or(body::ContentTypeHint::Default as i32),
401      }))
402    }
403    _ => Err(anyhow!("Expected a body table or nil from Lua, got {}", value.type_name())),
404  }
405}
406
407// ---- Matching rules / generators / plugin configuration <-> Lua ----
408
409fn matching_rules_to_lua(lua: &Lua, rules: &HashMap<String, MatchingRules>) -> mlua::Result<Table> {
410  let table = lua.create_table()?;
411  for (path, rule_list) in rules {
412    let rules_table = lua.create_table()?;
413    for rule in &rule_list.rule {
414      let rule_table = lua.create_table()?;
415      rule_table.set("type", rule.r#type.clone())?;
416      if let Some(values) = &rule.values {
417        rule_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
418      }
419      rules_table.push(rule_table)?;
420    }
421    table.set(path.clone(), rules_table)?;
422  }
423  Ok(table)
424}
425
426fn plugin_configuration_to_lua(lua: &Lua, config: &Option<PluginConfiguration>) -> mlua::Result<Value> {
427  match config {
428    None => Ok(Value::Nil),
429    Some(config) => {
430      let table = lua.create_table()?;
431      if let Some(interaction_configuration) = &config.interaction_configuration {
432        table.set(
433          "interaction_configuration",
434          lua.to_value(&proto_struct_to_json(interaction_configuration))?,
435        )?;
436      }
437      if let Some(pact_configuration) = &config.pact_configuration {
438        table.set(
439          "pact_configuration",
440          lua.to_value(&proto_struct_to_json(pact_configuration))?,
441        )?;
442      }
443      Ok(Value::Table(table))
444    }
445  }
446}
447
448fn lua_to_plugin_configuration(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<PluginConfiguration>> {
449  match value {
450    None | Some(Value::Nil) => Ok(None),
451    Some(Value::Table(table)) => {
452      let interaction_configuration: Option<serde_json::Value> =
453        table.get::<Option<Value>>("interaction_configuration")?
454          .map(|v| lua.from_value(v))
455          .transpose()?;
456      let pact_configuration: Option<serde_json::Value> =
457        table.get::<Option<Value>>("pact_configuration")?
458          .map(|v| lua.from_value(v))
459          .transpose()?;
460      Ok(Some(PluginConfiguration {
461        interaction_configuration: interaction_configuration.map(|v| to_proto_struct(&as_json_map(v))),
462        pact_configuration: pact_configuration.map(|v| to_proto_struct(&as_json_map(v))),
463      }))
464    }
465    Some(other) => Err(anyhow!("Expected a plugin_config table or nil from Lua, got {}", other.type_name())),
466  }
467}
468
469fn as_json_map(value: serde_json::Value) -> HashMap<String, serde_json::Value> {
470  match value {
471    serde_json::Value::Object(map) => map.into_iter().collect(),
472    _ => HashMap::new(),
473  }
474}
475
476// ---- CompareContents <-> Lua ----
477
478fn compare_request_to_lua(lua: &Lua, request: &CompareContentsRequest) -> mlua::Result<Table> {
479  let table = lua.create_table()?;
480  table.set("expected", body_to_lua(lua, &request.expected)?)?;
481  table.set("actual", body_to_lua(lua, &request.actual)?)?;
482  table.set("allow_unexpected_keys", request.allow_unexpected_keys)?;
483  table.set("rules", matching_rules_to_lua(lua, &request.rules)?)?;
484  table.set(
485    "plugin_configuration",
486    plugin_configuration_to_lua(lua, &request.plugin_configuration)?,
487  )?;
488  Ok(table)
489}
490
491fn lua_to_compare_response(table: Table) -> anyhow::Result<CompareContentsResponse> {
492  let error: Option<String> = table.get("error")?;
493  if let Some(error) = error {
494    return Ok(CompareContentsResponse {
495      error,
496      type_mismatch: None,
497      results: HashMap::new(),
498    });
499  }
500
501  let type_mismatch: Option<Table> = table.get("type-mismatch")?;
502  if let Some(type_mismatch) = type_mismatch {
503    let expected: String = type_mismatch.get("expected")?;
504    let actual: String = type_mismatch.get("actual")?;
505    return Ok(CompareContentsResponse {
506      error: String::new(),
507      type_mismatch: Some(ContentTypeMismatch { expected, actual }),
508      results: HashMap::new(),
509    });
510  }
511
512  let mismatches: Option<Table> = table.get("mismatches")?;
513  let mut results = HashMap::new();
514  if let Some(mismatches) = mismatches {
515    for pair in mismatches.pairs::<String, Value>() {
516      let (path, value) = pair?;
517      let mismatch_list = lua_value_to_content_mismatches(&path, value)?;
518      if !mismatch_list.is_empty() {
519        results.insert(path, ContentMismatches { mismatches: mismatch_list });
520      }
521    }
522  }
523
524  Ok(CompareContentsResponse {
525    error: String::new(),
526    type_mismatch: None,
527    results,
528  })
529}
530
531/// Stringifies a scalar Lua value (used for the `expected`/`actual` fields of a mismatch
532/// table), rather than requiring exactly a Lua string - a claim/header value being compared
533/// could just as easily be a number or boolean.
534fn lua_scalar_to_string(value: Value) -> anyhow::Result<Option<String>> {
535  match value {
536    Value::Nil => Ok(None),
537    Value::Boolean(b) => Ok(Some(b.to_string())),
538    Value::Integer(i) => Ok(Some(i.to_string())),
539    Value::Number(n) => Ok(Some(n.to_string())),
540    Value::String(s) => Ok(Some(s.to_str()?.to_string())),
541    other => Err(anyhow!("Expected a scalar mismatch value from Lua, got {}", other.type_name())),
542  }
543}
544
545fn lua_value_to_content_mismatches(path: &str, value: Value) -> anyhow::Result<Vec<ContentMismatch>> {
546  match value {
547    Value::String(s) => Ok(vec![ContentMismatch {
548      expected: None,
549      actual: None,
550      mismatch: s.to_str()?.to_string(),
551      path: path.to_string(),
552      diff: String::new(),
553      mismatch_type: String::new(),
554    }]),
555    Value::Table(table) => {
556      // Either a single mismatch table ({mismatch=..., expected=..., ...}), or a sequence of them / plain strings
557      let mismatch_field: Option<String> = table.get("mismatch")?;
558      if let Some(mismatch) = mismatch_field {
559        // expected/actual can reasonably be non-string Lua values (e.g. a numeric or boolean
560        // claim value), so stringify whatever's there rather than requiring exactly a string.
561        let expected = lua_scalar_to_string(table.get("expected")?)?;
562        let actual = lua_scalar_to_string(table.get("actual")?)?;
563        let path_override: Option<String> = table.get("path")?;
564        let diff: Option<String> = table.get("diff")?;
565        let mismatch_type: Option<String> = table.get("mismatch_type")?;
566        Ok(vec![ContentMismatch {
567          expected: expected.map(|s| s.into_bytes()),
568          actual: actual.map(|s| s.into_bytes()),
569          mismatch,
570          path: path_override.unwrap_or_else(|| path.to_string()),
571          diff: diff.unwrap_or_default(),
572          mismatch_type: mismatch_type.unwrap_or_default(),
573        }])
574      } else {
575        let mut result = vec![];
576        for entry in table.sequence_values::<Value>() {
577          result.extend(lua_value_to_content_mismatches(path, entry?)?);
578        }
579        Ok(result)
580      }
581    }
582    Value::Nil => Ok(vec![]),
583    other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
584  }
585}
586
587// ---- ConfigureInteraction <-> Lua ----
588
589/// Converts a single Lua interaction-contents table (shaped as
590/// `{ contents = <body>, part_name = "...", plugin_config = <table> }`) into an
591/// `InteractionResponse`.
592fn lua_to_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<InteractionResponse> {
593  let contents: Option<Value> = table.get("contents")?;
594  let body = match contents {
595    Some(value) => lua_to_body(value)?,
596    None => None,
597  };
598  let plugin_config: Option<Value> = table.get("plugin_config")?;
599  let part_name: Option<String> = table.get("part_name")?;
600  Ok(InteractionResponse {
601    contents: body,
602    rules: HashMap::new(),
603    generators: HashMap::new(),
604    message_metadata: None,
605    plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
606    interaction_markup: String::new(),
607    interaction_markup_type: 0,
608    part_name: part_name.unwrap_or_default(),
609    metadata_rules: HashMap::new(),
610    metadata_generators: HashMap::new(),
611  })
612}
613
614/// Converts the table returned by the Lua `configure_interaction` function, shaped as
615/// `{ interactions = { { contents = <body>, part_name = "..." }, ... }, plugin_config = <table> }`,
616/// into a `ConfigureInteractionResponse`. `interactions` is always a sequence, even when there
617/// is only one interaction (as is the case for a plain body content-matcher like JWT).
618fn lua_to_configure_response(lua: &Lua, table: Table) -> anyhow::Result<ConfigureInteractionResponse> {
619  let mut interactions = vec![];
620  let items: Option<Table> = table.get("interactions")?;
621  if let Some(items) = items {
622    for entry in items.sequence_values::<Table>() {
623      interactions.push(lua_to_interaction_response(lua, entry?)?);
624    }
625  }
626
627  let plugin_config: Option<Value> = table.get("plugin_config")?;
628  Ok(ConfigureInteractionResponse {
629    error: String::new(),
630    interaction: interactions,
631    plugin_configuration: lua_to_plugin_configuration(lua, plugin_config)?,
632  })
633}
634
635// ---- TRANSPORT plugin support: mock server / verification <-> Lua ----
636
637/// Converts a `google.protobuf.Struct` to a plain Lua value, `nil` if not set.
638fn struct_to_lua(lua: &Lua, value: &Option<prost_types::Struct>) -> mlua::Result<Value> {
639  match value {
640    Some(value) => lua.to_value(&proto_struct_to_json(value)),
641    None => Ok(Value::Nil),
642  }
643}
644
645/// Converts V2 `InteractionContents` (structured per-interaction data sent in place of a whole
646/// Pact JSON document) into a Lua table shaped as
647/// `{ interaction_type, consumer, provider, plugin_configuration = { interaction_configuration, pact_configuration } }`.
648fn interaction_contents_to_lua(lua: &Lua, contents: &proto_v2::InteractionContents) -> mlua::Result<Table> {
649  let table = lua.create_table()?;
650  table.set("interaction_type", contents.interaction_type.clone())?;
651  table.set("consumer", contents.consumer.clone())?;
652  table.set("provider", contents.provider.clone())?;
653  if let Some(plugin_configuration) = &contents.plugin_configuration {
654    let config_table = lua.create_table()?;
655    if let Some(interaction_configuration) = &plugin_configuration.interaction_configuration {
656      config_table.set("interaction_configuration", lua.to_value(&proto_struct_to_json(interaction_configuration))?)?;
657    }
658    if let Some(pact_configuration) = &plugin_configuration.pact_configuration {
659      config_table.set("pact_configuration", lua.to_value(&proto_struct_to_json(pact_configuration))?)?;
660    }
661    table.set("plugin_configuration", config_table)?;
662  }
663  Ok(table)
664}
665
666/// V1 `InteractionData` and V2 `InteractionData` are structurally identical (same wire format);
667/// converting via an encode/decode round trip lets the rest of this module deal with a single
668/// (V1) type, matching the approach `plugin_manager.rs` uses in the other direction (see
669/// `to_proto_v2_interaction_data`). Returns an error rather than panicking if the round trip
670/// ever fails (it shouldn't, given the identical wire format, but this data originates from a
671/// caller-supplied gRPC request, so a decode failure should be a recoverable error, not a
672/// crash).
673fn v2_interaction_data_to_v1(data: &proto_v2::InteractionData) -> anyhow::Result<InteractionData> {
674  use prost::Message;
675  InteractionData::decode(data.encode_to_vec().as_slice())
676    .map_err(|err| anyhow!("Failed to convert V2 InteractionData to V1 - {}", err))
677}
678
679/// Converts request/response metadata to a Lua table. Each value is either a plain Lua value
680/// (JSON-like, for a non-binary `MetadataValue`) or a `{ binary = <lua string> }` wrapper table
681/// (for a binary `MetadataValue`), so a Lua script can tell the two apart.
682fn metadata_to_lua(lua: &Lua, metadata: &HashMap<String, MetadataValue>) -> mlua::Result<Table> {
683  let table = lua.create_table()?;
684  for (key, value) in metadata {
685    let lua_value = match &value.value {
686      Some(metadata_value::Value::NonBinaryValue(value)) => lua.to_value(&proto_value_to_json(value))?,
687      Some(metadata_value::Value::BinaryValue(bytes)) => {
688        let wrapper = lua.create_table()?;
689        wrapper.set("binary", lua.create_string(bytes)?)?;
690        Value::Table(wrapper)
691      }
692      None => Value::Nil,
693    };
694    table.set(key.clone(), lua_value)?;
695  }
696  Ok(table)
697}
698
699/// Converts a Lua metadata table (see [`metadata_to_lua`]) back into `MetadataValue`s.
700fn lua_to_metadata(lua: &Lua, table: Option<Table>) -> anyhow::Result<HashMap<String, MetadataValue>> {
701  let mut metadata = HashMap::new();
702  if let Some(table) = table {
703    for pair in table.pairs::<String, Value>() {
704      let (key, value) = pair?;
705      let binary: Option<mlua::String> = match &value {
706        Value::Table(wrapper) => wrapper.get("binary")?,
707        _ => None,
708      };
709      let metadata_value = if let Some(binary) = binary {
710        metadata_value::Value::BinaryValue(binary.as_bytes().to_vec())
711      } else {
712        let json: serde_json::Value = lua.from_value(value)?;
713        metadata_value::Value::NonBinaryValue(to_proto_value(&json))
714      };
715      metadata.insert(key, MetadataValue { value: Some(metadata_value) });
716    }
717  }
718  Ok(metadata)
719}
720
721/// Converts `InteractionData` (a request/response body plus metadata) to a Lua table shaped as
722/// `{ body = <body table>, metadata = <metadata table> }`, or `nil` if not set.
723fn interaction_data_to_lua(lua: &Lua, data: &Option<InteractionData>) -> mlua::Result<Value> {
724  match data {
725    None => Ok(Value::Nil),
726    Some(data) => {
727      let table = lua.create_table()?;
728      table.set("body", body_to_lua(lua, &data.body)?)?;
729      table.set("metadata", metadata_to_lua(lua, &data.metadata)?)?;
730      Ok(Value::Table(table))
731    }
732  }
733}
734
735/// Converts a Lua interaction-data table (see [`interaction_data_to_lua`]) back into
736/// `InteractionData`, or `None` if the Lua value was `nil`.
737fn lua_to_interaction_data(lua: &Lua, value: Option<Value>) -> anyhow::Result<Option<InteractionData>> {
738  match value {
739    None | Some(Value::Nil) => Ok(None),
740    Some(Value::Table(table)) => {
741      let body: Option<Value> = table.get("body")?;
742      let body = match body {
743        Some(value) => lua_to_body(value)?,
744        None => None,
745      };
746      let metadata_table: Option<Table> = table.get("metadata")?;
747      Ok(Some(InteractionData {
748        body,
749        metadata: lua_to_metadata(lua, metadata_table)?,
750      }))
751    }
752    Some(other) => Err(anyhow!("Expected an interaction data table or nil from Lua, got {}", other.type_name())),
753  }
754}
755
756/// Converts the table returned by the Lua `start_mock_server` function, shaped as either
757/// `{ error = "..." }` or `{ details = { key, port, address } }`, into a `StartMockServerResponse`.
758fn lua_to_start_mock_server_response(table: Table) -> anyhow::Result<StartMockServerResponse> {
759  let error: Option<String> = table.get("error")?;
760  if let Some(error) = error {
761    return Ok(StartMockServerResponse {
762      response: Some(start_mock_server_response::Response::Error(error)),
763    });
764  }
765
766  let details: Option<Table> = table.get("details")?;
767  let details = details.ok_or_else(|| {
768    anyhow!("Lua start_mock_server() must return either an 'error' or 'details' field")
769  })?;
770  Ok(StartMockServerResponse {
771    response: Some(start_mock_server_response::Response::Details(MockServerDetails {
772      key: details.get("key")?,
773      port: details.get("port")?,
774      address: details.get("address")?,
775    })),
776  })
777}
778
779/// Converts the table returned by the Lua `shutdown_mock_server`/`get_mock_server_results`
780/// functions, shaped as `{ ok = bool, results = { { path, error, mismatches = { ... } }, ... } }`,
781/// into `MockServerResults`. Reuses [`lua_value_to_content_mismatches`] for each result's
782/// `mismatches` field, the same helper `match_contents` responses use.
783fn lua_to_mock_server_results(table: Table) -> anyhow::Result<MockServerResults> {
784  let ok: bool = table.get::<Option<bool>>("ok")?.unwrap_or(true);
785  let mut results = vec![];
786  let results_table: Option<Table> = table.get("results")?;
787  if let Some(results_table) = results_table {
788    for entry in results_table.sequence_values::<Table>() {
789      let entry = entry?;
790      let path: String = entry.get::<Option<String>>("path")?.unwrap_or_default();
791      let error: String = entry.get::<Option<String>>("error")?.unwrap_or_default();
792      let mismatches_value: Value = entry.get("mismatches")?;
793      results.push(MockServerResult {
794        path: path.clone(),
795        error,
796        mismatches: lua_value_to_content_mismatches(&path, mismatches_value)?,
797      });
798    }
799  }
800  Ok(MockServerResults { ok, results })
801}
802
803/// Converts the table returned by the Lua `prepare_interaction_for_verification` function,
804/// shaped as either `{ error = "..." }` or `{ interaction_data = { body, metadata } }`, into a
805/// `VerificationPreparationResponse`.
806fn lua_to_verification_preparation_response(
807  lua: &Lua,
808  table: Table,
809) -> anyhow::Result<VerificationPreparationResponse> {
810  let error: Option<String> = table.get("error")?;
811  if let Some(error) = error {
812    return Ok(VerificationPreparationResponse {
813      response: Some(verification_preparation_response::Response::Error(error)),
814    });
815  }
816
817  let data: Option<Value> = table.get("interaction_data")?;
818  let data = data.ok_or_else(|| {
819    anyhow!("Lua prepare_interaction_for_verification() must return either an 'error' or 'interaction_data' field")
820  })?;
821  let interaction_data = lua_to_interaction_data(lua, Some(data))?
822    .unwrap_or_else(|| InteractionData { body: None, metadata: HashMap::new() });
823  Ok(VerificationPreparationResponse {
824    response: Some(verification_preparation_response::Response::InteractionData(interaction_data)),
825  })
826}
827
828/// Converts a single Lua verification mismatch (a plain error string, or a mismatch table shaped
829/// like a `match_contents` mismatch) into a `VerificationResultItem`.
830fn lua_to_verification_result_item(value: Value) -> anyhow::Result<VerificationResultItem> {
831  match value {
832    Value::String(s) => Ok(VerificationResultItem {
833      result: Some(verification_result_item::Result::Error(s.to_str()?.to_string())),
834    }),
835    Value::Table(table) => {
836      let mismatch: Option<String> = table.get("mismatch")?;
837      let path: Option<String> = table.get("path")?;
838      let expected = lua_scalar_to_string(table.get("expected")?)?;
839      let actual = lua_scalar_to_string(table.get("actual")?)?;
840      let diff: Option<String> = table.get("diff")?;
841      let mismatch_type: Option<String> = table.get("mismatch_type")?;
842      Ok(VerificationResultItem {
843        result: Some(verification_result_item::Result::Mismatch(ContentMismatch {
844          expected: expected.map(|s| s.into_bytes()),
845          actual: actual.map(|s| s.into_bytes()),
846          mismatch: mismatch.unwrap_or_default(),
847          path: path.unwrap_or_default(),
848          diff: diff.unwrap_or_default(),
849          mismatch_type: mismatch_type.unwrap_or_default(),
850        })),
851      })
852    }
853    other => Err(anyhow!("Expected a mismatch string or table from Lua, got {}", other.type_name())),
854  }
855}
856
857/// Converts the table returned by the Lua `verify_interaction` function, shaped as either
858/// `{ error = "..." }` or
859/// `{ result = { success, response_data, mismatches = { ... }, output = { ... } } }`, into a
860/// `VerifyInteractionResponse`.
861fn lua_to_verify_interaction_response(lua: &Lua, table: Table) -> anyhow::Result<VerifyInteractionResponse> {
862  let error: Option<String> = table.get("error")?;
863  if let Some(error) = error {
864    return Ok(VerifyInteractionResponse {
865      response: Some(verify_interaction_response::Response::Error(error)),
866    });
867  }
868
869  let result_table: Option<Table> = table.get("result")?;
870  let result_table = result_table
871    .ok_or_else(|| anyhow!("Lua verify_interaction() must return either an 'error' or 'result' field"))?;
872
873  let success: bool = result_table.get::<Option<bool>>("success")?.unwrap_or(false);
874  let response_data: Option<Value> = result_table.get("response_data")?;
875  let response_data = lua_to_interaction_data(lua, response_data)?;
876
877  let mut mismatches = vec![];
878  let mismatches_value: Option<Value> = result_table.get("mismatches")?;
879  if let Some(Value::Table(mismatches_table)) = mismatches_value {
880    for entry in mismatches_table.sequence_values::<Value>() {
881      mismatches.push(lua_to_verification_result_item(entry?)?);
882    }
883  }
884
885  let output: Option<Vec<String>> = result_table.get("output")?;
886
887  Ok(VerifyInteractionResponse {
888    response: Some(verify_interaction_response::Response::Result(VerificationResult {
889      success,
890      response_data,
891      mismatches,
892      output: output.unwrap_or_default(),
893    })),
894  })
895}
896
897#[async_trait]
898impl PactPluginRpc for LuaPactPlugin {
899  async fn init_plugin(&mut self, request: PluginInitRequest) -> anyhow::Result<PluginInitResponse> {
900    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
901    let catalogue = call_init(&lua, &request.implementation, &request.version)?;
902    Ok(PluginInitResponse {
903      catalogue,
904      plugin_capabilities: vec![],
905    })
906  }
907}
908
909#[async_trait]
910impl PluginInstance for LuaPactPlugin {
911  fn manifest(&self) -> &PactPluginManifest {
912    &self.manifest
913  }
914
915  fn instance_id(&self) -> &str {
916    &self.instance_id
917  }
918
919  fn has_capability(&self, capability: &str) -> bool {
920    self.plugin_capabilities.iter().any(|c| c == capability)
921  }
922
923  async fn compare_contents(
924    &self,
925    request: CompareContentsRequest,
926  ) -> anyhow::Result<CompareContentsResponse> {
927    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
928    let match_fn: Function = lua
929      .globals()
930      .get("match_contents")
931      .map_err(|_| anyhow!("Lua plugin does not define a global 'match_contents' function"))?;
932    let request_table = compare_request_to_lua(&lua, &request)?;
933    let result: Table = match_fn
934      .call(request_table)
935      .map_err(|err| anyhow!("Lua match_contents() function failed - {}", err))?;
936    lua_to_compare_response(result)
937  }
938
939  async fn configure_interaction(
940    &self,
941    request: ConfigureInteractionRequest,
942  ) -> anyhow::Result<ConfigureInteractionResponse> {
943    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
944    let configure_fn: Function = lua
945      .globals()
946      .get("configure_interaction")
947      .map_err(|_| anyhow!("Lua plugin does not define a global 'configure_interaction' function"))?;
948    let config: Value = match &request.contents_config {
949      Some(config) => lua.to_value(&proto_struct_to_json(config))?,
950      None => Value::Nil,
951    };
952    let result: Table = configure_fn
953      .call((request.content_type.clone(), config))
954      .map_err(|err| anyhow!("Lua configure_interaction() function failed - {}", err))?;
955    lua_to_configure_response(&lua, result)
956  }
957
958  async fn generate_content(
959    &self,
960    request: GenerateContentRequest,
961  ) -> anyhow::Result<GenerateContentResponse> {
962    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
963    let generate_fn: Option<Function> = lua.globals().get("generate_content")?;
964    match generate_fn {
965      None => Ok(GenerateContentResponse {
966        contents: request.contents,
967      }),
968      Some(generate_fn) => {
969        let contents = body_to_lua(&lua, &request.contents)?;
970        let generators = lua.create_table()?;
971        for (path, generator) in &request.generators {
972          let generator_table = lua.create_table()?;
973          generator_table.set("type", generator.r#type.clone())?;
974          if let Some(values) = &generator.values {
975            generator_table.set("values", lua.to_value(&proto_struct_to_json(values))?)?;
976          }
977          generators.set(path.clone(), generator_table)?;
978        }
979        let test_mode = match generate_content_request::TestMode::try_from(request.test_mode)
980          .unwrap_or(generate_content_request::TestMode::Unknown)
981        {
982          generate_content_request::TestMode::Consumer => "Consumer",
983          generate_content_request::TestMode::Provider => "Provider",
984          generate_content_request::TestMode::Unknown => "Unknown",
985        };
986        let result: Value = generate_fn
987          .call((contents, generators, test_mode))
988          .map_err(|err| anyhow!("Lua generate_content() function failed - {}", err))?;
989        Ok(GenerateContentResponse {
990          contents: lua_to_body(result)?,
991        })
992      }
993    }
994  }
995
996  async fn start_mock_server(
997    &self,
998    request: StartMockServerRequest,
999  ) -> anyhow::Result<StartMockServerResponse> {
1000    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1001    let start_fn: Function = lua
1002      .globals()
1003      .get("start_mock_server")
1004      .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1005    let request_table = lua.create_table()?;
1006    request_table.set("host_interface", request.host_interface)?;
1007    request_table.set("port", request.port)?;
1008    request_table.set("tls", request.tls)?;
1009    request_table.set("pact", request.pact)?;
1010    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1011    let result: Table = start_fn
1012      .call(request_table)
1013      .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1014    lua_to_start_mock_server_response(result)
1015  }
1016
1017  async fn start_mock_server_v2(
1018    &self,
1019    request: proto_v2::StartMockServerRequest,
1020  ) -> anyhow::Result<StartMockServerResponse> {
1021    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1022    let start_fn: Function = lua
1023      .globals()
1024      .get("start_mock_server")
1025      .map_err(|_| anyhow!("Lua plugin does not define a global 'start_mock_server' function"))?;
1026    let request_table = lua.create_table()?;
1027    request_table.set("host_interface", request.host_interface)?;
1028    request_table.set("port", request.port)?;
1029    request_table.set("tls", request.tls)?;
1030    let interactions_table = lua.create_table()?;
1031    for interaction in &request.interactions {
1032      interactions_table.push(interaction_contents_to_lua(&lua, interaction)?)?;
1033    }
1034    request_table.set("interactions", interactions_table)?;
1035    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1036    let result: Table = start_fn
1037      .call(request_table)
1038      .map_err(|err| anyhow!("Lua start_mock_server() function failed - {}", err))?;
1039    lua_to_start_mock_server_response(result)
1040  }
1041
1042  async fn shutdown_mock_server(
1043    &self,
1044    request: ShutdownMockServerRequest,
1045  ) -> anyhow::Result<ShutdownMockServerResponse> {
1046    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1047    let shutdown_fn: Function = lua
1048      .globals()
1049      .get("shutdown_mock_server")
1050      .map_err(|_| anyhow!("Lua plugin does not define a global 'shutdown_mock_server' function"))?;
1051    let result: Table = shutdown_fn
1052      .call(request.server_key)
1053      .map_err(|err| anyhow!("Lua shutdown_mock_server() function failed - {}", err))?;
1054    let results = lua_to_mock_server_results(result)?;
1055    Ok(ShutdownMockServerResponse {
1056      ok: results.ok,
1057      results: results.results,
1058    })
1059  }
1060
1061  async fn get_mock_server_results(
1062    &self,
1063    request: MockServerRequest,
1064  ) -> anyhow::Result<MockServerResults> {
1065    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1066    let results_fn: Function = lua
1067      .globals()
1068      .get("get_mock_server_results")
1069      .map_err(|_| anyhow!("Lua plugin does not define a global 'get_mock_server_results' function"))?;
1070    let result: Table = results_fn
1071      .call(request.server_key)
1072      .map_err(|err| anyhow!("Lua get_mock_server_results() function failed - {}", err))?;
1073    lua_to_mock_server_results(result)
1074  }
1075
1076  async fn prepare_interaction_for_verification(
1077    &self,
1078    request: VerificationPreparationRequest,
1079  ) -> anyhow::Result<VerificationPreparationResponse> {
1080    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1081    let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1082      anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1083    })?;
1084    let request_table = lua.create_table()?;
1085    request_table.set("pact", request.pact)?;
1086    request_table.set("interaction_key", request.interaction_key)?;
1087    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1088    let result: Table = prepare_fn
1089      .call(request_table)
1090      .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1091    lua_to_verification_preparation_response(&lua, result)
1092  }
1093
1094  async fn prepare_interaction_for_verification_v2(
1095    &self,
1096    request: proto_v2::VerificationPreparationRequest,
1097  ) -> anyhow::Result<VerificationPreparationResponse> {
1098    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1099    let prepare_fn: Function = lua.globals().get("prepare_interaction_for_verification").map_err(|_| {
1100      anyhow!("Lua plugin does not define a global 'prepare_interaction_for_verification' function")
1101    })?;
1102    let request_table = lua.create_table()?;
1103    if let Some(interaction_contents) = &request.interaction_contents {
1104      request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1105    }
1106    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1107    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1108    let result: Table = prepare_fn
1109      .call(request_table)
1110      .map_err(|err| anyhow!("Lua prepare_interaction_for_verification() function failed - {}", err))?;
1111    lua_to_verification_preparation_response(&lua, result)
1112  }
1113
1114  async fn verify_interaction(
1115    &self,
1116    request: VerifyInteractionRequest,
1117  ) -> anyhow::Result<VerifyInteractionResponse> {
1118    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1119    let verify_fn: Function = lua
1120      .globals()
1121      .get("verify_interaction")
1122      .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1123    let request_table = lua.create_table()?;
1124    request_table.set("interaction_data", interaction_data_to_lua(&lua, &request.interaction_data)?)?;
1125    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1126    request_table.set("pact", request.pact)?;
1127    request_table.set("interaction_key", request.interaction_key)?;
1128    let result: Table = verify_fn
1129      .call(request_table)
1130      .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1131    lua_to_verify_interaction_response(&lua, result)
1132  }
1133
1134  async fn verify_interaction_v2(
1135    &self,
1136    request: proto_v2::VerifyInteractionRequest,
1137  ) -> anyhow::Result<VerifyInteractionResponse> {
1138    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1139    let verify_fn: Function = lua
1140      .globals()
1141      .get("verify_interaction")
1142      .map_err(|_| anyhow!("Lua plugin does not define a global 'verify_interaction' function"))?;
1143    let request_table = lua.create_table()?;
1144    let interaction_data = request.interaction_data.as_ref()
1145      .map(v2_interaction_data_to_v1)
1146      .transpose()?;
1147    request_table.set("interaction_data", interaction_data_to_lua(&lua, &interaction_data)?)?;
1148    request_table.set("config", struct_to_lua(&lua, &request.config)?)?;
1149    if let Some(interaction_contents) = &request.interaction_contents {
1150      request_table.set("interaction_contents", interaction_contents_to_lua(&lua, interaction_contents)?)?;
1151    }
1152    request_table.set("test_context", struct_to_lua(&lua, &request.test_context)?)?;
1153    let result: Table = verify_fn
1154      .call(request_table)
1155      .map_err(|err| anyhow!("Lua verify_interaction() function failed - {}", err))?;
1156    lua_to_verify_interaction_response(&lua, result)
1157  }
1158
1159  async fn update_catalogue(&self, request: Catalogue) -> anyhow::Result<()> {
1160    let lua = self.runtime.lock().map_err(|_| anyhow!("Lua runtime mutex was poisoned"))?;
1161    let update_fn: Option<Function> = lua.globals().get("update_catalogue")?;
1162    if let Some(update_fn) = update_fn {
1163      let table = lua.create_table()?;
1164      for entry in &request.catalogue {
1165        let entry_table = lua.create_table()?;
1166        let entry_type = catalogue_entry::EntryType::try_from(entry.r#type)
1167          .unwrap_or(catalogue_entry::EntryType::ContentMatcher);
1168        entry_table.set("entryType", entry_type.as_str_name())?;
1169        entry_table.set("key", entry.key.clone())?;
1170        entry_table.set("values", entry.values.clone())?;
1171        table.push(entry_table)?;
1172      }
1173      update_fn
1174        .call::<()>(table)
1175        .map_err(|err| anyhow!("Lua update_catalogue() function failed - {}", err))?;
1176    }
1177    Ok(())
1178  }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183  use std::collections::HashMap;
1184
1185  use super::*;
1186
1187  fn jwt_manifest() -> PactPluginManifest {
1188    // Deliberately not `.canonicalize()`d: on Windows that returns a `\\?\`-prefixed verbatim
1189    // path, and the forward slashes `set_package_path`/`add_luarocks_path` append to build
1190    // Lua's `package.path` aren't auto-translated to `\` under that prefix (unlike a normal
1191    // path), breaking `require` for every sibling .lua file. A plain absolute path (with
1192    // unresolved `..` components) resolves fine without it.
1193    let plugin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../plugins/jwt");
1194    assert!(plugin_dir.exists(), "plugins/jwt directory should exist at {:?}", plugin_dir);
1195    PactPluginManifest {
1196      plugin_dir: plugin_dir.to_string_lossy().to_string(),
1197      plugin_interface_version: 1,
1198      name: "jwt".to_string(),
1199      version: "0.0.0".to_string(),
1200      executable_type: "lua".to_string(),
1201      minimum_required_version: None,
1202      entry_point: "plugin.lua".to_string(),
1203      entry_points: HashMap::new(),
1204      args: None,
1205      dependencies: None,
1206      plugin_config: HashMap::new(),
1207    }
1208  }
1209
1210  const PRIVATE_KEY: &str = include_str!("../tests/fixtures/jwt-test-key.pem");
1211
1212  #[test]
1213  fn loads_pure_lua_packages_from_a_configured_luarocks_directory() {
1214    let rocks_root = tempdir::TempDir::new("luarocks-test").unwrap();
1215    let lua_dir = rocks_root.path().join("share").join("lua").join(LUAROCKS_LUA_VERSION);
1216    std::fs::create_dir_all(&lua_dir).unwrap();
1217    std::fs::write(
1218      lua_dir.join("greeter.lua"),
1219      r#"return { hello = function() return "hello from luarocks" end }"#,
1220    ).unwrap();
1221
1222    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1223    std::fs::write(
1224      plugin_dir.path().join("entry.lua"),
1225      r#"
1226        local greeter = require "greeter"
1227        GREETER_RESULT = greeter.hello()
1228      "#,
1229    ).unwrap();
1230
1231    let mut plugin_config = HashMap::new();
1232    plugin_config.insert(
1233      "luaRocksDir".to_string(),
1234      serde_json::Value::String(rocks_root.path().to_string_lossy().to_string()),
1235    );
1236
1237    let manifest = PactPluginManifest {
1238      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1239      plugin_interface_version: 1,
1240      name: "luarocks-test".to_string(),
1241      version: "0.0.0".to_string(),
1242      executable_type: "lua".to_string(),
1243      minimum_required_version: None,
1244      entry_point: "entry.lua".to_string(),
1245      entry_points: HashMap::new(),
1246      args: None,
1247      dependencies: None,
1248      plugin_config,
1249    };
1250
1251    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1252    let lua = plugin.runtime.lock().unwrap();
1253    let result: String = lua.globals().get("GREETER_RESULT").unwrap();
1254    assert_eq!(result, "hello from luarocks");
1255  }
1256
1257  #[test]
1258  fn ignores_a_missing_luarocks_directory_instead_of_failing() {
1259    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1260    std::fs::write(plugin_dir.path().join("entry.lua"), "-- no-op").unwrap();
1261
1262    let mut plugin_config = HashMap::new();
1263    plugin_config.insert(
1264      "luaRocksDir".to_string(),
1265      serde_json::Value::String("/no/such/directory".to_string()),
1266    );
1267
1268    let manifest = PactPluginManifest {
1269      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1270      plugin_interface_version: 1,
1271      name: "luarocks-test".to_string(),
1272      version: "0.0.0".to_string(),
1273      executable_type: "lua".to_string(),
1274      minimum_required_version: None,
1275      entry_point: "entry.lua".to_string(),
1276      entry_points: HashMap::new(),
1277      args: None,
1278      dependencies: None,
1279      plugin_config,
1280    };
1281
1282    start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1283  }
1284
1285  #[test]
1286  fn captures_print_and_logger_output_into_the_per_instance_log_file() {
1287    let output_dir = tempdir::TempDir::new("lua-plugin-log-test").unwrap();
1288    // SAFETY: no other test reads/writes PACT_OUTPUT_DIR; matches existing test conventions
1289    // in this crate for env-var-configured global state (see plugin_manager.rs tests).
1290    unsafe { std::env::set_var("PACT_OUTPUT_DIR", output_dir.path()); }
1291
1292    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1293    std::fs::write(
1294      plugin_dir.path().join("entry.lua"),
1295      r#"
1296        print("hello", "world", 42)
1297        logger("a logger message")
1298      "#,
1299    ).unwrap();
1300
1301    let manifest = PactPluginManifest {
1302      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1303      plugin_interface_version: 1,
1304      name: "log-test".to_string(),
1305      version: "0.0.0".to_string(),
1306      executable_type: "lua".to_string(),
1307      minimum_required_version: None,
1308      entry_point: "entry.lua".to_string(),
1309      entry_points: HashMap::new(),
1310      args: None,
1311      dependencies: None,
1312      plugin_config: HashMap::new(),
1313    };
1314
1315    start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1316    unsafe { std::env::remove_var("PACT_OUTPUT_DIR"); }
1317
1318    let log_path = output_dir.path().join("logs").join("pact-plugin-log-test-test-instance.log");
1319    let contents = std::fs::read_to_string(&log_path)
1320      .unwrap_or_else(|err| panic!("Expected a log file at {:?} - {}", log_path, err));
1321    assert_eq!(contents, "hello\tworld\t42\na logger message\n");
1322  }
1323
1324  #[tokio::test]
1325  async fn loads_the_jwt_plugin_and_runs_the_init_function() {
1326    let manifest = jwt_manifest();
1327    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1328    let lua = plugin.runtime.lock().unwrap();
1329    let entries = call_init(&lua, "test", "0.0.0").unwrap();
1330    assert_eq!(entries.len(), 2);
1331    assert_eq!(entries[0].key, "jwt");
1332    assert_eq!(entries[0].r#type, catalogue_entry::EntryType::ContentMatcher as i32);
1333    assert_eq!(entries[1].r#type, catalogue_entry::EntryType::ContentGenerator as i32);
1334  }
1335
1336  #[tokio::test]
1337  async fn configure_interaction_then_match_contents_round_trip() {
1338    let manifest = jwt_manifest();
1339    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1340
1341    let mut config_fields = HashMap::new();
1342    config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
1343    config_fields.insert("subject".to_string(), serde_json::Value::String("test-subject".to_string()));
1344    config_fields.insert("issuer".to_string(), serde_json::Value::String("test-issuer".to_string()));
1345    config_fields.insert("audience".to_string(), serde_json::Value::String("test-audience".to_string()));
1346    config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
1347
1348    let configure_request = ConfigureInteractionRequest {
1349      content_type: "application/jwt+json".to_string(),
1350      contents_config: Some(to_proto_struct(&config_fields)),
1351    };
1352    let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
1353    assert_eq!(configure_response.error, "");
1354    assert_eq!(configure_response.interaction.len(), 1);
1355
1356    let interaction = &configure_response.interaction[0];
1357    let body = interaction.contents.clone().expect("expected a body");
1358    assert_eq!(body.content_type, "application/jwt+json");
1359    let token = String::from_utf8(body.content.clone().unwrap()).unwrap();
1360    assert_eq!(token.split('.').count(), 3);
1361
1362    let compare_request = CompareContentsRequest {
1363      expected: Some(body.clone()),
1364      actual: Some(body),
1365      allow_unexpected_keys: false,
1366      rules: HashMap::new(),
1367      plugin_configuration: interaction.plugin_configuration.clone(),
1368    };
1369    let compare_response = plugin.compare_contents(compare_request).await.unwrap();
1370    assert_eq!(compare_response.error, "");
1371    assert!(compare_response.type_mismatch.is_none());
1372    assert!(
1373      compare_response.results.is_empty(),
1374      "expected no mismatches, got {:?}",
1375      compare_response.results
1376    );
1377  }
1378
1379  #[tokio::test]
1380  async fn match_contents_detects_a_tampered_token() {
1381    let manifest = jwt_manifest();
1382    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1383
1384    let mut config_fields = HashMap::new();
1385    config_fields.insert("private-key".to_string(), serde_json::Value::String(PRIVATE_KEY.to_string()));
1386    config_fields.insert("algorithm".to_string(), serde_json::Value::String("RS512".to_string()));
1387
1388    let configure_request = ConfigureInteractionRequest {
1389      content_type: "application/jwt+json".to_string(),
1390      contents_config: Some(to_proto_struct(&config_fields)),
1391    };
1392    let configure_response = plugin.configure_interaction(configure_request).await.unwrap();
1393    let interaction = &configure_response.interaction[0];
1394    let expected_body = interaction.contents.clone().unwrap();
1395
1396    let mut actual_body = expected_body.clone();
1397    let mut token = String::from_utf8(actual_body.content.clone().unwrap()).unwrap();
1398    token.push('x'); // tamper with the signature
1399    actual_body.content = Some(token.into_bytes());
1400
1401    let compare_request = CompareContentsRequest {
1402      expected: Some(expected_body),
1403      actual: Some(actual_body),
1404      allow_unexpected_keys: false,
1405      rules: HashMap::new(),
1406      plugin_configuration: interaction.plugin_configuration.clone(),
1407    };
1408    let compare_response = plugin.compare_contents(compare_request).await.unwrap();
1409    assert!(!compare_response.results.is_empty(), "expected a mismatch to be detected");
1410  }
1411
1412  #[tokio::test]
1413  async fn compare_contents_handles_non_string_mismatch_values() {
1414    let plugin_dir = tempdir::TempDir::new("lua-plugin-test").unwrap();
1415    std::fs::write(
1416      plugin_dir.path().join("entry.lua"),
1417      r#"
1418        function match_contents(request)
1419          return {
1420            mismatches = {
1421              ["claims:exp"] = { expected = 123, actual = 456, mismatch = "exp differs", path = "claims:exp" },
1422              ["claims:verified"] = { expected = true, actual = false, mismatch = "verified differs", path = "claims:verified" }
1423            }
1424          }
1425        end
1426      "#,
1427    ).unwrap();
1428
1429    let manifest = PactPluginManifest {
1430      plugin_dir: plugin_dir.path().to_string_lossy().to_string(),
1431      plugin_interface_version: 1,
1432      name: "scalar-mismatch-test".to_string(),
1433      version: "0.0.0".to_string(),
1434      executable_type: "lua".to_string(),
1435      minimum_required_version: None,
1436      entry_point: "entry.lua".to_string(),
1437      entry_points: HashMap::new(),
1438      args: None,
1439      dependencies: None,
1440      plugin_config: HashMap::new(),
1441    };
1442    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1443
1444    let compare_request = CompareContentsRequest {
1445      expected: None,
1446      actual: None,
1447      allow_unexpected_keys: false,
1448      rules: HashMap::new(),
1449      plugin_configuration: None,
1450    };
1451    let response = plugin.compare_contents(compare_request).await.unwrap();
1452
1453    let exp_mismatch = &response.results["claims:exp"].mismatches[0];
1454    assert_eq!(exp_mismatch.expected.as_deref(), Some("123".as_bytes()));
1455    assert_eq!(exp_mismatch.actual.as_deref(), Some("456".as_bytes()));
1456
1457    let verified_mismatch = &response.results["claims:verified"].mismatches[0];
1458    assert_eq!(verified_mismatch.expected.as_deref(), Some("true".as_bytes()));
1459    assert_eq!(verified_mismatch.actual.as_deref(), Some("false".as_bytes()));
1460  }
1461
1462  fn transport_manifest(plugin_dir: &std::path::Path, plugin_interface_version: u8) -> PactPluginManifest {
1463    PactPluginManifest {
1464      plugin_dir: plugin_dir.to_string_lossy().to_string(),
1465      plugin_interface_version,
1466      name: "transport-test".to_string(),
1467      version: "0.0.0".to_string(),
1468      executable_type: "lua".to_string(),
1469      minimum_required_version: None,
1470      entry_point: "entry.lua".to_string(),
1471      entry_points: HashMap::new(),
1472      args: None,
1473      dependencies: None,
1474      plugin_config: HashMap::new(),
1475    }
1476  }
1477
1478  const TRANSPORT_PLUGIN_SCRIPT: &str = r#"
1479    function start_mock_server(request)
1480      START_MOCK_SERVER_REQUEST = request
1481      if request.port == 0 then
1482        return { error = "could not bind a mock server" }
1483      end
1484      return { details = { key = "mock-server-1", port = 12345, address = "127.0.0.1:12345" } }
1485    end
1486
1487    function shutdown_mock_server(server_key)
1488      SHUTDOWN_SERVER_KEY = server_key
1489      return {
1490        ok = false,
1491        results = { { path = "/foo", error = "did not match", mismatches = { "simple string mismatch" } } }
1492      }
1493    end
1494
1495    function get_mock_server_results(server_key)
1496      GET_RESULTS_SERVER_KEY = server_key
1497      return { ok = true, results = {} }
1498    end
1499
1500    function prepare_interaction_for_verification(request)
1501      PREPARE_REQUEST = request
1502      return {
1503        interaction_data = {
1504          body = { content_type = "application/json", contents = "prepared-body", content_type_hint = "TEXT" },
1505          metadata = { path = "/foo", tag = { binary = "raw-bytes" } }
1506        }
1507      }
1508    end
1509
1510    function verify_interaction(request)
1511      VERIFY_REQUEST = request
1512      if request.config ~= nil and request.config.fail == true then
1513        return { error = "verification failed" }
1514      end
1515      return {
1516        result = {
1517          success = true,
1518          response_data = { body = { content_type = "application/json", contents = "response-body" }, metadata = {} },
1519          mismatches = { "a plain mismatch", { mismatch = "a table mismatch", path = "$.foo", expected = 1, actual = 2 } },
1520          output = { "POST /foo", "200 OK" }
1521        }
1522      }
1523    end
1524  "#;
1525
1526  fn start_transport_plugin(plugin_interface_version: u8) -> LuaPactPlugin {
1527    let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
1528    std::fs::write(plugin_dir.path().join("entry.lua"), TRANSPORT_PLUGIN_SCRIPT).unwrap();
1529    let manifest = transport_manifest(plugin_dir.path(), plugin_interface_version);
1530    // The script is fully read into the Lua VM by `start_lua_plugin`, so the tempdir doesn't
1531    // need to outlive this call.
1532    start_lua_plugin(&manifest, "test-instance".to_string()).unwrap()
1533  }
1534
1535  #[tokio::test]
1536  async fn start_mock_server_v1_round_trip() {
1537    let plugin = start_transport_plugin(1);
1538    let response = plugin.start_mock_server(StartMockServerRequest {
1539      host_interface: "127.0.0.1".to_string(),
1540      port: 8080,
1541      tls: false,
1542      pact: "{\"consumer\":{}}".to_string(),
1543      test_context: None,
1544    }).await.unwrap();
1545    match response.response.unwrap() {
1546      start_mock_server_response::Response::Details(details) => {
1547        assert_eq!(details.key, "mock-server-1");
1548        assert_eq!(details.port, 12345);
1549        assert_eq!(details.address, "127.0.0.1:12345");
1550      }
1551      other => panic!("expected mock server details, got {:?}", other),
1552    }
1553  }
1554
1555  #[tokio::test]
1556  async fn start_mock_server_v1_returns_the_lua_error() {
1557    let plugin = start_transport_plugin(1);
1558    let response = plugin.start_mock_server(StartMockServerRequest {
1559      host_interface: "127.0.0.1".to_string(),
1560      port: 0,
1561      tls: false,
1562      pact: "{}".to_string(),
1563      test_context: None,
1564    }).await.unwrap();
1565    match response.response.unwrap() {
1566      start_mock_server_response::Response::Error(err) => assert_eq!(err, "could not bind a mock server"),
1567      other => panic!("expected an error response, got {:?}", other),
1568    }
1569  }
1570
1571  #[tokio::test]
1572  async fn start_mock_server_v2_passes_structured_interactions() {
1573    let plugin = start_transport_plugin(2);
1574    let request = proto_v2::StartMockServerRequest {
1575      host_interface: "127.0.0.1".to_string(),
1576      port: 8080,
1577      tls: false,
1578      interactions: vec![proto_v2::InteractionContents {
1579        interaction_type: "Synchronous/HTTP".to_string(),
1580        plugin_configuration: None,
1581        consumer: "test-consumer".to_string(),
1582        provider: "test-provider".to_string(),
1583      }],
1584      test_context: None,
1585    };
1586    let response = plugin.start_mock_server_v2(request).await.unwrap();
1587    assert!(matches!(response.response.unwrap(), start_mock_server_response::Response::Details(_)));
1588
1589    let lua = plugin.runtime.lock().unwrap();
1590    let captured: Table = lua.globals().get("START_MOCK_SERVER_REQUEST").unwrap();
1591    let interactions: Table = captured.get("interactions").unwrap();
1592    let first: Table = interactions.get(1).unwrap();
1593    assert_eq!(first.get::<String>("interaction_type").unwrap(), "Synchronous/HTTP");
1594    assert_eq!(first.get::<String>("consumer").unwrap(), "test-consumer");
1595  }
1596
1597  #[tokio::test]
1598  async fn shutdown_and_get_mock_server_results_parse_mismatches() {
1599    let plugin = start_transport_plugin(1);
1600
1601    let shutdown_response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
1602      server_key: "mock-server-1".to_string(),
1603    }).await.unwrap();
1604    assert!(!shutdown_response.ok);
1605    assert_eq!(shutdown_response.results.len(), 1);
1606    assert_eq!(shutdown_response.results[0].path, "/foo");
1607    assert_eq!(shutdown_response.results[0].mismatches[0].mismatch, "simple string mismatch");
1608
1609    let results_response = plugin.get_mock_server_results(MockServerRequest {
1610      server_key: "mock-server-1".to_string(),
1611    }).await.unwrap();
1612    assert!(results_response.ok);
1613    assert!(results_response.results.is_empty());
1614  }
1615
1616  #[tokio::test]
1617  async fn prepare_interaction_for_verification_v1_round_trip() {
1618    let plugin = start_transport_plugin(1);
1619    let response = plugin.prepare_interaction_for_verification(VerificationPreparationRequest {
1620      pact: "{}".to_string(),
1621      interaction_key: "interaction-1".to_string(),
1622      config: None,
1623    }).await.unwrap();
1624
1625    match response.response.unwrap() {
1626      verification_preparation_response::Response::InteractionData(data) => {
1627        let body = data.body.unwrap();
1628        assert_eq!(body.content, Some("prepared-body".as_bytes().to_vec()));
1629        let metadata = data.metadata;
1630        assert!(matches!(
1631          metadata["path"].value,
1632          Some(metadata_value::Value::NonBinaryValue(_))
1633        ));
1634        match &metadata["tag"].value {
1635          Some(metadata_value::Value::BinaryValue(bytes)) => assert_eq!(bytes, b"raw-bytes"),
1636          other => panic!("expected a binary metadata value, got {:?}", other),
1637        }
1638      }
1639      other => panic!("expected interaction data, got {:?}", other),
1640    }
1641  }
1642
1643  #[tokio::test]
1644  async fn prepare_interaction_for_verification_v2_passes_interaction_contents() {
1645    let plugin = start_transport_plugin(2);
1646    let request = proto_v2::VerificationPreparationRequest {
1647      interaction_contents: Some(proto_v2::InteractionContents {
1648        interaction_type: "Synchronous/HTTP".to_string(),
1649        plugin_configuration: None,
1650        consumer: "test-consumer".to_string(),
1651        provider: "test-provider".to_string(),
1652      }),
1653      config: None,
1654      test_context: None,
1655    };
1656    let response = plugin.prepare_interaction_for_verification_v2(request).await.unwrap();
1657    assert!(matches!(
1658      response.response.unwrap(),
1659      verification_preparation_response::Response::InteractionData(_)
1660    ));
1661
1662    let lua = plugin.runtime.lock().unwrap();
1663    let captured: Table = lua.globals().get("PREPARE_REQUEST").unwrap();
1664    let interaction_contents: Table = captured.get("interaction_contents").unwrap();
1665    assert_eq!(interaction_contents.get::<String>("provider").unwrap(), "test-provider");
1666  }
1667
1668  #[tokio::test]
1669  async fn verify_interaction_v1_round_trip() {
1670    let plugin = start_transport_plugin(1);
1671    let mut metadata = HashMap::new();
1672    metadata.insert("path".to_string(), MetadataValue {
1673      value: Some(metadata_value::Value::NonBinaryValue(prost_types::Value {
1674        kind: Some(prost_types::value::Kind::StringValue("/foo".to_string())),
1675      })),
1676    });
1677    let response = plugin.verify_interaction(VerifyInteractionRequest {
1678      interaction_data: Some(InteractionData {
1679        body: Some(Body {
1680          content_type: "application/json".to_string(),
1681          content: Some("request-body".as_bytes().to_vec()),
1682          content_type_hint: body::ContentTypeHint::Text as i32,
1683        }),
1684        metadata,
1685      }),
1686      config: None,
1687      pact: "{}".to_string(),
1688      interaction_key: "interaction-1".to_string(),
1689    }).await.unwrap();
1690
1691    match response.response.unwrap() {
1692      verify_interaction_response::Response::Result(result) => {
1693        assert!(result.success);
1694        assert_eq!(result.output, vec!["POST /foo".to_string(), "200 OK".to_string()]);
1695        assert_eq!(result.mismatches.len(), 2);
1696        match &result.mismatches[0].result {
1697          Some(verification_result_item::Result::Error(err)) => assert_eq!(err, "a plain mismatch"),
1698          other => panic!("expected an error mismatch, got {:?}", other),
1699        }
1700        match &result.mismatches[1].result {
1701          Some(verification_result_item::Result::Mismatch(mismatch)) => {
1702            assert_eq!(mismatch.mismatch, "a table mismatch");
1703            assert_eq!(mismatch.expected, Some(b"1".to_vec()));
1704          }
1705          other => panic!("expected a mismatch, got {:?}", other),
1706        }
1707      }
1708      other => panic!("expected a verification result, got {:?}", other),
1709    }
1710
1711    let lua = plugin.runtime.lock().unwrap();
1712    let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
1713    let interaction_data: Table = captured.get("interaction_data").unwrap();
1714    let metadata: Table = interaction_data.get("metadata").unwrap();
1715    assert_eq!(metadata.get::<String>("path").unwrap(), "/foo");
1716  }
1717
1718  #[tokio::test]
1719  async fn verify_interaction_v1_returns_the_lua_error() {
1720    let plugin = start_transport_plugin(1);
1721    let mut config = HashMap::new();
1722    config.insert("fail".to_string(), serde_json::Value::Bool(true));
1723    let response = plugin.verify_interaction(VerifyInteractionRequest {
1724      interaction_data: None,
1725      config: Some(to_proto_struct(&config)),
1726      pact: "{}".to_string(),
1727      interaction_key: "interaction-1".to_string(),
1728    }).await.unwrap();
1729    match response.response.unwrap() {
1730      verify_interaction_response::Response::Error(err) => assert_eq!(err, "verification failed"),
1731      other => panic!("expected an error response, got {:?}", other),
1732    }
1733  }
1734
1735  #[tokio::test]
1736  async fn verify_interaction_v2_converts_the_v2_interaction_data_and_contents() {
1737    let plugin = start_transport_plugin(2);
1738    let request = proto_v2::VerifyInteractionRequest {
1739      interaction_data: Some(proto_v2::InteractionData {
1740        body: Some(proto_v2::Body {
1741          content_type: "application/json".to_string(),
1742          content: Some("request-body".as_bytes().to_vec()),
1743          content_type_hint: 0,
1744        }),
1745        metadata: HashMap::new(),
1746      }),
1747      config: None,
1748      interaction_contents: Some(proto_v2::InteractionContents {
1749        interaction_type: "Synchronous/HTTP".to_string(),
1750        plugin_configuration: None,
1751        consumer: "test-consumer".to_string(),
1752        provider: "test-provider".to_string(),
1753      }),
1754      test_context: None,
1755    };
1756    let response = plugin.verify_interaction_v2(request).await.unwrap();
1757    assert!(matches!(
1758      response.response.unwrap(),
1759      verify_interaction_response::Response::Result(_)
1760    ));
1761
1762    let lua = plugin.runtime.lock().unwrap();
1763    let captured: Table = lua.globals().get("VERIFY_REQUEST").unwrap();
1764    let interaction_data: Table = captured.get("interaction_data").unwrap();
1765    let body: Table = interaction_data.get("body").unwrap();
1766    assert_eq!(body.get::<mlua::String>("contents").unwrap().to_str().unwrap(), "request-body");
1767    let interaction_contents: Table = captured.get("interaction_contents").unwrap();
1768    assert_eq!(interaction_contents.get::<String>("consumer").unwrap(), "test-consumer");
1769  }
1770
1771  #[tokio::test]
1772  async fn shutdown_mock_server_defaults_ok_to_true_when_the_field_is_absent() {
1773    // Regression test: `Table::get::<bool>("ok")` converts a *missing* key's Lua nil straight
1774    // to `false` (mlua's bool conversion, matching Lua's own nil-is-falsy semantics) rather than
1775    // erroring - so a plain `.unwrap_or(true)` fallback was never reached, and an `ok`-less
1776    // response used to silently report `ok = false` instead of the documented default of
1777    // `true`. Reading as `Option<bool>` first lets a missing key correctly fall through to the
1778    // `unwrap_or(true)` default, since `Option<T>` intercepts Lua nil before the inner
1779    // conversion happens.
1780    let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
1781    std::fs::write(
1782      plugin_dir.path().join("entry.lua"),
1783      r#"
1784        function shutdown_mock_server(server_key)
1785          return { results = {} }
1786        end
1787      "#,
1788    ).unwrap();
1789    let manifest = transport_manifest(plugin_dir.path(), 1);
1790    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1791
1792    let response = plugin.shutdown_mock_server(ShutdownMockServerRequest {
1793      server_key: "mock-server-1".to_string(),
1794    }).await.unwrap();
1795    assert!(response.ok, "expected 'ok' to default to true when the Lua script doesn't set it");
1796  }
1797
1798  #[tokio::test]
1799  async fn shutdown_mock_server_errors_on_a_wrong_typed_path_field() {
1800    let plugin_dir = tempdir::TempDir::new("lua-transport-plugin-test").unwrap();
1801    std::fs::write(
1802      plugin_dir.path().join("entry.lua"),
1803      r#"
1804        function shutdown_mock_server(server_key)
1805          return { ok = false, results = { { path = {}, error = "boom", mismatches = {} } } }
1806        end
1807      "#,
1808    ).unwrap();
1809    let manifest = transport_manifest(plugin_dir.path(), 1);
1810    let plugin = start_lua_plugin(&manifest, "test-instance".to_string()).unwrap();
1811
1812    let result = plugin.shutdown_mock_server(ShutdownMockServerRequest {
1813      server_key: "mock-server-1".to_string(),
1814    }).await;
1815    assert!(
1816      result.is_err(),
1817      "expected a wrong-typed 'path' field (a table, not a string) to be a hard error, not silently default, got {:?}",
1818      result
1819    );
1820  }
1821}