Skip to main content

attackstr/
lib.rs

1#![warn(clippy::pedantic)]
2#![cfg_attr(
3    not(test),
4    deny(
5        clippy::unwrap_used,
6        clippy::expect_used,
7        clippy::todo,
8        clippy::unimplemented,
9        clippy::panic
10    )
11)]
12#![allow(clippy::module_name_repetitions)]
13//! # attackstr
14//!
15//! Grammar-based security payload generation for the Santh ecosystem.
16//!
17//! Every security tool needs attack payloads  -  `SQLi`, XSS, command injection,
18//! SSTI, SSRF, XXE, and more. This crate provides a single, configurable
19//! engine that all Santh tools share. Upgrade payloads once, every tool
20//! benefits.
21//!
22//! # Architecture
23//!
24//! Payloads are defined in TOML grammar files. Each grammar specifies:
25//!
26//! - **Contexts**: injection points (string break, numeric, attribute, etc.)
27//! - **Techniques**: attack patterns with template variables
28//! - **Variables**: substitution values (tautologies, commands, etc.)
29//! - **Encodings**: transforms applied to final payloads (URL, hex, unicode, etc.)
30//!
31//! The engine computes the Cartesian product:
32//! `contexts × techniques × variable_combos × encodings`
33//!
34//! # Usage
35//!
36//! ```rust
37//! use attackstr::{PayloadDb, PayloadConfig};
38//!
39//! let mut db = PayloadDb::with_config(PayloadConfig::default());
40//! db.load_toml(r#"
41//! [grammar]
42//! name = "example"
43//! sink_category = "sql-injection"
44//!
45//! [[techniques]]
46//! name = "basic"
47//! template = "' OR 1=1 --"
48//! "#).unwrap();
49//!
50//! // Get payloads for a category
51//! let sqli = db.payloads("sql-injection");
52//! for payload in sqli {
53//!     println!("{}", payload.text);
54//! }
55//!
56//! // Get payloads with marker injection for taint tracking
57//! let marked = db.payloads_with_marker("xss", "SLN_MARKER_42");
58//! ```
59//!
60//! # Custom Encodings
61//!
62//! Register custom encoding transforms:
63//!
64//! ```rust
65//! use attackstr::PayloadDb;
66//!
67//! let mut db = PayloadDb::new();
68//! db.register_encoding("rot13", |s| {
69//!     s.chars().map(|c| match c {
70//!         'a'..='m' | 'A'..='M' => (c as u8 + 13) as char,
71//!         'n'..='z' | 'N'..='Z' => (c as u8 - 13) as char,
72//!         _ => c,
73//!     }).collect()
74//! });
75//! ```
76
77#![forbid(unsafe_code)]
78#![warn(missing_docs)]
79
80/// TOML-configurable settings.
81pub mod config;
82mod encoding;
83pub mod grammar;
84mod loader;
85mod mutate;
86#[cfg(feature = "exploits")]
87pub mod ports;
88/// Grammar validation.
89pub mod validate;
90
91pub use config::{parse_marker_position, PayloadConfigFile};
92pub use encoding::{apply_encoding, BuiltinEncoding, CustomEncoder, Encoder, EncodingError};
93pub use grammar::{
94    depluralize, expand, expand_template, Context, Encoding, Grammar, GrammarMeta, Technique,
95    TemplateExpansionError, Variable,
96};
97pub use loader::PayloadDb;
98pub use mutate::{
99    mutate_all, mutate_case, mutate_encoding_mix, mutate_html, mutate_null_bytes,
100    mutate_sql_comments, mutate_unicode, mutate_whitespace,
101};
102use serde::{Deserialize, Serialize};
103use std::collections::BTreeMap;
104use std::hash::{Hash, Hasher};
105pub use validate::{validate, GrammarIssue, IssueLevel};
106
107/// A trait for sources that can provide payloads.
108///
109/// This trait abstracts over different payload storage and generation
110/// strategies, allowing users to swap implementations.
111///
112/// # Thread Safety
113/// This trait does not require `Send` or `Sync`. Thread-safety depends on the
114/// concrete implementing type.
115///
116/// # Example
117///
118/// ```rust
119/// use attackstr::{PayloadSource, PayloadDb};
120///
121/// fn count_payloads(source: &mut dyn PayloadSource) -> usize {
122///     source.payload_count()
123/// }
124/// ```
125pub trait PayloadSource {
126    /// Get all payloads for a given category.
127    ///
128    /// The returned slice is cached on subsequent calls for the same category.
129    fn payloads(&mut self, category: &str) -> &[Payload];
130
131    /// Get all available category names.
132    fn categories(&self) -> Vec<&str>;
133
134    /// Get the total number of payloads across all categories.
135    fn payload_count(&self) -> usize;
136}
137
138/// A static payload source that holds payloads directly in memory.
139///
140/// This is useful for users who generate payloads externally and want
141/// to use them with the attackstr ecosystem.
142///
143/// # Thread Safety
144/// `StaticPayloads` is `Send` and `Sync`.
145///
146/// # Example
147///
148/// ```rust
149/// use attackstr::{StaticPayloads, Payload, PayloadSource};
150///
151/// let payloads = vec![
152///     Payload {
153///         text: "test".into(),
154///         category: "custom".into(),
155///         technique: "manual".into(),
156///         context: "default".into(),
157///         encoding: "raw".into(),
158///         cwe: None,
159///         severity: None,
160///         confidence: 1.0,
161///         expected_pattern: None,
162///         target_media_type: None,
163///     },
164/// ];
165///
166/// let mut source = StaticPayloads::new(payloads);
167/// assert_eq!(source.payloads("custom").len(), 1);
168/// ```
169#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
170pub struct StaticPayloads {
171    payloads: Vec<Payload>,
172    #[serde(skip)]
173    category_ranges: BTreeMap<String, std::ops::Range<usize>>,
174}
175
176impl StaticPayloads {
177    /// Create a new `StaticPayloads` from a vector of payloads.
178    ///
179    /// Example:
180    /// ```rust
181    /// use attackstr::{Payload, StaticPayloads};
182    ///
183    /// let payloads = vec![Payload {
184    ///     text: "alert(1)".into(),
185    ///     category: "xss".into(),
186    ///     technique: "basic".into(),
187    ///     context: "default".into(),
188    ///     encoding: "raw".into(),
189    ///     cwe: None,
190    ///     severity: None,
191    ///     confidence: 1.0,
192    ///     expected_pattern: None,
193    ///     target_media_type: None,
194    /// }];
195    ///
196    /// let source = StaticPayloads::new(payloads);
197    /// assert_eq!(source.all_payloads().len(), 1);
198    /// ```
199    pub fn new(mut payloads: Vec<Payload>) -> Self {
200        sort_payloads_by_category(&mut payloads);
201        Self {
202            category_ranges: build_category_ranges(&payloads),
203            payloads,
204        }
205    }
206
207    /// Add a single payload to this source.
208    ///
209    /// Example:
210    /// ```rust
211    /// use attackstr::{Payload, StaticPayloads};
212    ///
213    /// let mut source = StaticPayloads::default();
214    /// source.add(Payload {
215    ///     text: "test".into(),
216    ///     category: "custom".into(),
217    ///     technique: "manual".into(),
218    ///     context: "default".into(),
219    ///     encoding: "raw".into(),
220    ///     cwe: None,
221    ///     severity: None,
222    ///     confidence: 1.0,
223    ///     expected_pattern: None,
224    ///     target_media_type: None,
225    /// });
226    /// assert_eq!(source.all_payloads().len(), 1);
227    /// ```
228    pub fn add(&mut self, payload: Payload) {
229        self.payloads.push(payload);
230        sort_payloads_by_category(&mut self.payloads);
231        self.category_ranges = build_category_ranges(&self.payloads);
232    }
233
234    /// Get all payloads regardless of category.
235    ///
236    /// Example:
237    /// ```rust
238    /// use attackstr::StaticPayloads;
239    ///
240    /// let source = StaticPayloads::default();
241    /// assert!(source.all_payloads().is_empty());
242    /// ```
243    pub fn all_payloads(&self) -> &[Payload] {
244        &self.payloads
245    }
246
247    /// Iterate over all payloads in this source.
248    ///
249    /// Example:
250    /// ```rust
251    /// use attackstr::StaticPayloads;
252    ///
253    /// let source = StaticPayloads::default();
254    /// assert_eq!(source.iter().count(), 0);
255    /// ```
256    pub fn iter(&self) -> impl Iterator<Item = &Payload> {
257        self.payloads.iter()
258    }
259
260    /// Iterate over payloads for a single category.
261    ///
262    /// Example:
263    /// ```rust
264    /// use attackstr::{Payload, StaticPayloads};
265    ///
266    /// let source = StaticPayloads::new(vec![Payload {
267    ///     text: "alert(1)".into(),
268    ///     category: "xss".into(),
269    ///     technique: "basic".into(),
270    ///     context: "default".into(),
271    ///     encoding: "raw".into(),
272    ///     cwe: None,
273    ///     severity: None,
274    ///     confidence: 1.0,
275    ///     expected_pattern: None,
276    ///     target_media_type: None,
277    /// }]);
278    /// assert_eq!(source.iter_category("xss").count(), 1);
279    /// ```
280    pub fn iter_category<'a>(
281        &'a self,
282        category: &'a str,
283    ) -> impl Iterator<Item = &'a Payload> + 'a {
284        self.payloads
285            .iter()
286            .filter(move |payload| payload.category == category)
287    }
288}
289
290impl std::fmt::Display for StaticPayloads {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        write!(f, "StaticPayloads(count={})", self.payloads.len())
293    }
294}
295
296impl From<Vec<Payload>> for StaticPayloads {
297    fn from(payloads: Vec<Payload>) -> Self {
298        Self::new(payloads)
299    }
300}
301
302impl PayloadSource for StaticPayloads {
303    fn payloads(&mut self, category: &str) -> &[Payload] {
304        if self.category_ranges.is_empty() && !self.payloads.is_empty() {
305            sort_payloads_by_category(&mut self.payloads);
306            self.category_ranges = build_category_ranges(&self.payloads);
307        }
308        self.category_ranges
309            .get(category)
310            .map_or(&[], |range| &self.payloads[range.clone()])
311    }
312
313    fn categories(&self) -> Vec<&str> {
314        use std::collections::HashSet;
315        let mut seen = HashSet::new();
316        self.payloads
317            .iter()
318            .filter_map(|p| {
319                if seen.insert(p.category.clone()) {
320                    Some(p.category.as_str())
321                } else {
322                    None
323                }
324            })
325            .collect()
326    }
327
328    fn payload_count(&self) -> usize {
329        self.payloads.len()
330    }
331}
332
333fn build_category_ranges(payloads: &[Payload]) -> BTreeMap<String, std::ops::Range<usize>> {
334    let mut ranges = BTreeMap::new();
335    let mut start = 0;
336    while start < payloads.len() {
337        let category = payloads[start].category.clone();
338        let mut end = start + 1;
339        while end < payloads.len() && payloads[end].category == category {
340            end += 1;
341        }
342        ranges.insert(category, start..end);
343        start = end;
344    }
345    ranges
346}
347
348/// Configuration for payload generation behavior.
349///
350/// # Thread Safety
351/// `PayloadConfig` is `Send` and `Sync`.
352#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
353pub struct PayloadConfig {
354    /// Maximum payloads per category before truncation (0 = unlimited).
355    pub max_per_category: usize,
356    /// Whether to deduplicate identical payloads within a category.
357    pub deduplicate: bool,
358    /// Default marker prefix for taint tracking (e.g. "SLN").
359    pub marker_prefix: String,
360    /// Categories to exclude from generation (e.g. for compliance).
361    pub exclude_categories: Vec<String>,
362    /// Categories to include exclusively (empty = all).
363    pub include_categories: Vec<String>,
364    /// Restrict loaded grammars to one or more runtimes (empty = all).
365    pub target_runtime: Option<Vec<String>>,
366    /// Where to place the taint marker in generated marker payloads.
367    pub marker_position: MarkerPosition,
368    /// Maximum length of a single payload in bytes (0 = unlimited).
369    pub max_payload_length: usize,
370}
371
372impl PayloadConfig {
373    /// Create a builder for [`PayloadConfig`].
374    ///
375    /// Example:
376    /// ```rust
377    /// use attackstr::PayloadConfig;
378    ///
379    /// let config = PayloadConfig::builder().marker_prefix("TRACE").build();
380    /// assert_eq!(config.marker_prefix, "TRACE");
381    /// ```
382    pub fn builder() -> PayloadConfigBuilder {
383        PayloadConfigBuilder::default()
384    }
385
386    /// Load a [`PayloadConfig`] from a TOML file.
387    ///
388    /// Example:
389    /// ```rust
390    /// use attackstr::PayloadConfig;
391    ///
392    /// let dir = tempfile::tempdir().unwrap();
393    /// let path = dir.path().join("payloads.toml");
394    /// std::fs::write(&path, "marker_prefix = \"TRACE\"\n").unwrap();
395    ///
396    /// let config = PayloadConfig::load(&path).unwrap();
397    /// assert_eq!(config.marker_prefix, "TRACE");
398    /// ```
399    ///
400    /// # Errors
401    /// Returns a [`PayloadError`] if reading or parsing the file fails.
402    pub fn load<P: AsRef<std::path::Path>>(path: P) -> Result<Self, PayloadError> {
403        PayloadConfigFile::load(path)?.into_config()
404    }
405
406    /// Parse a [`PayloadConfig`] directly from TOML text.
407    ///
408    /// Example:
409    /// ```rust
410    /// use attackstr::{MarkerPosition, PayloadConfig};
411    ///
412    /// let config = PayloadConfig::from_toml("marker_position = \"suffix\"", "<inline>").unwrap();
413    /// assert_eq!(config.marker_position, MarkerPosition::Suffix);
414    /// ```
415    ///
416    /// # Errors
417    /// Returns a [`PayloadError`] if parsing the TOML fails.
418    pub fn from_toml(toml_str: &str, source: impl Into<String>) -> Result<Self, PayloadError> {
419        PayloadConfigFile::from_toml(toml_str, source.into())?.into_config()
420    }
421}
422
423impl std::fmt::Display for PayloadConfig {
424    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
425        write!(
426            f,
427            "PayloadConfig(max_per_category={}, deduplicate={}, marker_position={})",
428            self.max_per_category, self.deduplicate, self.marker_position
429        )
430    }
431}
432
433impl Default for PayloadConfig {
434    fn default() -> Self {
435        Self {
436            max_per_category: 0,
437            deduplicate: true,
438            marker_prefix: "SLN".into(),
439            exclude_categories: Vec::new(),
440            include_categories: Vec::new(),
441            target_runtime: None,
442            marker_position: MarkerPosition::Prefix,
443            max_payload_length: 100_000,
444        }
445    }
446}
447
448/// Placement strategy for marker-injected payloads.
449///
450/// # Thread Safety
451/// `MarkerPosition` is `Send` and `Sync`.
452#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
453#[non_exhaustive]
454pub enum MarkerPosition {
455    /// Prepend the marker to the payload text.
456    Prefix,
457    /// Append the marker to the payload text.
458    Suffix,
459    /// Wrap the marker in braces and prepend it inline.
460    Inline,
461    /// Replace `{MARKER}` placeholders in the payload text.
462    Replace(String),
463}
464
465impl std::fmt::Display for MarkerPosition {
466    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467        match self {
468            Self::Prefix => f.write_str("prefix"),
469            Self::Suffix => f.write_str("suffix"),
470            Self::Inline => f.write_str("inline"),
471            Self::Replace(value) => write!(f, "replace:{value}"),
472        }
473    }
474}
475
476/// Builder for [`PayloadConfig`].
477///
478/// # Thread Safety
479/// `PayloadConfigBuilder` is `Send` and `Sync`.
480#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
481pub struct PayloadConfigBuilder {
482    config: PayloadConfig,
483}
484
485impl std::fmt::Display for PayloadConfigBuilder {
486    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487        write!(f, "PayloadConfigBuilder({})", self.config)
488    }
489}
490
491impl PayloadConfigBuilder {
492    /// Set the maximum payload count per category.
493    pub fn max_per_category(mut self, max_per_category: usize) -> Self {
494        self.config.max_per_category = max_per_category;
495        self
496    }
497
498    /// Set whether identical payloads should be deduplicated.
499    pub fn deduplicate(mut self, deduplicate: bool) -> Self {
500        self.config.deduplicate = deduplicate;
501        self
502    }
503
504    /// Set the marker prefix.
505    pub fn marker_prefix(mut self, marker_prefix: impl Into<String>) -> Self {
506        self.config.marker_prefix = marker_prefix.into();
507        self
508    }
509
510    /// Set the categories to exclude.
511    pub fn exclude_categories(mut self, exclude_categories: Vec<String>) -> Self {
512        self.config.exclude_categories = exclude_categories;
513        self
514    }
515
516    /// Set the categories to include.
517    pub fn include_categories(mut self, include_categories: Vec<String>) -> Self {
518        self.config.include_categories = include_categories;
519        self
520    }
521
522    /// Set the allowed target runtimes.
523    pub fn target_runtime(mut self, target_runtime: Option<Vec<String>>) -> Self {
524        self.config.target_runtime = target_runtime;
525        self
526    }
527
528    /// Set the marker placement strategy.
529    pub fn marker_position(mut self, marker_position: MarkerPosition) -> Self {
530        self.config.marker_position = marker_position;
531        self
532    }
533
534    /// Set the maximum length of a single payload in bytes.
535    pub fn max_payload_length(mut self, max_payload_length: usize) -> Self {
536        self.config.max_payload_length = max_payload_length;
537        self
538    }
539
540    /// Build the final [`PayloadConfig`].
541    ///
542    /// Example:
543    /// ```rust
544    /// use attackstr::PayloadConfig;
545    ///
546    /// let config = PayloadConfig::builder().max_per_category(10).build();
547    /// assert_eq!(config.max_per_category, 10);
548    /// ```
549    pub fn build(self) -> PayloadConfig {
550        self.config
551    }
552}
553
554fn sort_payloads_by_category(payloads: &mut [Payload]) {
555    payloads.sort_by(|left, right| {
556        left.category
557            .cmp(&right.category)
558            .then_with(|| left.technique.cmp(&right.technique))
559            .then_with(|| left.context.cmp(&right.context))
560            .then_with(|| left.encoding.cmp(&right.encoding))
561            .then_with(|| left.text.cmp(&right.text))
562    });
563}
564
565/// A generated payload with metadata about its origin.
566///
567/// # Thread Safety
568/// `Payload` is `Send` and `Sync`.
569#[derive(Clone, Debug, Serialize, Deserialize)]
570pub struct Payload {
571    /// The payload string.
572    pub text: String,
573    /// Which category this payload targets (e.g. "sql-injection").
574    pub category: String,
575    /// Which technique generated it (e.g. "union-based").
576    pub technique: String,
577    /// Which context it was generated in (e.g. "string-break").
578    pub context: String,
579    /// Which encoding was applied (e.g. "`url_encode`").
580    pub encoding: String,
581    /// Optional CWE identifier inherited from the grammar.
582    pub cwe: Option<String>,
583    /// Optional severity hint inherited from the grammar.
584    pub severity: Option<String>,
585    /// Confidence score for this payload variant.
586    pub confidence: f64,
587    /// Optional regex pattern expected in the observed response.
588    pub expected_pattern: Option<String>,
589    /// Target media type for correct downstream escaping.
590    #[serde(default)]
591    pub target_media_type: Option<String>,
592}
593
594impl PartialEq for Payload {
595    fn eq(&self, other: &Self) -> bool {
596        self.text == other.text
597            && self.category == other.category
598            && self.technique == other.technique
599            && self.context == other.context
600            && self.encoding == other.encoding
601            && self.cwe == other.cwe
602            && self.severity == other.severity
603            && self.confidence.to_bits() == other.confidence.to_bits()
604            && self.expected_pattern == other.expected_pattern
605            && self.target_media_type == other.target_media_type
606    }
607}
608
609impl Default for Payload {
610    fn default() -> Self {
611        Self {
612            text: String::new(),
613            category: String::new(),
614            technique: String::new(),
615            context: String::new(),
616            encoding: "raw".to_string(),
617            cwe: None,
618            severity: None,
619            confidence: 1.0,
620            expected_pattern: None,
621            target_media_type: None,
622        }
623    }
624}
625
626impl Eq for Payload {}
627
628impl Hash for Payload {
629    fn hash<H: Hasher>(&self, state: &mut H) {
630        self.text.hash(state);
631        self.category.hash(state);
632        self.technique.hash(state);
633        self.context.hash(state);
634        self.encoding.hash(state);
635        self.cwe.hash(state);
636        self.severity.hash(state);
637        self.confidence.to_bits().hash(state);
638        self.expected_pattern.hash(state);
639        self.target_media_type.hash(state);
640    }
641}
642
643impl std::fmt::Display for Payload {
644    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
645        write!(
646            f,
647            "{}:{}:{}:{}",
648            self.category, self.technique, self.context, self.text
649        )
650    }
651}
652
653/// Errors from payload operations.
654///
655/// # Thread Safety
656/// `PayloadError` is `Send` and `Sync`.
657#[derive(Debug, thiserror::Error)]
658#[non_exhaustive]
659pub enum PayloadError {
660    /// Failed to read a file.
661    #[error("{0}. Fix: verify the file or directory exists and that the current process has permission to read it.")]
662    Io(#[from] std::io::Error),
663    /// Failed to parse TOML configuration.
664    #[error("{message}", message = Self::config_parse_message(file, source))]
665    ConfigParse {
666        /// Which config file failed.
667        file: String,
668        /// The parse error.
669        source: Box<toml::de::Error>,
670    },
671    /// Failed to parse TOML grammar.
672    #[error("{message}", message = Self::grammar_parse_message(file, source))]
673    GrammarParse {
674        /// Which file failed.
675        file: String,
676        /// The parse error.
677        source: Box<toml::de::Error>,
678    },
679    /// Grammar parsed but failed semantic validation.
680    #[error("{message}", message = Self::grammar_validation_message(file, issues))]
681    GrammarValidation {
682        /// Which file failed.
683        file: String,
684        /// Structured validation issues collected for the grammar.
685        issues: Vec<GrammarIssue>,
686    },
687    /// Failed to expand template placeholders in a grammar.
688    #[error("{message}", message = Self::template_expansion_message(file, source))]
689    TemplateExpansion {
690        /// Which file failed.
691        file: String,
692        /// The expansion error.
693        source: TemplateExpansionError,
694    },
695    /// Path is not a directory.
696    #[error("path '{0}' is not a directory. Fix: pass a directory that contains `.toml` grammar files or update `grammar_dirs` in your config.")]
697    NotADirectory(String),
698    /// Another directory load is already in progress for this database instance.
699    #[error("payload database load is already in progress. Fix: wait for the current `load_dir` call to finish before starting another one on the same `PayloadDb`.")]
700    ConcurrentLoad,
701    /// Invalid configuration value encountered during conversion.
702    #[error("invalid configuration value: {0}. Fix: check your config file against the supported options.")]
703    InvalidConfig(String),
704}
705
706impl Serialize for PayloadError {
707    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
708    where
709        S: serde::Serializer,
710    {
711        use serde::ser::SerializeMap;
712
713        let mut map = serializer.serialize_map(Some(2))?;
714        map.serialize_entry("kind", self.kind())?;
715        map.serialize_entry("message", &self.to_string())?;
716        map.end()
717    }
718}
719
720impl<'de> Deserialize<'de> for PayloadError {
721    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
722    where
723        D: serde::Deserializer<'de>,
724    {
725        #[derive(Deserialize)]
726        struct PayloadErrorWire {
727            kind: String,
728            message: String,
729        }
730
731        let wire = PayloadErrorWire::deserialize(deserializer)?;
732        Ok(Self::Io(std::io::Error::other(format!(
733            "[{}] {}",
734            wire.kind, wire.message
735        ))))
736    }
737}
738
739impl PayloadError {
740    fn kind(&self) -> &'static str {
741        match self {
742            Self::Io(_) => "io",
743            Self::ConfigParse { .. } => "config_parse",
744            Self::GrammarParse { .. } => "grammar_parse",
745            Self::GrammarValidation { .. } => "grammar_validation",
746            Self::TemplateExpansion { .. } => "template_expansion",
747            Self::NotADirectory(_) => "not_a_directory",
748            Self::ConcurrentLoad => "concurrent_load",
749            Self::InvalidConfig(_) => "invalid_config",
750        }
751    }
752
753    fn config_parse_message(file: &str, source: &toml::de::Error) -> String {
754        format!(
755            "config parse error in {file}: {source}. Fix: make the file valid TOML and keep payload settings at the top level, for example `max_per_category = 100` and `grammar_dirs = [\"./grammars\"]`."
756        )
757    }
758
759    fn grammar_parse_message(file: &str, source: &toml::de::Error) -> String {
760        let detail = source.to_string();
761        let fix = if detail.contains("missing field `grammar`") {
762            "Fix: add a `[grammar]` table with at least `name` and `sink_category`."
763        } else if detail.contains("missing field `name`")
764            || detail.contains("missing field `sink_category`")
765        {
766            "Fix: every grammar needs a `[grammar]` section with both `name` and `sink_category` fields."
767        } else if detail.contains("missing field `template`") {
768            "Fix: every `[[techniques]]` entry needs a `name` and `template`."
769        } else {
770            "Fix: make the file valid TOML and include a `[grammar]` section plus at least one `[[techniques]]` entry."
771        };
772
773        format!("grammar parse error in {file}: {detail}. {fix}")
774    }
775
776    fn template_expansion_message(file: &str, source: &TemplateExpansionError) -> String {
777        let fix = match source {
778            TemplateExpansionError::UnclosedBrace { .. } => {
779                "Fix: close every `{placeholder}` with a matching `}` and escape literal braces by leaving them outside placeholder syntax."
780            }
781            TemplateExpansionError::RecursionLimitExceeded { max_depth } => {
782                return format!(
783                    "template expansion error in {file}: {source}. Fix: remove circular or self-referential variables so expansion stays below the recursion limit of {max_depth}."
784                );
785            }
786            TemplateExpansionError::PayloadLimitExceeded { limit } => {
787                return format!(
788                    "template expansion error in {file}: {source}. Fix: reduce Cartesian product size (contexts x techniques x variables) to stay below the {limit} limit."
789                );
790            }
791            TemplateExpansionError::ExpansionLengthExceeded { max_len } => {
792                return format!(
793                    "template expansion error in {file}: {source}. Fix: remove exponential variable growth so expansion stays below {max_len} bytes."
794                );
795            }
796            TemplateExpansionError::UnknownEncoding { transform } => {
797                return format!(
798                    "template expansion error in {file}: {source}. Fix: register `{transform}` via `PayloadDb::register_encoding`, or replace the reference with a known built-in (raw, url_encode, html_encode, double_url_encode, hex_encode, unicode_escape, base64)."
799                );
800            }
801        };
802
803        format!("template expansion error in {file}: {source}. {fix}")
804    }
805
806    fn grammar_validation_message(file: &str, issues: &[GrammarIssue]) -> String {
807        let issue_count = issues.len();
808        let summary = issues.first().map_or_else(
809            || "unknown validation failure".to_string(),
810            |issue| format!("{}: {}", issue.level, issue.message),
811        );
812        format!(
813            "grammar validation error in {file}: {summary}. Fix: resolve the reported validation issue{plural} before loading the grammar.",
814            plural = if issue_count == 1 { "" } else { "s" }
815        )
816    }
817}
818
819// The README's Rust examples are collected as doctests, so the quick-start
820// can never drift from the API (contract rung: README compiles and runs).
821#[doc = include_str!("../README.md")]
822mod readme_doctests {}