1#![forbid(unsafe_code)]
33
34use std::{
35 collections::HashMap,
36 path::{Path, PathBuf},
37 sync::{Arc, Mutex},
38};
39
40use regex::{Regex, RegexBuilder};
41use serde::{Deserialize, Serialize};
42
43pub trait Tamper: Send + Sync {
51 fn name(&self) -> &str;
53
54 fn apply(&self, input: &str) -> String;
56
57 fn manifest(&self) -> TamperManifest;
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct TamperManifest {
71 pub name: String,
73 pub version: String,
75 pub author: String,
77 pub payload_classes: Vec<String>,
79 pub contexts: Vec<String>,
82 pub description: String,
84}
85
86impl TamperManifest {
87 pub fn validate(&self) -> Result<(), PluginError> {
92 if self.name.is_empty() {
93 return Err(PluginError::InvalidManifest(
94 "name must not be empty".into(),
95 ));
96 }
97 if !self
98 .name
99 .chars()
100 .all(|c| c.is_ascii_alphanumeric() || c == '_')
101 {
102 return Err(PluginError::InvalidManifest(format!(
103 "name '{}' must contain only ASCII alphanumeric characters and underscores",
104 self.name
105 )));
106 }
107 if self.version.is_empty() {
108 return Err(PluginError::InvalidManifest(
109 "version must not be empty".into(),
110 ));
111 }
112 if self.author.is_empty() {
113 return Err(PluginError::InvalidManifest(
114 "author must not be empty".into(),
115 ));
116 }
117 if self.description.len() > 512 {
118 return Err(PluginError::InvalidManifest(format!(
119 "description exceeds 512 chars ({} chars)",
120 self.description.len()
121 )));
122 }
123 Ok(())
124 }
125}
126
127#[derive(Debug, thiserror::Error)]
133pub enum PluginError {
134 #[error("Invalid manifest: {0}")]
136 InvalidManifest(String),
137
138 #[error("Name collision: plugin '{0}' is already registered")]
140 NameCollision(String),
141
142 #[error("TOML parse error in {file}: {cause}")]
144 TomlParse { file: PathBuf, cause: String },
145
146 #[error("Invalid regex '{pattern}' in {file}: {cause}")]
148 InvalidRegex {
149 file: PathBuf,
150 pattern: String,
151 cause: String,
152 },
153
154 #[error("WASM load error in {file}: {cause}")]
156 WasmLoad { file: PathBuf, cause: String },
157
158 #[error("WASM sandbox violation in '{plugin}': {detail}")]
160 WasmSandboxViolation { plugin: String, detail: String },
161
162 #[error("WASM fuel exhausted in '{plugin}' after {fuel} instructions")]
164 WasmFuelExhausted { plugin: String, fuel: u64 },
165
166 #[error("I/O error: {0}")]
168 Io(#[from] std::io::Error),
169}
170
171#[derive(Debug, Deserialize)]
192struct TomlPluginFile {
193 manifest: TomlManifest,
194 #[serde(default)]
199 rules: Vec<TomlRule>,
200}
201
202#[derive(Debug, Deserialize)]
203struct TomlManifest {
204 name: String,
205 version: String,
206 author: String,
207 #[serde(default)]
208 payload_classes: Vec<String>,
209 #[serde(default)]
210 contexts: Vec<String>,
211 description: String,
212}
213
214#[derive(Debug, Deserialize)]
216struct TomlRule {
217 pattern: String,
219 replacement: String,
222}
223
224struct TomlTamper {
229 manifest: TamperManifest,
230 rules: Vec<(Regex, String)>,
232}
233
234impl Tamper for TomlTamper {
235 fn name(&self) -> &str {
236 &self.manifest.name
237 }
238
239 fn apply(&self, input: &str) -> String {
240 let mut result = input.to_owned();
241 for (re, replacement) in &self.rules {
242 if replacement == "$REVERSED" {
243 result = re
244 .replace_all(&result, |caps: ®ex::Captures<'_>| {
245 caps[0].chars().rev().collect::<String>()
246 })
247 .into_owned();
248 } else {
249 result = re.replace_all(&result, replacement.as_str()).into_owned();
250 }
251 }
252 result
253 }
254
255 fn manifest(&self) -> TamperManifest {
256 self.manifest.clone()
257 }
258}
259
260const TOML_MAX_BYTES: u64 = 256 * 1024;
262
263fn read_capped_file(path: &Path, max_bytes: u64) -> Result<Vec<u8>, std::io::Error> {
269 use std::io::Read;
270 let f = std::fs::File::open(path)?;
271 let mut limited = f.take(max_bytes + 1);
272 let mut buf = Vec::with_capacity(8 * 1024);
273 limited.read_to_end(&mut buf)?;
274 if (buf.len() as u64) > max_bytes {
275 return Err(std::io::Error::new(
276 std::io::ErrorKind::InvalidData,
277 format!(
278 "{}: file exceeds {}-byte cap (>{} bytes observed)",
279 path.display(),
280 max_bytes,
281 max_bytes,
282 ),
283 ));
284 }
285 Ok(buf)
286}
287
288fn load_toml_plugin(path: &Path) -> Result<Box<dyn Tamper>, PluginError> {
289 let raw = read_capped_file(path, TOML_MAX_BYTES).map_err(|e| {
290 PluginError::InvalidManifest(format!(
291 "{}: failed to read manifest ({}, max {} bytes)",
292 path.display(),
293 e,
294 TOML_MAX_BYTES,
295 ))
296 })?;
297 let content = String::from_utf8(raw).map_err(|e| {
298 PluginError::InvalidManifest(format!("{}: not valid UTF-8: {e}", path.display()))
299 })?;
300 let parsed: TomlPluginFile = toml::from_str(&content).map_err(|e| PluginError::TomlParse {
301 file: path.to_owned(),
302 cause: e.to_string(),
303 })?;
304
305 let manifest = TamperManifest {
306 name: parsed.manifest.name,
307 version: parsed.manifest.version,
308 author: parsed.manifest.author,
309 payload_classes: parsed.manifest.payload_classes,
310 contexts: parsed.manifest.contexts,
311 description: parsed.manifest.description,
312 };
313 manifest.validate()?;
314
315 const PLUGIN_REGEX_SIZE_LIMIT: usize = 1024 * 1024;
322 let mut compiled_rules = Vec::with_capacity(parsed.rules.len());
323 for rule in &parsed.rules {
324 let re = RegexBuilder::new(&rule.pattern)
325 .size_limit(PLUGIN_REGEX_SIZE_LIMIT)
326 .build()
327 .map_err(|e| PluginError::InvalidRegex {
328 file: path.to_owned(),
329 pattern: rule.pattern.clone(),
330 cause: e.to_string(),
331 })?;
332 compiled_rules.push((re, rule.replacement.clone()));
333 }
334
335 Ok(Box::new(TomlTamper {
336 manifest,
337 rules: compiled_rules,
338 }))
339}
340
341const WASM_FUEL_PER_CALL: u64 = 1_000_000;
347
348const WASM_MAX_BYTES: u64 = 4 * 1024 * 1024;
350
351struct WasmTamper {
352 manifest: TamperManifest,
353 store_module: Arc<Mutex<WasmRuntime>>,
356}
357
358struct WasmRuntime {
359 store: wasmtime::Store<()>,
360 memory: wasmtime::Memory,
361 tamper_fn: wasmtime::TypedFunc<(i32, i32), i64>,
362 alloc_fn: wasmtime::TypedFunc<i32, i32>,
363 dealloc_fn: Option<wasmtime::TypedFunc<(i32, i32), ()>>,
364}
365
366impl WasmRuntime {
367 fn call_tamper(&mut self, input: &str) -> Option<String> {
374 let alloc_fn = self.alloc_fn.clone();
376 let tamper_fn = self.tamper_fn.clone();
377 let dealloc_fn = self.dealloc_fn.clone();
378 let memory = self.memory;
379
380 self.store.set_fuel(WASM_FUEL_PER_CALL).ok()?;
381
382 let bytes = input.as_bytes();
383 let len = bytes.len() as i32;
384
385 let ptr = alloc_fn.call(&mut self.store, len).ok()?;
387
388 memory.write(&mut self.store, ptr as usize, bytes).ok()?;
390
391 let result_packed = tamper_fn.call(&mut self.store, (ptr, len)).ok()?;
393
394 if let Some(ref dealloc) = dealloc_fn {
396 dealloc.call(&mut self.store, (ptr, len)).ok();
397 }
398
399 let result_ptr = ((result_packed >> 32) & 0xFFFF_FFFF) as usize;
401 let result_len = (result_packed & 0xFFFF_FFFF) as usize;
402
403 let mem_size = memory.data_size(&self.store);
412 if result_ptr.saturating_add(result_len) > mem_size {
413 return None; }
415
416 let mut out = vec![0u8; result_len];
417 memory.read(&self.store, result_ptr, &mut out).ok()?;
418
419 if let Some(ref dealloc) = dealloc_fn {
421 dealloc
422 .call(&mut self.store, (result_ptr as i32, result_len as i32))
423 .ok();
424 }
425
426 String::from_utf8(out).ok()
427 }
428}
429
430impl Tamper for WasmTamper {
431 fn name(&self) -> &str {
432 &self.manifest.name
433 }
434
435 fn apply(&self, input: &str) -> String {
436 let mut rt = match self.store_module.lock() {
437 Ok(g) => g,
438 Err(_) => return input.to_owned(), };
440 rt.call_tamper(input).unwrap_or_else(|| input.to_owned())
441 }
442
443 fn manifest(&self) -> TamperManifest {
444 self.manifest.clone()
445 }
446}
447
448#[derive(Deserialize)]
451struct WasmEmbeddedManifest {
452 name: String,
453 version: String,
454 author: String,
455 #[serde(default)]
456 payload_classes: Vec<String>,
457 #[serde(default)]
458 contexts: Vec<String>,
459 description: String,
460}
461
462fn load_wasm_plugin(path: &Path) -> Result<Box<dyn Tamper>, PluginError> {
463 let wasm_bytes = read_capped_file(path, WASM_MAX_BYTES).map_err(|e| {
464 PluginError::InvalidManifest(format!(
465 "{}: failed to read WASM ({}, max {} bytes)",
466 path.display(),
467 e,
468 WASM_MAX_BYTES,
469 ))
470 })?;
471
472 let mut config = wasmtime::Config::new();
474 config.consume_fuel(true);
475 config.memory_guard_size(0);
477 config.max_wasm_stack(512 * 1024); let engine = wasmtime::Engine::new(&config).map_err(|e| PluginError::WasmLoad {
482 file: path.to_owned(),
483 cause: format!("engine creation failed: {e}"),
484 })?;
485
486 let manifest = extract_wasm_manifest(&wasm_bytes, path)?;
488
489 let module =
490 wasmtime::Module::new(&engine, &wasm_bytes).map_err(|e| PluginError::WasmLoad {
491 file: path.to_owned(),
492 cause: format!("module compilation failed: {e}"),
493 })?;
494
495 let linker: wasmtime::Linker<()> = wasmtime::Linker::new(&engine);
497
498 let mut store = wasmtime::Store::new(&engine, ());
499 store.set_fuel(WASM_FUEL_PER_CALL).ok();
500
501 let instance = linker
502 .instantiate(&mut store, &module)
503 .map_err(|e| PluginError::WasmLoad {
504 file: path.to_owned(),
505 cause: format!("instantiation failed (module may import disallowed symbols): {e}"),
506 })?;
507
508 let memory =
509 instance
510 .get_memory(&mut store, "memory")
511 .ok_or_else(|| PluginError::WasmLoad {
512 file: path.to_owned(),
513 cause: "module must export a 'memory' with name 'memory'".into(),
514 })?;
515
516 let tamper_fn: wasmtime::TypedFunc<(i32, i32), i64> = instance
517 .get_typed_func(&mut store, "tamper")
518 .map_err(|e| PluginError::WasmLoad {
519 file: path.to_owned(),
520 cause: format!("missing export 'tamper(i32,i32)->i64': {e}"),
521 })?;
522
523 let alloc_fn: wasmtime::TypedFunc<i32, i32> = instance
524 .get_typed_func(&mut store, "alloc")
525 .map_err(|e| PluginError::WasmLoad {
526 file: path.to_owned(),
527 cause: format!("missing export 'alloc(i32)->i32': {e}"),
528 })?;
529
530 let dealloc_fn: Option<wasmtime::TypedFunc<(i32, i32), ()>> =
531 instance.get_typed_func(&mut store, "dealloc").ok();
532
533 let runtime = WasmRuntime {
534 store,
535 memory,
536 tamper_fn,
537 alloc_fn,
538 dealloc_fn,
539 };
540
541 Ok(Box::new(WasmTamper {
542 manifest,
543 store_module: Arc::new(Mutex::new(runtime)),
544 }))
545}
546
547fn extract_wasm_manifest(wasm_bytes: &[u8], path: &Path) -> Result<TamperManifest, PluginError> {
549 if wasm_bytes.len() < 8 {
552 return Err(PluginError::WasmLoad {
553 file: path.to_owned(),
554 cause: "not a valid WASM binary (too short)".into(),
555 });
556 }
557
558 let magic = &wasm_bytes[..4];
559 if magic != b"\0asm" {
560 return Err(PluginError::WasmLoad {
561 file: path.to_owned(),
562 cause: "not a valid WASM binary (bad magic)".into(),
563 });
564 }
565
566 let mut offset = 8usize; while offset < wasm_bytes.len() {
568 let section_id = wasm_bytes[offset];
569 offset += 1;
570
571 let (section_size, leb_bytes) = read_leb128_u32(&wasm_bytes[offset..])?;
573 offset += leb_bytes;
574
575 let section_end = offset + section_size as usize;
576 if section_end > wasm_bytes.len() {
577 break;
578 }
579
580 if section_id == 0 {
581 let name_end = offset;
583 let (name_len, nl) = read_leb128_u32(&wasm_bytes[name_end..])?;
584 let name_start = name_end + nl;
585 let name_finish = name_start + name_len as usize;
586 if name_finish <= section_end {
587 let section_name = &wasm_bytes[name_start..name_finish];
588 if section_name == b"wafrift_manifest" {
589 let payload = &wasm_bytes[name_finish..section_end];
590 let toml_str =
591 std::str::from_utf8(payload).map_err(|_| PluginError::WasmLoad {
592 file: path.to_owned(),
593 cause: "wafrift_manifest custom section is not valid UTF-8".into(),
594 })?;
595 let em: WasmEmbeddedManifest =
596 toml::from_str(toml_str).map_err(|e| PluginError::TomlParse {
597 file: path.to_owned(),
598 cause: format!("wafrift_manifest section: {e}"),
599 })?;
600 let mf = TamperManifest {
601 name: em.name,
602 version: em.version,
603 author: em.author,
604 payload_classes: em.payload_classes,
605 contexts: em.contexts,
606 description: em.description,
607 };
608 mf.validate()?;
609 return Ok(mf);
610 }
611 }
612 }
613
614 offset = section_end;
615 }
616
617 Err(PluginError::WasmLoad {
618 file: path.to_owned(),
619 cause: "missing 'wafrift_manifest' custom section — see docs/PLUGIN_API.md".into(),
620 })
621}
622
623fn read_leb128_u32(data: &[u8]) -> Result<(u32, usize), PluginError> {
624 let mut result = 0u32;
625 let mut shift = 0u32;
626 for (i, &byte) in data.iter().enumerate().take(5) {
627 result |= u32::from(byte & 0x7F) << shift;
628 shift += 7;
629 if byte & 0x80 == 0 {
630 return Ok((result, i + 1));
631 }
632 }
633 Err(PluginError::InvalidManifest(
634 "malformed LEB128 in WASM section header".into(),
635 ))
636}
637
638pub struct TamperRegistry {
647 plugins: Vec<Box<dyn Tamper>>,
648 name_index: HashMap<String, usize>,
649}
650
651impl TamperRegistry {
652 #[must_use]
654 pub fn new() -> Self {
655 Self {
656 plugins: Vec::new(),
657 name_index: HashMap::new(),
658 }
659 }
660
661 pub fn register(&mut self, plugin: Box<dyn Tamper>) -> Result<(), PluginError> {
667 let name = plugin.name().to_owned();
668 if self.name_index.contains_key(&name) {
669 return Err(PluginError::NameCollision(name));
670 }
671 let idx = self.plugins.len();
672 self.name_index.insert(name, idx);
673 self.plugins.push(plugin);
674 Ok(())
675 }
676
677 #[must_use]
679 pub fn get(&self, name: &str) -> Option<&dyn Tamper> {
680 self.name_index
681 .get(name)
682 .and_then(|&idx| self.plugins.get(idx))
683 .map(|b| b.as_ref())
684 }
685
686 #[must_use]
688 pub fn all(&self) -> &[Box<dyn Tamper>] {
689 &self.plugins
690 }
691
692 #[must_use]
694 pub fn len(&self) -> usize {
695 self.plugins.len()
696 }
697
698 #[must_use]
700 pub fn is_empty(&self) -> bool {
701 self.plugins.is_empty()
702 }
703
704 pub fn load_dir(&mut self, dir: &Path) -> Vec<PluginError> {
710 let entries = match std::fs::read_dir(dir) {
711 Ok(e) => e,
712 Err(_) => return Vec::new(), };
714
715 let mut errors = Vec::new();
716 for entry in entries.flatten() {
717 let path = entry.path();
718 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
719 let result = match ext {
720 "toml" => load_toml_plugin(&path),
721 "wasm" => load_wasm_plugin(&path),
722 _ => continue,
723 };
724 match result {
725 Ok(plugin) => {
726 if let Err(e) = self.register(plugin) {
727 errors.push(e);
728 }
729 }
730 Err(e) => errors.push(e),
731 }
732 }
733 errors
734 }
735}
736
737impl Default for TamperRegistry {
738 fn default() -> Self {
739 Self::new()
740 }
741}
742
743#[must_use]
751pub fn default_plugin_dir() -> Option<PathBuf> {
752 dirs::home_dir().map(|h| h.join(".wafrift").join("tampers"))
753}
754
755#[must_use]
761pub fn load_all() -> Vec<Box<dyn Tamper>> {
762 let mut registry = TamperRegistry::new();
763 if let Some(dir) = default_plugin_dir() {
764 let errors = registry.load_dir(&dir);
765 for e in errors {
766 tracing::warn!("plugin-api: skipping plugin: {e}");
767 }
768 }
769 registry.plugins
770}
771
772#[must_use]
776pub fn load_from(dir: &Path) -> Vec<Box<dyn Tamper>> {
777 let mut registry = TamperRegistry::new();
778 let errors = registry.load_dir(dir);
779 for e in errors {
780 tracing::warn!("plugin-api: skipping plugin: {e}");
781 }
782 registry.plugins
783}
784
785#[cfg(test)]
790mod tests {
791 use super::*;
792 use std::io::Write;
793 use tempfile::TempDir;
794
795 fn write_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
798 let path = dir.path().join(name);
799 let mut f = std::fs::File::create(&path).unwrap();
800 f.write_all(content.as_bytes()).unwrap();
801 path
802 }
803
804 fn minimal_toml(name: &str, pattern: &str, replacement: &str) -> String {
805 format!(
806 r#"
807[manifest]
808name = "{name}"
809version = "1.0.0"
810author = "Test Author"
811payload_classes = ["sqli"]
812contexts = ["query_string"]
813description = "Test tamper"
814
815[[rules]]
816pattern = "{pattern}"
817replacement = "{replacement}"
818"#
819 )
820 }
821
822 #[test]
825 fn load_dir_empty_returns_zero_plugins() {
826 let dir = TempDir::new().unwrap();
827 let plugins = load_from(dir.path());
828 assert_eq!(plugins.len(), 0);
829 }
830
831 #[test]
834 fn load_dir_nonexistent_returns_zero_plugins() {
835 let path = std::path::Path::new("/nonexistent/path/tampers");
836 let plugins = load_from(path);
837 assert_eq!(plugins.len(), 0);
838 }
839
840 #[test]
843 fn load_one_toml_tamper() {
844 let dir = TempDir::new().unwrap();
845 write_file(&dir, "upper.toml", &minimal_toml("upper", "[a-z]", "X"));
846
847 let mut registry = TamperRegistry::new();
848 let errors = registry.load_dir(dir.path());
849 assert!(errors.is_empty(), "Unexpected errors: {errors:?}");
850 assert_eq!(registry.len(), 1);
851
852 let t = registry.get("upper").expect("should be registered");
853 assert_eq!(t.name(), "upper");
854 }
855
856 #[test]
859 fn toml_tamper_apply_regex() {
860 let dir = TempDir::new().unwrap();
861 write_file(
862 &dir,
863 "space_to_comment.toml",
864 &minimal_toml("space_to_comment", r" ", "/**/"),
865 );
866
867 let mut registry = TamperRegistry::new();
868 registry.load_dir(dir.path());
869
870 let result = registry
871 .get("space_to_comment")
872 .unwrap()
873 .apply("SELECT * FROM users");
874 assert!(result.contains("/**/"), "got: {result}");
875 assert!(!result.contains(" "), "spaces should be replaced");
876 }
877
878 #[test]
881 fn toml_tamper_reversed_magic() {
882 let dir = TempDir::new().unwrap();
883 write_file(
884 &dir,
885 "rev.toml",
886 &minimal_toml("rev", "^(.+)$", "$REVERSED"),
887 );
888
889 let mut registry = TamperRegistry::new();
890 registry.load_dir(dir.path());
891
892 let result = registry.get("rev").unwrap().apply("abc");
893 assert_eq!(result, "cba");
894 }
895
896 #[test]
899 fn malformed_manifest_rejected() {
900 let dir = TempDir::new().unwrap();
901 write_file(
902 &dir,
903 "bad.toml",
904 r#"
905[manifest]
906name = ""
907version = "1.0.0"
908author = "Author"
909description = "Empty name should fail"
910[[rules]]
911pattern = "x"
912replacement = "y"
913"#,
914 );
915
916 let mut registry = TamperRegistry::new();
917 let errors = registry.load_dir(dir.path());
918 assert!(!errors.is_empty(), "should have rejected empty name");
919 assert_eq!(registry.len(), 0);
920 }
921
922 #[test]
925 fn invalid_regex_rejected() {
926 let dir = TempDir::new().unwrap();
927 let content = r#"
931[manifest]
932name = "bad_re"
933version = "1.0.0"
934author = "Test Author"
935payload_classes = ["sqli"]
936contexts = ["query_string"]
937description = "Test tamper"
938
939[[rules]]
940pattern = '[invalid('
941replacement = "x"
942"#;
943 write_file(&dir, "bad_re.toml", content);
944
945 let mut registry = TamperRegistry::new();
946 let errors = registry.load_dir(dir.path());
947 assert!(!errors.is_empty());
948 assert!(matches!(errors[0], PluginError::InvalidRegex { .. }));
949 }
950
951 #[test]
954 fn name_collision_rejected() {
955 let dir = TempDir::new().unwrap();
956 write_file(&dir, "dup.toml", &minimal_toml("dup_tamper", "x", "y"));
957 write_file(&dir, "dup2.toml", &minimal_toml("dup_tamper", "a", "b"));
958
959 let mut registry = TamperRegistry::new();
960 let errors = registry.load_dir(dir.path());
961 assert_eq!(registry.len(), 1);
963 assert!(!errors.is_empty());
964 assert!(matches!(errors[0], PluginError::NameCollision(_)));
965 }
966
967 #[test]
970 fn unknown_extensions_skipped() {
971 let dir = TempDir::new().unwrap();
972 write_file(&dir, "script.py", "print('hello')");
973 write_file(&dir, "data.json", "{}");
974
975 let mut registry = TamperRegistry::new();
976 let errors = registry.load_dir(dir.path());
977 assert!(errors.is_empty());
978 assert_eq!(registry.len(), 0);
979 }
980
981 #[test]
984 fn manifest_name_with_spaces_rejected() {
985 let mf = TamperManifest {
986 name: "bad name with spaces".into(),
987 version: "1.0.0".into(),
988 author: "A".into(),
989 payload_classes: vec![],
990 contexts: vec![],
991 description: "desc".into(),
992 };
993 let err = mf.validate().unwrap_err();
994 assert!(matches!(err, PluginError::InvalidManifest(_)));
995 }
996
997 #[test]
1000 fn manifest_description_too_long_rejected() {
1001 let mf = TamperManifest {
1002 name: "ok_name".into(),
1003 version: "1.0.0".into(),
1004 author: "A".into(),
1005 payload_classes: vec![],
1006 contexts: vec![],
1007 description: "x".repeat(513),
1008 };
1009 let err = mf.validate().unwrap_err();
1010 assert!(matches!(err, PluginError::InvalidManifest(_)));
1011 }
1012
1013 #[test]
1016 fn parallel_registry_access() {
1017 use std::sync::Arc;
1018 use std::thread;
1019
1020 let dir = TempDir::new().unwrap();
1021 write_file(&dir, "par.toml", &minimal_toml("par_tamper", "0", "N"));
1024
1025 let mut registry = TamperRegistry::new();
1026 registry.load_dir(dir.path());
1027 let registry = Arc::new(registry);
1028
1029 let handles: Vec<_> = (0..8)
1030 .map(|i| {
1031 let r = Arc::clone(®istry);
1032 thread::spawn(move || {
1033 let input = format!("payload_0_{i}");
1035 let result = r.get("par_tamper").unwrap().apply(&input);
1036 assert!(result.contains('N'), "thread {i}: got {result}");
1037 })
1038 })
1039 .collect();
1040
1041 for h in handles {
1042 h.join().unwrap();
1043 }
1044 }
1045
1046 #[test]
1049 fn malformed_toml_parse_error() {
1050 let dir = TempDir::new().unwrap();
1051 write_file(&dir, "garbage.toml", "not valid toml [[[ !!!");
1052
1053 let mut registry = TamperRegistry::new();
1054 let errors = registry.load_dir(dir.path());
1055 assert!(!errors.is_empty());
1056 assert!(matches!(errors[0], PluginError::TomlParse { .. }));
1057 }
1058
1059 #[test]
1062 fn wasm_wrong_magic_rejected() {
1063 let dir = TempDir::new().unwrap();
1064 let path = dir.path().join("fake.wasm");
1066 std::fs::write(&path, b"not a wasm file at all!!!!").unwrap();
1067
1068 let result = load_wasm_plugin(&path);
1069 assert!(
1070 matches!(result, Err(PluginError::WasmLoad { .. })),
1071 "expected WasmLoad error"
1072 );
1073 }
1074
1075 #[test]
1078 fn load_all_no_panic_with_missing_dir() {
1079 let tmp = TempDir::new().unwrap();
1083 let absent = tmp.path().join("absent_subdir");
1084 let plugins = load_from(&absent);
1085 assert_eq!(plugins.len(), 0);
1086 }
1087
1088 #[test]
1091 fn toml_multiple_rules_applied_in_order() {
1092 let dir = TempDir::new().unwrap();
1093 let content = r#"
1094[manifest]
1095name = "multi_rule"
1096version = "1.0.0"
1097author = "Test"
1098payload_classes = ["sqli"]
1099contexts = ["query_string"]
1100description = "Two rules applied sequentially"
1101
1102[[rules]]
1103pattern = "SELECT"
1104replacement = "SEL/**/ECT"
1105
1106[[rules]]
1107pattern = " "
1108replacement = "/**/"
1109"#;
1110 write_file(&dir, "multi_rule.toml", content);
1111
1112 let mut registry = TamperRegistry::new();
1113 let errors = registry.load_dir(dir.path());
1114 assert!(errors.is_empty());
1115
1116 let result = registry.get("multi_rule").unwrap().apply("SELECT 1");
1117 assert!(result.contains("SEL/**/ECT"), "got: {result}");
1120 assert!(!result.contains(" "), "spaces should be gone: {result}");
1121 }
1122
1123 #[test]
1131 fn read_capped_file_rejects_oversize_input() {
1132 use std::io::Write;
1133 let dir = tempfile::tempdir().expect("tmpdir");
1134 let path = dir.path().join("oversize.bin");
1135 let mut f = std::fs::File::create(&path).expect("create");
1136 f.write_all(&vec![b'x'; 1024]).expect("write");
1137 drop(f);
1138 let err = super::read_capped_file(&path, 256).expect_err("must reject");
1139 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1140 assert!(err.to_string().contains("exceeds"), "msg: {err}");
1141 }
1142
1143 #[test]
1144 fn read_capped_file_accepts_exact_cap() {
1145 use std::io::Write;
1146 let dir = tempfile::tempdir().expect("tmpdir");
1147 let path = dir.path().join("exact.bin");
1148 let mut f = std::fs::File::create(&path).expect("create");
1149 f.write_all(&[b'a'; 100]).expect("write");
1150 drop(f);
1151 let got = super::read_capped_file(&path, 100).expect("at cap must pass");
1152 assert_eq!(got.len(), 100);
1153 }
1154}