Skip to main content

wafrift_plugin_api/
lib.rs

1//! wafrift-plugin-api — External tamper plugin system.
2//!
3//! Lets external contributors add tampers **without a Rust rebuild**.
4//! Plugins live at `~/.wafrift/tampers/`:
5//!
6//! | Extension | Mechanism | Use case |
7//! |-----------|-----------|----------|
8//! | `.toml`   | Regex substitution rules | ~80% of tampers (encoders / replacers) |
9//! | `.wasm`   | WebAssembly (wasmtime)   | Turing-complete logic |
10//!
11//! # Security model
12//!
13//! WASM modules run inside a `wasmtime::Engine` with **no** WASI
14//! capabilities attached: no filesystem, no network, no environment
15//! variables, no random, no clocks. The only ABI is a single exported
16//! function `tamper(ptr: i32, len: i32) -> i64` that receives the
17//! payload as UTF-8 bytes via linear memory and returns a
18//! `(ptr << 32 | len)` packed into an i64.  Memory is bounded to 4 MiB.
19//! Fuel limiting caps execution to 1 000 000 instructions per call.
20//!
21//! # Quick start
22//!
23//! ```no_run
24//! use wafrift_plugin_api::load_all;
25//!
26//! let tampers = load_all();
27//! for t in &tampers {
28//!     println!("{}: {}", t.name(), t.apply("SELECT 1"));
29//! }
30//! ```
31
32#![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
43// ──────────────────────────────────────────────────────────────────────────
44// Public trait: Tamper
45// ──────────────────────────────────────────────────────────────────────────
46
47/// Every plugin — TOML or WASM — implements this trait.
48///
49/// The trait is object-safe so plugins can be stored as `Box<dyn Tamper>`.
50pub trait Tamper: Send + Sync {
51    /// Unique, ASCII-only snake_case name.  Must not collide with built-ins.
52    fn name(&self) -> &str;
53
54    /// Transform a payload for WAF evasion.
55    fn apply(&self, input: &str) -> String;
56
57    /// Structured metadata every plugin must provide.
58    fn manifest(&self) -> TamperManifest;
59}
60
61// ──────────────────────────────────────────────────────────────────────────
62// TamperManifest
63// ──────────────────────────────────────────────────────────────────────────
64
65/// Metadata that every external contribution must declare.
66///
67/// Validated at load time; malformed manifests are rejected before the
68/// plugin reaches the registry.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct TamperManifest {
71    /// Unique, snake_case plugin name (must match the file stem).
72    pub name: String,
73    /// Semver string, e.g. `"1.0.0"`.
74    pub version: String,
75    /// Plugin author / email.
76    pub author: String,
77    /// Which payload classes this tamper targets, e.g. `["sqli", "xss"]`.
78    pub payload_classes: Vec<String>,
79    /// Injection contexts where the tamper is appropriate, e.g.
80    /// `["query_string", "json_body"]`.
81    pub contexts: Vec<String>,
82    /// Human-readable description (max 512 chars).
83    pub description: String,
84}
85
86impl TamperManifest {
87    /// Validate that the manifest fields are structurally sound.
88    ///
89    /// # Errors
90    /// Returns a description of the first validation failure.
91    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// ──────────────────────────────────────────────────────────────────────────
128// Error type
129// ──────────────────────────────────────────────────────────────────────────
130
131/// Errors that can occur during plugin loading or execution.
132#[derive(Debug, thiserror::Error)]
133pub enum PluginError {
134    /// A required manifest field is missing or invalid.
135    #[error("Invalid manifest: {0}")]
136    InvalidManifest(String),
137
138    /// Two plugins share the same name.
139    #[error("Name collision: plugin '{0}' is already registered")]
140    NameCollision(String),
141
142    /// A TOML file could not be parsed.
143    #[error("TOML parse error in {file}: {cause}")]
144    TomlParse { file: PathBuf, cause: String },
145
146    /// A regex pattern in a TOML tamper is invalid.
147    #[error("Invalid regex '{pattern}' in {file}: {cause}")]
148    InvalidRegex {
149        file: PathBuf,
150        pattern: String,
151        cause: String,
152    },
153
154    /// A WASM module failed to load or compile.
155    #[error("WASM load error in {file}: {cause}")]
156    WasmLoad { file: PathBuf, cause: String },
157
158    /// The WASM module tried to use a disallowed capability.
159    #[error("WASM sandbox violation in '{plugin}': {detail}")]
160    WasmSandboxViolation { plugin: String, detail: String },
161
162    /// WASM execution ran out of fuel.
163    #[error("WASM fuel exhausted in '{plugin}' after {fuel} instructions")]
164    WasmFuelExhausted { plugin: String, fuel: u64 },
165
166    /// Generic I/O error while scanning the plugin directory.
167    #[error("I/O error: {0}")]
168    Io(#[from] std::io::Error),
169}
170
171// ──────────────────────────────────────────────────────────────────────────
172// TOML tamper file format
173// ──────────────────────────────────────────────────────────────────────────
174
175/// Top-level structure of a `~/.wafrift/tampers/*.toml` plugin file.
176///
177/// # Example TOML
178/// ```toml
179/// [manifest]
180/// name = "reverse_string"
181/// version = "1.0.0"
182/// author = "Jane Researcher <jane@example.com>"
183/// payload_classes = ["generic"]
184/// contexts = ["query_string", "json_body"]
185/// description = "Reverses every token for simple obfuscation tests."
186///
187/// [[rules]]
188/// pattern = "^(.+)$"
189/// replacement = "$REVERSED"   # magic: entire match reversed
190/// ```
191#[derive(Debug, Deserialize)]
192struct TomlPluginFile {
193    manifest: TomlManifest,
194    /// Rules are optional — a manifest-only plugin loads as an
195    /// identity tamper. Used by external contributors who want to
196    /// register metadata (e.g. for a future WASM upgrade path) without
197    /// shipping regex rules yet.
198    #[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/// A single regex-based substitution rule.
215#[derive(Debug, Deserialize)]
216struct TomlRule {
217    /// Regex pattern to match in the input.
218    pattern: String,
219    /// Replacement string.  Supports `$1`, `$2` … for capture groups.
220    /// Special magic token `$REVERSED` reverses the entire match.
221    replacement: String,
222}
223
224// ──────────────────────────────────────────────────────────────────────────
225// TOML-backed Tamper implementation
226// ──────────────────────────────────────────────────────────────────────────
227
228struct TomlTamper {
229    manifest: TamperManifest,
230    /// Pre-compiled (pattern, replacement) pairs.
231    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: &regex::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
260/// Maximum TOML plugin file size: 256 KiB.
261const TOML_MAX_BYTES: u64 = 256 * 1024;
262
263/// Bounded read for plugin files. The previous metadata()+read()
264/// pattern was vulnerable to TOCTOU: a symlink reporting len=0 (e.g.
265/// pointing at /dev/zero) would pass the metadata gate and then
266/// stream until OOM. Enforce the cap DURING the read so symlinks +
267/// races + post-stat replacements cannot evade it.
268fn 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    // ReDoS guard: cap the compiled NFA size so a malicious plugin
316    // with a pathological pattern (e.g. `(a+)+`) cannot stall the
317    // engine. 1 MiB is stricter than the workspace-canonical 4 MiB
318    // (wafrift_types::REGEX_NFA_SIZE_LIMIT) because plugin patterns
319    // come from fully untrusted third parties. The tighter cap is
320    // intentional — do NOT bump it to match the workspace constant.
321    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
341// ──────────────────────────────────────────────────────────────────────────
342// WASM-backed Tamper implementation
343// ──────────────────────────────────────────────────────────────────────────
344
345/// Fuel budget: 1 000 000 instructions per `apply()` call.
346const WASM_FUEL_PER_CALL: u64 = 1_000_000;
347
348/// Maximum `.wasm` file size: 4 MiB.
349const WASM_MAX_BYTES: u64 = 4 * 1024 * 1024;
350
351struct WasmTamper {
352    manifest: TamperManifest,
353    /// Arc+Mutex so `WasmTamper: Send + Sync` despite `Store` being `!Send`.
354    /// Each `apply()` call locks, runs the guest, and unlocks.
355    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    /// Execute one tamper call.
368    ///
369    /// We resolve borrow-checker conflicts by cloning the `TypedFunc`
370    /// values out of their `Option` wrappers before mutably borrowing
371    /// `self.store` — `TypedFunc` is a lightweight handle (index +
372    /// type marker) designed to be cloned cheaply.
373    fn call_tamper(&mut self, input: &str) -> Option<String> {
374        // Clone handles upfront to avoid aliasing borrows later.
375        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        // Allocate guest memory for the input.
386        let ptr = alloc_fn.call(&mut self.store, len).ok()?;
387
388        // Write payload into guest linear memory.
389        memory.write(&mut self.store, ptr as usize, bytes).ok()?;
390
391        // Call the guest tamper function.
392        let result_packed = tamper_fn.call(&mut self.store, (ptr, len)).ok()?;
393
394        // Free the input allocation if a dealloc export is present.
395        if let Some(ref dealloc) = dealloc_fn {
396            dealloc.call(&mut self.store, (ptr, len)).ok();
397        }
398
399        // Unpack (result_ptr << 32 | result_len).
400        let result_ptr = ((result_packed >> 32) & 0xFFFF_FFFF) as usize;
401        let result_len = (result_packed & 0xFFFF_FFFF) as usize;
402
403        // §15 host-OOM defence: `result_len` is attacker-controlled — the low
404        // 32 bits of the UNTRUSTED guest's return value, up to ~4 GiB. The
405        // guest's own linear memory is capped (4 MiB), so any (ptr, len) that
406        // does not fit inside the current guest memory is necessarily a lie —
407        // and a naive `vec![0u8; result_len]` would allocate gigabytes on the
408        // HOST and OOM it BEFORE `memory.read` (which only bounds-checks the
409        // read itself) ever runs. Reject the out-of-bounds/oversized result
410        // up front so the host allocation can never exceed guest memory.
411        let mem_size = memory.data_size(&self.store);
412        if result_ptr.saturating_add(result_len) > mem_size {
413            return None; // oversized / out-of-bounds guest result — fail safe
414        }
415
416        let mut out = vec![0u8; result_len];
417        memory.read(&self.store, result_ptr, &mut out).ok()?;
418
419        // Free the output allocation.
420        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(), // poisoned — fail safe
439        };
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/// Manifest is embedded in the WASM custom section `wafrift_manifest` as
449/// TOML text.  This is the struct that section deserializes into.
450#[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    // Build a sandboxed engine: no WASI, fuel enabled, memory limited.
473    let mut config = wasmtime::Config::new();
474    config.consume_fuel(true);
475    // Cap the guest linear memory to WASM_MEMORY_PAGES × 64 KiB = 4 MiB.
476    config.memory_guard_size(0);
477    config.max_wasm_stack(512 * 1024); // 512 KiB Wasm stack
478    // Multi-memory and threads are not needed; keeping them off
479    // narrows the attack surface of the sandboxed guest.
480
481    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    // Extract the manifest from the custom section before compiling.
487    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    // Linker with NO imports — no WASI, no host functions.
496    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
547/// Reads the manifest from the WASM custom section named `wafrift_manifest`.
548fn extract_wasm_manifest(wasm_bytes: &[u8], path: &Path) -> Result<TamperManifest, PluginError> {
549    // WASM binary format: 4-byte magic + 4-byte version, then sections.
550    // We scan for custom sections (section id = 0) with name "wafrift_manifest".
551    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; // skip magic + version
567    while offset < wasm_bytes.len() {
568        let section_id = wasm_bytes[offset];
569        offset += 1;
570
571        // LEB128-decode the section size.
572        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            // Custom section: starts with a name string (LEB128 length + bytes).
582            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
638// ──────────────────────────────────────────────────────────────────────────
639// TamperRegistry
640// ──────────────────────────────────────────────────────────────────────────
641
642/// Registry that holds all loaded external tampers.
643///
644/// Designed for concurrent read access: after construction it is
645/// immutably shared across threads via `Arc<TamperRegistry>`.
646pub struct TamperRegistry {
647    plugins: Vec<Box<dyn Tamper>>,
648    name_index: HashMap<String, usize>,
649}
650
651impl TamperRegistry {
652    /// Create an empty registry.
653    #[must_use]
654    pub fn new() -> Self {
655        Self {
656            plugins: Vec::new(),
657            name_index: HashMap::new(),
658        }
659    }
660
661    /// Register a tamper.  Returns an error on name collision.
662    ///
663    /// # Errors
664    /// Returns [`PluginError::NameCollision`] if a tamper with the same
665    /// name is already registered.
666    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    /// Look up a tamper by name.
678    #[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    /// All registered tampers (order matches registration order).
687    #[must_use]
688    pub fn all(&self) -> &[Box<dyn Tamper>] {
689        &self.plugins
690    }
691
692    /// Number of registered tampers.
693    #[must_use]
694    pub fn len(&self) -> usize {
695        self.plugins.len()
696    }
697
698    /// True if no tampers are registered.
699    #[must_use]
700    pub fn is_empty(&self) -> bool {
701        self.plugins.is_empty()
702    }
703
704    /// Load all plugins from the given directory, in-place.
705    ///
706    /// Files with unrecognised extensions are silently skipped.
707    /// Load failures are collected and returned; the registry still
708    /// contains all plugins that loaded successfully.
709    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(), // directory doesn't exist — not an error
713        };
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// ──────────────────────────────────────────────────────────────────────────
744// Public discovery function
745// ──────────────────────────────────────────────────────────────────────────
746
747/// Return the default plugin directory: `~/.wafrift/tampers/`.
748///
749/// Returns `None` if the home directory cannot be determined.
750#[must_use]
751pub fn default_plugin_dir() -> Option<PathBuf> {
752    dirs::home_dir().map(|h| h.join(".wafrift").join("tampers"))
753}
754
755/// Scan `~/.wafrift/tampers/` and return all successfully-loaded plugins.
756///
757/// Errors from individual plugins are logged at WARN level and dropped.
758/// An empty `Vec` is returned when no plugins are found or the directory
759/// does not exist.
760#[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/// Scan the given directory and return all successfully-loaded plugins.
773///
774/// Errors are logged at WARN level.
775#[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// ──────────────────────────────────────────────────────────────────────────
786// Tests
787// ──────────────────────────────────────────────────────────────────────────
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use std::io::Write;
793    use tempfile::TempDir;
794
795    // ── helpers ────────────────────────────────────────────────────────────
796
797    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    // ── 1. Empty directory → zero plugins ─────────────────────────────────
823
824    #[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    // ── 2. Non-existent directory → zero plugins, no panic ────────────────
832
833    #[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    // ── 3. Load one valid TOML tamper ──────────────────────────────────────
841
842    #[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    // ── 4. TOML tamper applies regex correctly ─────────────────────────────
857
858    #[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    // ── 5. TOML tamper with $REVERSED magic ───────────────────────────────
879
880    #[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    // ── 6. Malformed manifest → rejected ──────────────────────────────────
897
898    #[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    // ── 7. Invalid regex → rejected ────────────────────────────────────────
923
924    #[test]
925    fn invalid_regex_rejected() {
926        let dir = TempDir::new().unwrap();
927        // Use a TOML literal string (single-quoted in TOML) for the pattern so
928        // backslashes are preserved verbatim.  We embed it manually instead of
929        // going through `minimal_toml` which wraps patterns in double quotes.
930        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    // ── 8. Name collision rejected ────────────────────────────────────────
952
953    #[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        // One loads successfully, one collides.
962        assert_eq!(registry.len(), 1);
963        assert!(!errors.is_empty());
964        assert!(matches!(errors[0], PluginError::NameCollision(_)));
965    }
966
967    // ── 9. Unrecognised extension skipped silently ─────────────────────────
968
969    #[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    // ── 10. Manifest validation: name with illegal chars ──────────────────
982
983    #[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    // ── 11. Manifest validation: description too long ─────────────────────
998
999    #[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    // ── 12. Parallel registry read access is safe ─────────────────────────
1014
1015    #[test]
1016    fn parallel_registry_access() {
1017        use std::sync::Arc;
1018        use std::thread;
1019
1020        let dir = TempDir::new().unwrap();
1021        // Use a literal character (not a regex meta) to avoid TOML
1022        // backslash-escape issues in the minimal_toml template.
1023        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(&registry);
1032                thread::spawn(move || {
1033                    // "payload_0" → "N" replaces '0' → "payNload_N"
1034                    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    // ── 13. Malformed TOML parse error ────────────────────────────────────
1047
1048    #[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    // ── 14. WASM file with wrong magic → WasmLoad error ───────────────────
1060
1061    #[test]
1062    fn wasm_wrong_magic_rejected() {
1063        let dir = TempDir::new().unwrap();
1064        // Not a WASM binary — write random bytes.
1065        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    // ── 15. load_all() does not panic when home dir has no tampers dir ─────
1076
1077    #[test]
1078    fn load_all_no_panic_with_missing_dir() {
1079        // If ~/.wafrift/tampers/ doesn't exist, load_all() returns empty vec.
1080        // We can't override HOME in a reliable cross-platform test, so we
1081        // test the lower-level function directly.
1082        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    // ── 16. Multiple rules applied in order ───────────────────────────────
1089
1090    #[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        // First rule fires: "SEL/**/ECT 1"
1118        // Second rule fires: "SEL/**/ECT/**/1"
1119        assert!(result.contains("SEL/**/ECT"), "got: {result}");
1120        assert!(!result.contains(" "), "spaces should be gone: {result}");
1121    }
1122
1123    // ── Round 20: bounded plugin reads (TOCTOU defence) ──────────────
1124    //
1125    // Pre-fix `metadata()`-then-`read()` was vulnerable to symlinks
1126    // reporting len=0 (pointed at /dev/zero) and to attackers
1127    // replacing the file between the stat and the read. The fix
1128    // enforces the cap DURING the read.
1129
1130    #[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}