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