Skip to main content

provenant/license_detection/
automaton.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Aho-Corasick automaton wrapper using daachorse.
5//!
6//! This module provides a `DoubleArrayAhoCorasick`-based automaton that is
7//! significantly smaller than the aho-corasick crate's implementation.
8//! The daachorse library provides ~85% smaller binary size and built-in
9//! serialization support.
10
11use crate::license_detection::models::RuleId;
12use daachorse::DoubleArrayAhoCorasick;
13use rancor::Fallible;
14use rkyv::with::{ArchiveWith, DeserializeWith, SerializeWith};
15use rkyv::{Archive, Deserialize, Place, Serialize};
16
17/// rkyv `with` adapter that archives an `Automaton` as its serialized byte form.
18pub struct AsBytes;
19
20impl ArchiveWith<Automaton> for AsBytes {
21    type Archived = <Vec<u8> as Archive>::Archived;
22    type Resolver = <Vec<u8> as Archive>::Resolver;
23
24    fn resolve_with(field: &Automaton, resolver: Self::Resolver, out: Place<Self::Archived>) {
25        field.serialize_bytes().resolve(resolver, out);
26    }
27}
28
29impl<S: Fallible + rkyv::ser::Writer + rkyv::ser::Allocator + ?Sized> SerializeWith<Automaton, S>
30    for AsBytes
31{
32    fn serialize_with(field: &Automaton, serializer: &mut S) -> Result<Self::Resolver, S::Error> {
33        field.serialize_bytes().serialize(serializer)
34    }
35}
36
37impl<D: Fallible + ?Sized> DeserializeWith<<Vec<u8> as Archive>::Archived, Automaton, D> for AsBytes
38where
39    <Vec<u8> as Archive>::Archived: Deserialize<Vec<u8>, D>,
40    D::Error: rancor::Source,
41{
42    fn deserialize_with(
43        field: &<Vec<u8> as Archive>::Archived,
44        deserializer: &mut D,
45    ) -> Result<Automaton, D::Error> {
46        // This adapter only runs when an on-disk cache file is rkyv-deserialized;
47        // the trusted embedded artifact is rebuilt via the index builder rather
48        // than deserialized. The automaton bytes may therefore be untrusted, so
49        // use the validating daachorse deserializer instead of the unchecked one.
50        let bytes: Vec<u8> = field.deserialize(deserializer)?;
51        Automaton::deserialize(&bytes).map_err(|e| {
52            // `DaachorseError` does not implement `std::error::Error`, so wrap its
53            // message in an `io::Error` to satisfy `rancor::Source::new`.
54            rancor::Source::new(std::io::Error::new(
55                std::io::ErrorKind::InvalidData,
56                format!("invalid serialized automaton: {e}"),
57            ))
58        })
59    }
60}
61
62/// A match found by the automaton.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Match {
65    /// The rule ID associated with the matched pattern.
66    pub rule_id: RuleId,
67    /// Start position in haystack (bytes, inclusive).
68    pub start: usize,
69    /// End position in haystack (bytes, exclusive).
70    pub end: usize,
71}
72
73/// Aho-Corasick automaton using daachorse's double-array implementation.
74///
75/// This wrapper provides the same interface as the previous FrozenNfa
76/// but with significantly smaller memory footprint and serialization support.
77pub struct Automaton {
78    inner: DoubleArrayAhoCorasick<u32>,
79}
80
81impl std::fmt::Debug for Automaton {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("Automaton")
84            .field("num_states", &self.inner.num_states())
85            .field("heap_bytes", &self.inner.heap_bytes())
86            .finish()
87    }
88}
89
90impl Clone for Automaton {
91    fn clone(&self) -> Self {
92        let bytes = self.inner.serialize();
93        Self::deserialize_unchecked(&bytes)
94    }
95}
96
97impl Automaton {
98    /// Create a new empty automaton.
99    ///
100    /// Since daachorse requires at least one non-empty pattern, we use a
101    /// dummy pattern that will never match in practice (a unique byte sequence).
102    pub fn empty() -> Self {
103        // Use a very unlikely byte sequence as a sentinel pattern
104        // This will match but never in our token-encoded data
105        let dummy_pattern: &[u8] = &[0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA, 0xF9, 0xF8];
106        let inner = DoubleArrayAhoCorasick::new([dummy_pattern])
107            .expect("Failed to create empty automaton with hardcoded dummy pattern");
108        Self { inner }
109    }
110
111    /// Find all overlapping matches in the haystack.
112    ///
113    /// Returns an iterator that yields all matches found in the haystack,
114    /// including overlapping matches. The matches are yielded in order of
115    /// their end position.
116    ///
117    /// **Important**: This filters matches to only those starting at even
118    /// byte positions (token boundaries). Each token is encoded as 2 bytes,
119    /// so matches starting at odd byte positions would span token boundaries.
120    pub fn find_overlapping_iter(&self, haystack: &[u8]) -> FindOverlappingIter {
121        FindOverlappingIter::new(&self.inner, haystack)
122    }
123
124    /// Deserialize an automaton from bytes, validating the structure.
125    ///
126    /// Use this for any bytes that are not produced in-process during the same
127    /// run (for example, bytes loaded from an on-disk cache file), since the
128    /// validation rejects malformed input instead of triggering undefined
129    /// behavior.
130    pub fn deserialize(bytes: &[u8]) -> Result<Self, daachorse::errors::DaachorseError> {
131        let (ac, _) = DoubleArrayAhoCorasick::deserialize(bytes)?;
132        Ok(Self { inner: ac })
133    }
134
135    /// Deserialize an automaton from bytes without validation.
136    ///
137    /// # Safety
138    /// The bytes must be valid serialized data from the underlying daachorse
139    /// automaton. Only use this for trusted, in-process bytes (for example,
140    /// round-tripping through [`Automaton::serialize_bytes`] during `Clone`);
141    /// for cache-sourced bytes use [`Automaton::deserialize`] instead.
142    pub fn deserialize_unchecked(bytes: &[u8]) -> Self {
143        let (ac, _) = unsafe { DoubleArrayAhoCorasick::deserialize_unchecked(bytes) };
144        Self { inner: ac }
145    }
146
147    /// Get the number of states in the automaton.
148    pub fn num_states(&self) -> usize {
149        self.inner.num_states()
150    }
151
152    /// Get the memory usage in bytes.
153    pub fn heap_bytes(&self) -> usize {
154        self.inner.heap_bytes()
155    }
156
157    /// Serialize the automaton to a byte vector.
158    pub fn serialize_bytes(&self) -> Vec<u8> {
159        self.inner.serialize()
160    }
161}
162
163impl Default for Automaton {
164    fn default() -> Self {
165        Self::empty()
166    }
167}
168
169/// Iterator over all overlapping matches in a haystack.
170///
171/// This iterator finds all matches, including those that overlap, by
172/// continuing to search after each match rather than skipping past it.
173///
174/// **Token Boundary Filtering**: This iterator only yields matches that
175/// start at even byte positions. Since each token is encoded as 2 bytes,
176/// matches at odd positions would incorrectly span token boundaries.
177pub struct FindOverlappingIter {
178    inner: std::vec::IntoIter<daachorse::Match<u32>>,
179}
180
181impl FindOverlappingIter {
182    fn new(automaton: &DoubleArrayAhoCorasick<u32>, haystack: &[u8]) -> Self {
183        let matches: Vec<_> = automaton.find_overlapping_iter(haystack).collect();
184        Self {
185            inner: matches.into_iter(),
186        }
187    }
188}
189
190impl Iterator for FindOverlappingIter {
191    type Item = Match;
192
193    fn next(&mut self) -> Option<Self::Item> {
194        loop {
195            let m = self.inner.next()?;
196            // Token boundary check: each token is 2 bytes, so matches must
197            // start at even byte positions. Odd positions would span tokens.
198            if m.start() % 2 == 0 {
199                return Some(Match {
200                    rule_id: RuleId::new(m.value() as usize),
201                    start: m.start(),
202                    end: m.end(),
203                });
204            }
205            // Skip matches at odd byte positions (invalid token boundaries)
206        }
207    }
208}
209
210/// Builder for constructing automatons incrementally.
211///
212/// This mirrors the `FrozenNfaBuilder` interface for compatibility.
213pub struct AutomatonBuilder {
214    patterns: Vec<Vec<u8>>,
215    values: Vec<u32>,
216}
217
218impl AutomatonBuilder {
219    /// Create a new builder.
220    pub fn new() -> Self {
221        Self {
222            patterns: Vec::new(),
223            values: Vec::new(),
224        }
225    }
226
227    /// Add a pattern to the automaton with an associated value.
228    ///
229    /// Empty patterns are skipped. Daachorse 3.0+ supports duplicate patterns;
230    /// each occurrence gets its own value.
231    pub fn add_pattern_with_value(&mut self, pattern: &[u8], value: u32) {
232        if !pattern.is_empty() {
233            self.patterns.push(pattern.to_vec());
234            self.values.push(value);
235        }
236    }
237
238    /// Add a pattern to the automaton.
239    ///
240    /// Empty patterns are skipped. Assigns sequential IDs (0, 1, 2, ...).
241    pub fn add_pattern(&mut self, pattern: &[u8]) {
242        let value = self.patterns.len() as u32;
243        self.add_pattern_with_value(pattern, value);
244    }
245
246    /// Build the automaton.
247    ///
248    /// Uses `with_values()` so each pattern's value is directly accessible
249    /// via `Match::value()`, eliminating the need for an external pattern_id-to-rid mapping.
250    pub fn build(self) -> Automaton {
251        if self.patterns.is_empty() {
252            return Automaton::empty();
253        }
254
255        let patvals: Vec<(&[u8], u32)> = self
256            .patterns
257            .iter()
258            .zip(self.values.iter())
259            .map(|(p, &v)| (p.as_slice(), v))
260            .collect();
261
262        match DoubleArrayAhoCorasick::with_values(patvals) {
263            Ok(ac) => Automaton { inner: ac },
264            Err(_) => Automaton::empty(),
265        }
266    }
267}
268
269impl Default for AutomatonBuilder {
270    fn default() -> Self {
271        Self::new()
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_token_boundary_filtering() {
281        let pattern: &[u8] = &[31, 49];
282        let mut builder = AutomatonBuilder::new();
283        builder.add_pattern(pattern);
284        let ac = builder.build();
285
286        // The pattern [31, 49] appears at bytes 1-2 (odd position)
287        // which would span token boundaries - should NOT match
288        let haystack: &[u8] = &[109, 31, 49, 74];
289        let matches: Vec<_> = ac.find_overlapping_iter(haystack).collect();
290        assert!(
291            matches.is_empty(),
292            "Should not match across token boundaries"
293        );
294    }
295
296    #[test]
297    fn test_valid_token_match() {
298        let pattern: &[u8] = &[31, 49];
299        let mut builder = AutomatonBuilder::new();
300        builder.add_pattern(pattern);
301        let ac = builder.build();
302
303        let haystack: &[u8] = &[0, 0, 31, 49, 0, 0];
304        let matches: Vec<_> = ac.find_overlapping_iter(haystack).collect();
305        assert_eq!(matches.len(), 1);
306        assert_eq!(matches[0].start, 2);
307        assert_eq!(matches[0].end, 4);
308    }
309
310    #[test]
311    fn test_builder_skips_empty_patterns() {
312        let mut builder = AutomatonBuilder::new();
313        builder.add_pattern(b"");
314        builder.add_pattern(b"hello");
315        builder.add_pattern(b"");
316        let ac = builder.build();
317
318        let matches: Vec<_> = ac.find_overlapping_iter(b"hello").collect();
319        assert_eq!(matches.len(), 1);
320    }
321
322    #[test]
323    fn test_builder_with_values() {
324        let mut builder = AutomatonBuilder::new();
325        builder.add_pattern_with_value(b"hello", 42);
326        builder.add_pattern_with_value(b"world", 99);
327        let ac = builder.build();
328
329        let matches: Vec<_> = ac.find_overlapping_iter(b"hello world").collect();
330        assert_eq!(matches.len(), 2);
331        assert_eq!(matches[0].rule_id, RuleId::new(42));
332        assert_eq!(matches[1].rule_id, RuleId::new(99));
333    }
334
335    #[test]
336    fn test_builder_duplicate_patterns() {
337        let mut builder = AutomatonBuilder::new();
338        builder.add_pattern_with_value(b"hello", 10);
339        builder.add_pattern_with_value(b"hello", 20);
340        let ac = builder.build();
341
342        let matches: Vec<_> = ac.find_overlapping_iter(b"hello").collect();
343        assert_eq!(matches.len(), 2);
344        let mut values: Vec<RuleId> = matches.iter().map(|m| m.rule_id).collect();
345        values.sort();
346        assert_eq!(values, vec![RuleId::new(10), RuleId::new(20)]);
347    }
348}