1use 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
55pub 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
73pub(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 pub(crate) fn set_plugin_capabilities(&mut self, capabilities: Vec<String>) {
101 self.plugin_capabilities = capabilities;
102 }
103}
104
105struct 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
154const LUAROCKS_LUA_VERSION: &str = "5.4";
157
158fn 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
209fn 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 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
312fn 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
354fn 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
407fn 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
476fn 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
531fn 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 let mismatch_field: Option<String> = table.get("mismatch")?;
558 if let Some(mismatch) = mismatch_field {
559 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
587fn 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
614fn 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
635fn 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
645fn 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
666fn 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
679fn 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
699fn 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
721fn 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
735fn 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
756fn 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
779fn 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
803fn 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
828fn 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
857fn 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 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 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'); 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 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 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}