Skip to main content

threatflux_string_analysis/
tracker.rs

1//! Bounded, concurrent string tracking and aggregate analysis.
2
3use crate::analyzer::{
4    DefaultStringAnalyzer, MIN_ENTROPY_INPUT_BYTES, StringAnalysis, StringAnalyzer,
5    SuspiciousIndicator,
6};
7use crate::categorizer::{Categorizer, DefaultCategorizer, StringCategory, validate_category};
8use crate::patterns::{DefaultPatternProvider, PatternProvider};
9use crate::types::{
10    AnalysisConfig, AnalysisError, AnalysisResult, MAX_DESCRIPTION_BYTES, MAX_REGEX_BYTES,
11    compact_string, regex_error_reason, validate_identifier,
12};
13use chrono::{DateTime, Utc};
14use regex::{Regex, RegexBuilder};
15use serde::{Deserialize, Serialize};
16use std::cmp::{Ordering, Reverse};
17use std::collections::{BTreeMap, BTreeSet, VecDeque};
18use std::sync::{Arc, Mutex, MutexGuard};
19
20const MOST_COMMON_LIMIT: usize = 100;
21const SUSPICIOUS_SAMPLE_LIMIT: usize = 50;
22const HIGH_ENTROPY_SAMPLE_LIMIT: usize = 50;
23const FILTER_REGEX_COMPILED_SIZE_LIMIT: usize = 2 * 1024 * 1024;
24const FILTER_REGEX_DFA_SIZE_LIMIT: usize = 4 * 1024 * 1024;
25const INDICATOR_MATCHED_TEXT_LIMIT: usize = 512;
26const MIN_RELATED_SCORE: f64 = 0.30;
27
28type StringEntryMap = Arc<Mutex<BTreeMap<String, StringEntry>>>;
29
30/// Inclusive range applied to an entry's `first_seen` timestamp.
31pub type DateTimeRange = (DateTime<Utc>, DateTime<Utc>);
32
33/// Context in which a string was observed.
34#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub enum StringContext {
37    /// String found in file content.
38    FileString {
39        /// Byte offset within the source file.
40        offset: Option<u64>,
41    },
42    /// String found in an import table or dependency list.
43    Import {
44        /// Imported library or module.
45        library: String,
46    },
47    /// String found in an export table.
48    Export {
49        /// Exported symbol or function.
50        symbol: String,
51    },
52    /// String found in an embedded resource.
53    Resource {
54        /// Resource type.
55        resource_type: String,
56    },
57    /// String found in a file section.
58    Section {
59        /// Section name.
60        section_name: String,
61    },
62    /// String found in file metadata.
63    Metadata {
64        /// Metadata field name.
65        field: String,
66    },
67    /// String representing a file-system path.
68    Path {
69        /// Application-defined path classification.
70        path_type: String,
71    },
72    /// String representing a URL.
73    Url {
74        /// URL protocol, when known.
75        protocol: Option<String>,
76    },
77    /// String representing a Windows registry key.
78    Registry {
79        /// Registry hive, when known.
80        hive: Option<String>,
81    },
82    /// String representing a command or script.
83    Command {
84        /// Application-defined command classification.
85        command_type: String,
86    },
87    /// Application-defined context.
88    Other {
89        /// Category name added to the aggregate entry.
90        category: String,
91    },
92}
93
94impl StringContext {
95    fn category_name(&self) -> &str {
96        match self {
97            Self::FileString { .. } => "file_string",
98            Self::Import { .. } => "import",
99            Self::Export { .. } => "export",
100            Self::Resource { .. } => "resource",
101            Self::Section { .. } => "section",
102            Self::Metadata { .. } => "metadata",
103            Self::Path { .. } => "path",
104            Self::Url { .. } => "url",
105            Self::Registry { .. } => "registry",
106            Self::Command { .. } => "command",
107            Self::Other { category } => category,
108        }
109    }
110
111    fn owned_field(&self) -> Option<(&'static str, &str)> {
112        match self {
113            Self::FileString { .. } => None,
114            Self::Import { library } => Some(("context.import.library", library)),
115            Self::Export { symbol } => Some(("context.export.symbol", symbol)),
116            Self::Resource { resource_type } => {
117                Some(("context.resource.resource_type", resource_type))
118            }
119            Self::Section { section_name } => Some(("context.section.section_name", section_name)),
120            Self::Metadata { field } => Some(("context.metadata.field", field)),
121            Self::Path { path_type } => Some(("context.path.path_type", path_type)),
122            Self::Url { protocol } => protocol
123                .as_deref()
124                .map(|value| ("context.url.protocol", value)),
125            Self::Registry { hive } => hive
126                .as_deref()
127                .map(|value| ("context.registry.hive", value)),
128            Self::Command { command_type } => Some(("context.command.command_type", command_type)),
129            Self::Other { category } => Some(("context.other.category", category)),
130        }
131    }
132
133    fn compact(&mut self) {
134        match self {
135            Self::FileString { .. } => {}
136            Self::Import { library } => compact_in_place(library),
137            Self::Export { symbol } => compact_in_place(symbol),
138            Self::Resource { resource_type } => compact_in_place(resource_type),
139            Self::Section { section_name } => compact_in_place(section_name),
140            Self::Metadata { field } => compact_in_place(field),
141            Self::Path { path_type } => compact_in_place(path_type),
142            Self::Url { protocol } => {
143                if let Some(protocol) = protocol {
144                    compact_in_place(protocol);
145                }
146            }
147            Self::Registry { hive } => {
148                if let Some(hive) = hive {
149                    compact_in_place(hive);
150                }
151            }
152            Self::Command { command_type } => compact_in_place(command_type),
153            Self::Other { category } => compact_in_place(category),
154        }
155    }
156}
157
158/// Stable identity of a source file.
159#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct FileIdentity {
162    /// Source path supplied by the caller.
163    pub file_path: String,
164    /// Source content hash or other caller-defined stable identifier.
165    pub file_hash: String,
166}
167
168/// Record of one string occurrence.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct StringOccurrence {
172    /// Path to the source file.
173    pub file_path: String,
174    /// Hash or stable identity of the source file.
175    pub file_hash: String,
176    /// Tool that produced the observation.
177    pub tool_name: String,
178    /// Caller-supplied event timestamp.
179    pub timestamp: DateTime<Utc>,
180    /// Context in which the value was observed.
181    pub context: StringContext,
182}
183
184impl StringOccurrence {
185    /// Construct an occurrence with an explicit event timestamp.
186    pub fn new(
187        file_path: impl Into<String>,
188        file_hash: impl Into<String>,
189        tool_name: impl Into<String>,
190        timestamp: DateTime<Utc>,
191        context: StringContext,
192    ) -> Self {
193        let mut context = context;
194        context.compact();
195        Self {
196            file_path: compact_string(file_path.into()),
197            file_hash: compact_string(file_hash.into()),
198            tool_name: compact_string(tool_name.into()),
199            timestamp,
200            context,
201        }
202    }
203
204    fn file_identity(&self) -> FileIdentity {
205        FileIdentity {
206            file_path: self.file_path.as_str().to_string(),
207            file_hash: self.file_hash.as_str().to_string(),
208        }
209    }
210
211    fn compact(&mut self) {
212        compact_in_place(&mut self.file_path);
213        compact_in_place(&mut self.file_hash);
214        compact_in_place(&mut self.tool_name);
215        self.context.compact();
216    }
217}
218
219/// Aggregate information retained for one distinct string value.
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221#[serde(deny_unknown_fields)]
222pub struct StringEntry {
223    /// Actual string value.
224    pub value: String,
225    /// Earliest caller-supplied event timestamp.
226    pub first_seen: DateTime<Utc>,
227    /// Latest caller-supplied event timestamp.
228    pub last_seen: DateTime<Utc>,
229    /// Saturating total observation count, including evicted occurrence details.
230    pub total_occurrences: u64,
231    /// Bounded, ordered set of distinct source identities.
232    pub unique_file_identities: BTreeSet<FileIdentity>,
233    /// Bounded occurrence details in ingestion order, oldest retained first.
234    pub occurrences: VecDeque<StringOccurrence>,
235    /// Bounded, deterministically ordered aggregate category names.
236    pub categories: BTreeSet<String>,
237    /// Whether the analyzer reported a suspicious signal.
238    pub is_suspicious: bool,
239    /// Shannon entropy calculated over UTF-8 bytes.
240    pub entropy: f64,
241    /// Bounded explainable evidence retained from initial analysis.
242    pub suspicious_indicators: Vec<SuspiciousIndicator>,
243    /// Whether analyzer evidence was omitted or unavailable as retained detail.
244    pub indicators_truncated: bool,
245}
246
247/// Statistics about a filtered tracker snapshot.
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct StringStatistics {
251    /// Number of distinct values matching the filter.
252    pub total_unique_strings: u64,
253    /// Saturating total occurrences for matching values.
254    pub total_occurrences: u64,
255    /// Number of distinct `(file_path, file_hash)` identities represented.
256    pub total_files_analyzed: u64,
257    /// Up to 100 values ordered by occurrence count descending, then value ascending.
258    pub most_common: Vec<(String, u64)>,
259    /// Total number of suspicious values before sampling.
260    pub total_suspicious_strings: u64,
261    /// Up to 50 suspicious values ordered by severity, count, then value.
262    pub suspicious_strings: Vec<String>,
263    /// Total number of values meeting the tracker's entropy threshold.
264    pub total_high_entropy_strings: u64,
265    /// Up to 50 high-entropy values ordered by entropy descending, then value.
266    pub high_entropy_strings: Vec<(String, f64)>,
267    /// Count of matching values in each category.
268    pub category_distribution: BTreeMap<String, u64>,
269    /// Distribution by UTF-8 byte length.
270    pub length_distribution: BTreeMap<String, u64>,
271}
272
273/// Filter criteria applied to statistics.
274#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
275#[serde(default, deny_unknown_fields)]
276pub struct StringFilter {
277    /// Minimum aggregate observation count.
278    pub min_occurrences: Option<u64>,
279    /// Maximum aggregate observation count.
280    pub max_occurrences: Option<u64>,
281    /// Minimum UTF-8 byte length.
282    pub min_length: Option<usize>,
283    /// Maximum UTF-8 byte length.
284    pub max_length: Option<usize>,
285    /// Match entries containing at least one listed category.
286    pub categories: Option<Vec<String>>,
287    /// Match entries observed at least once at a listed file path.
288    pub file_paths: Option<Vec<String>>,
289    /// Match entries observed at least once with a listed file hash.
290    pub file_hashes: Option<Vec<String>>,
291    /// When `Some(true)`, match only suspicious values; `false` does not filter.
292    pub suspicious_only: Option<bool>,
293    /// Regular expression applied to the full string value.
294    pub regex_pattern: Option<String>,
295    /// Minimum byte entropy.
296    pub min_entropy: Option<f64>,
297    /// Maximum byte entropy.
298    pub max_entropy: Option<f64>,
299    /// Inclusive range applied to `first_seen`.
300    pub date_range: Option<DateTimeRange>,
301}
302
303/// Thread-safe, bounded in-memory string tracker.
304#[derive(Clone)]
305pub struct StringTracker {
306    entries: StringEntryMap,
307    analyzer: Arc<dyn StringAnalyzer>,
308    categorizer: Arc<dyn Categorizer>,
309    config: Arc<AnalysisConfig>,
310}
311
312impl Default for StringTracker {
313    fn default() -> Self {
314        Self::new()
315    }
316}
317
318impl StringTracker {
319    /// Create a tracker with validated defaults and built-in heuristics.
320    pub fn new() -> Self {
321        match Self::with_config(AnalysisConfig::default()) {
322            Ok(tracker) => tracker,
323            Err(error) => panic!("built-in tracker defaults must be valid: {error}"),
324        }
325    }
326
327    /// Create a tracker with built-in components and validated limits.
328    pub fn with_config(config: AnalysisConfig) -> AnalysisResult<Self> {
329        config.validate()?;
330        let patterns = DefaultPatternProvider::new()?.get_patterns();
331        let analyzer = DefaultStringAnalyzer::new()
332            .with_entropy_threshold(config.min_suspicious_entropy)?
333            .with_max_indicators(config.max_indicators_per_string)?
334            .with_patterns(patterns)?;
335        Self::with_components_and_config(
336            Box::new(analyzer),
337            Box::new(DefaultCategorizer::new()),
338            config,
339        )
340    }
341
342    /// Create a tracker with custom components and default limits.
343    pub fn with_components(
344        analyzer: Box<dyn StringAnalyzer>,
345        categorizer: Box<dyn Categorizer>,
346    ) -> Self {
347        match Self::with_components_and_config(analyzer, categorizer, AnalysisConfig::default()) {
348            Ok(tracker) => tracker,
349            Err(error) => panic!("built-in tracker defaults must be valid: {error}"),
350        }
351    }
352
353    /// Create a tracker with custom components and validated limits.
354    pub fn with_components_and_config(
355        analyzer: Box<dyn StringAnalyzer>,
356        categorizer: Box<dyn Categorizer>,
357        config: AnalysisConfig,
358    ) -> AnalysisResult<Self> {
359        config.validate()?;
360        Ok(Self {
361            entries: Arc::new(Mutex::new(BTreeMap::new())),
362            analyzer: Arc::from(analyzer),
363            categorizer: Arc::from(categorizer),
364            config: Arc::new(config),
365        })
366    }
367
368    /// Return this tracker's immutable configuration.
369    pub fn config(&self) -> &AnalysisConfig {
370        &self.config
371    }
372
373    /// Track an occurrence using the current UTC time.
374    pub fn track_string(
375        &self,
376        value: &str,
377        file_path: &str,
378        file_hash: &str,
379        tool_name: &str,
380        context: StringContext,
381    ) -> AnalysisResult<()> {
382        self.validate_tracking_fields(value, file_path, file_hash, tool_name, &context)?;
383        self.track_occurrence(
384            value,
385            StringOccurrence::new(file_path, file_hash, tool_name, Utc::now(), context),
386        )
387    }
388
389    /// Track an occurrence with a caller-supplied event timestamp.
390    pub fn track_occurrence(
391        &self,
392        value: &str,
393        occurrence: StringOccurrence,
394    ) -> AnalysisResult<()> {
395        self.track_occurrence_with_categories(value, occurrence, None)
396    }
397
398    fn track_occurrence_with_categories(
399        &self,
400        value: &str,
401        mut occurrence: StringOccurrence,
402        precomputed_categories: Option<Vec<StringCategory>>,
403    ) -> AnalysisResult<()> {
404        self.validate_tracking_input(value, &occurrence)?;
405        occurrence.compact();
406        let context_category = occurrence.context.category_name().to_string();
407
408        // Fast path for an existing entry. No extension code runs while locked.
409        {
410            let mut entries = self.lock_entries();
411            if let Some(entry) = entries.get_mut(value) {
412                return self.update_existing_entry(entry, occurrence, context_category);
413            }
414            if entries.len() >= self.config.max_unique_strings {
415                return Err(AnalysisError::CapacityExceeded {
416                    resource: "unique strings",
417                    limit: self.config.max_unique_strings,
418                });
419            }
420        }
421
422        // Custom extension code deliberately runs outside the state mutex.
423        let categorized = match precomputed_categories {
424            Some(categories) => categories,
425            None => self.normalize_categories(self.categorizer.categorize(value))?,
426        };
427        let analysis = self.normalize_analysis(self.analyzer.analyze(value))?;
428        let categories = self.aggregate_categories(&analysis, &categorized, &context_category)?;
429
430        // Another thread may have inserted this value while analysis ran.
431        let mut entries = self.lock_entries();
432        if let Some(entry) = entries.get_mut(value) {
433            return self.update_existing_entry(entry, occurrence, context_category);
434        }
435        if entries.len() >= self.config.max_unique_strings {
436            return Err(AnalysisError::CapacityExceeded {
437                resource: "unique strings",
438                limit: self.config.max_unique_strings,
439            });
440        }
441
442        let timestamp = occurrence.timestamp;
443        let identity = occurrence.file_identity();
444        // Do not reserve the configured maximum for every distinct value. The
445        // collection grows with actual observations and remains bounded below.
446        let mut occurrences = VecDeque::new();
447        occurrences.push_back(occurrence);
448        entries.insert(
449            value.to_string(),
450            StringEntry {
451                value: value.to_string(),
452                first_seen: timestamp,
453                last_seen: timestamp,
454                total_occurrences: 1,
455                unique_file_identities: BTreeSet::from([identity]),
456                occurrences,
457                categories,
458                is_suspicious: analysis.is_suspicious,
459                entropy: analysis.entropy,
460                suspicious_indicators: analysis.suspicious_indicators,
461                indicators_truncated: analysis.indicators_truncated,
462            },
463        );
464        Ok(())
465    }
466
467    /// Track a batch and infer a context for each value.
468    ///
469    /// The operation is sequential rather than transactional: if a later value
470    /// fails validation or reaches capacity, earlier values remain tracked.
471    pub fn track_strings(
472        &self,
473        strings: &[String],
474        file_path: &str,
475        file_hash: &str,
476        tool_name: &str,
477    ) -> AnalysisResult<()> {
478        for value in strings {
479            // Validate all direct caller input before executing extension code.
480            self.validate_bytes("value", value, self.config.max_input_bytes)?;
481            self.validate_bytes("file_path", file_path, self.config.max_source_bytes)?;
482            self.validate_bytes("file_hash", file_hash, self.config.max_source_bytes)?;
483            self.validate_bytes("tool_name", tool_name, self.config.max_source_bytes)?;
484            let categories = self.normalize_categories(self.categorizer.categorize(value))?;
485            let context = inferred_context(value, &categories, self.config.max_source_bytes)?;
486            self.track_occurrence_with_categories(
487                value,
488                StringOccurrence::new(file_path, file_hash, tool_name, Utc::now(), context),
489                Some(categories),
490            )?;
491        }
492        Ok(())
493    }
494
495    /// Compatibility alias for [`StringTracker::track_strings`].
496    pub fn track_strings_from_results(
497        &self,
498        strings: &[String],
499        file_path: &str,
500        file_hash: &str,
501        tool_name: &str,
502    ) -> AnalysisResult<()> {
503        self.track_strings(strings, file_path, file_hash, tool_name)
504    }
505
506    /// Calculate deterministic statistics for an optional validated filter.
507    pub fn get_statistics(
508        &self,
509        filter: Option<&StringFilter>,
510    ) -> AnalysisResult<StringStatistics> {
511        let compiled_filter = CompiledFilter::new(filter, &self.config)?;
512        let entries = self.lock_entries();
513        // Aggregate over borrowed entries while holding the snapshot lock.
514        // Only bounded top-k buffers and exact borrowed-key accumulators grow;
515        // payload strings are cloned once, after the final samples are known.
516        let mut total_unique_strings = 0u64;
517        let mut total_occurrences = 0u64;
518        let mut file_identities: BTreeSet<&FileIdentity> = BTreeSet::new();
519        let mut most_common = Vec::with_capacity(MOST_COMMON_LIMIT + 1);
520        let mut total_suspicious_strings = 0u64;
521        let mut suspicious = Vec::with_capacity(SUSPICIOUS_SAMPLE_LIMIT + 1);
522        let mut total_high_entropy_strings = 0u64;
523        let mut high_entropy = Vec::with_capacity(HIGH_ENTROPY_SAMPLE_LIMIT + 1);
524        let mut category_counts: BTreeMap<&str, u64> = BTreeMap::new();
525        let mut length_counts: BTreeMap<&'static str, u64> = BTreeMap::new();
526
527        for entry in entries
528            .values()
529            .filter(|entry| compiled_filter.matches(entry))
530        {
531            total_unique_strings = total_unique_strings.saturating_add(1);
532            total_occurrences = total_occurrences.saturating_add(entry.total_occurrences);
533            file_identities.extend(entry.unique_file_identities.iter());
534            insert_bounded_sorted(&mut most_common, entry, MOST_COMMON_LIMIT, |left, right| {
535                right
536                    .total_occurrences
537                    .cmp(&left.total_occurrences)
538                    .then_with(|| left.value.cmp(&right.value))
539            });
540
541            if entry.is_suspicious {
542                total_suspicious_strings = total_suspicious_strings.saturating_add(1);
543                let severity = entry
544                    .suspicious_indicators
545                    .iter()
546                    .map(|indicator| indicator.severity)
547                    .max()
548                    .unwrap_or(0);
549                insert_bounded_sorted(
550                    &mut suspicious,
551                    (entry, severity),
552                    SUSPICIOUS_SAMPLE_LIMIT,
553                    |left, right| {
554                        right
555                            .1
556                            .cmp(&left.1)
557                            .then_with(|| right.0.total_occurrences.cmp(&left.0.total_occurrences))
558                            .then_with(|| left.0.value.cmp(&right.0.value))
559                    },
560                );
561            }
562
563            if entry.value.len() >= MIN_ENTROPY_INPUT_BYTES
564                && entry.entropy >= self.config.min_suspicious_entropy
565            {
566                total_high_entropy_strings = total_high_entropy_strings.saturating_add(1);
567                insert_bounded_sorted(
568                    &mut high_entropy,
569                    entry,
570                    HIGH_ENTROPY_SAMPLE_LIMIT,
571                    |left, right| {
572                        right
573                            .entropy
574                            .total_cmp(&left.entropy)
575                            .then_with(|| left.value.cmp(&right.value))
576                    },
577                );
578            }
579
580            for category in &entry.categories {
581                let count = category_counts.entry(category).or_insert(0u64);
582                *count = count.saturating_add(1);
583            }
584            let bucket = match entry.value.len() {
585                0..=10 => "0-10",
586                11..=20 => "11-20",
587                21..=50 => "21-50",
588                51..=100 => "51-100",
589                101..=200 => "101-200",
590                _ => "201+",
591            };
592            let count = length_counts.entry(bucket).or_insert(0u64);
593            *count = count.saturating_add(1);
594        }
595
596        let total_files_analyzed = usize_to_u64(file_identities.len());
597        let most_common = most_common
598            .into_iter()
599            .map(|entry| (entry.value.clone(), entry.total_occurrences))
600            .collect();
601        let suspicious_strings = suspicious
602            .into_iter()
603            .map(|(entry, _)| entry.value.clone())
604            .collect();
605        let high_entropy_strings = high_entropy
606            .into_iter()
607            .map(|entry| (entry.value.clone(), entry.entropy))
608            .collect();
609        let category_distribution = category_counts
610            .into_iter()
611            .map(|(category, count)| (category.to_string(), count))
612            .collect();
613        let length_distribution = length_counts
614            .into_iter()
615            .map(|(bucket, count)| (bucket.to_string(), count))
616            .collect();
617
618        Ok(StringStatistics {
619            total_unique_strings,
620            total_occurrences,
621            total_files_analyzed,
622            most_common,
623            total_suspicious_strings,
624            suspicious_strings,
625            total_high_entropy_strings,
626            high_entropy_strings,
627            category_distribution,
628            length_distribution,
629        })
630    }
631
632    /// Return a cloned entry for an exact value.
633    pub fn get_string_details(&self, value: &str) -> Option<StringEntry> {
634        self.lock_entries().get(value).cloned()
635    }
636
637    /// Search values using Unicode lowercase substring matching.
638    pub fn search_strings(&self, query: &str, limit: usize) -> AnalysisResult<Vec<StringEntry>> {
639        self.validate_bytes("query", query, self.config.max_input_bytes)?;
640        if query.trim().is_empty() || limit == 0 {
641            return Ok(Vec::new());
642        }
643
644        let query_lower = query.to_lowercase();
645        let entries = self.lock_entries();
646        let mut matches: Vec<_> = entries
647            .values()
648            .filter(|entry| entry.value.to_lowercase().contains(&query_lower))
649            .collect();
650        matches.sort_by(|left, right| {
651            right
652                .total_occurrences
653                .cmp(&left.total_occurrences)
654                .then_with(|| left.value.cmp(&right.value))
655        });
656        matches.truncate(limit);
657        Ok(matches.into_iter().cloned().collect())
658    }
659
660    /// Return related values using a deterministic, fixed-denominator score.
661    pub fn get_related_strings(
662        &self,
663        value: &str,
664        limit: usize,
665    ) -> AnalysisResult<Vec<(String, f64)>> {
666        self.validate_bytes("value", value, self.config.max_input_bytes)?;
667        if limit == 0 {
668            return Ok(Vec::new());
669        }
670
671        let entries = self.lock_entries();
672        let Some(target) = entries.get(value) else {
673            return Ok(Vec::new());
674        };
675        let mut related: Vec<_> = entries
676            .iter()
677            .filter(|(candidate, _)| candidate.as_str() != value)
678            .filter_map(|(candidate, entry)| {
679                let score = calculate_similarity(target, entry);
680                (score >= MIN_RELATED_SCORE).then_some((candidate.as_str(), score))
681            })
682            .collect();
683        related.sort_by(|left, right| right.1.total_cmp(&left.1).then_with(|| left.0.cmp(right.0)));
684        related.truncate(limit);
685        Ok(related
686            .into_iter()
687            .map(|(candidate, score)| (candidate.to_string(), score))
688            .collect())
689    }
690
691    /// Clear all retained state without changing configuration or components.
692    pub fn clear(&self) {
693        self.lock_entries().clear();
694    }
695
696    fn update_existing_entry(
697        &self,
698        entry: &mut StringEntry,
699        occurrence: StringOccurrence,
700        context_category: String,
701    ) -> AnalysisResult<()> {
702        let identity = occurrence.file_identity();
703        if !entry.unique_file_identities.contains(&identity)
704            && entry.unique_file_identities.len()
705                >= self.config.max_unique_file_identities_per_string
706        {
707            return Err(AnalysisError::CapacityExceeded {
708                resource: "unique file identities per string",
709                limit: self.config.max_unique_file_identities_per_string,
710            });
711        }
712        if !entry.categories.contains(&context_category)
713            && entry.categories.len() >= self.config.max_categories_per_string
714        {
715            return Err(AnalysisError::CapacityExceeded {
716                resource: "categories per string",
717                limit: self.config.max_categories_per_string,
718            });
719        }
720
721        entry.first_seen = entry.first_seen.min(occurrence.timestamp);
722        entry.last_seen = entry.last_seen.max(occurrence.timestamp);
723        entry.total_occurrences = entry.total_occurrences.saturating_add(1);
724        entry.unique_file_identities.insert(identity);
725        entry.categories.insert(context_category);
726        entry.occurrences.push_back(occurrence);
727        while entry.occurrences.len() > self.config.max_occurrences_per_string {
728            entry.occurrences.pop_front();
729        }
730        Ok(())
731    }
732
733    fn validate_tracking_input(
734        &self,
735        value: &str,
736        occurrence: &StringOccurrence,
737    ) -> AnalysisResult<()> {
738        self.validate_tracking_fields(
739            value,
740            &occurrence.file_path,
741            &occurrence.file_hash,
742            &occurrence.tool_name,
743            &occurrence.context,
744        )
745    }
746
747    fn validate_tracking_fields(
748        &self,
749        value: &str,
750        file_path: &str,
751        file_hash: &str,
752        tool_name: &str,
753        context: &StringContext,
754    ) -> AnalysisResult<()> {
755        self.validate_bytes("value", value, self.config.max_input_bytes)?;
756        self.validate_bytes("file_path", file_path, self.config.max_source_bytes)?;
757        self.validate_bytes("file_hash", file_hash, self.config.max_source_bytes)?;
758        self.validate_bytes("tool_name", tool_name, self.config.max_source_bytes)?;
759        if let Some((field, source_value)) = context.owned_field() {
760            self.validate_bytes(field, source_value, self.config.max_source_bytes)?;
761        }
762        if let StringContext::Other { category } = context {
763            validate_identifier("context category", category)?;
764        }
765        Ok(())
766    }
767
768    fn validate_bytes(&self, field: &'static str, value: &str, limit: usize) -> AnalysisResult<()> {
769        if value.len() > limit {
770            Err(AnalysisError::InputTooLarge {
771                field,
772                actual: value.len(),
773                limit,
774            })
775        } else {
776            Ok(())
777        }
778    }
779
780    fn normalize_analysis(&self, mut analysis: StringAnalysis) -> AnalysisResult<StringAnalysis> {
781        if !analysis.entropy.is_finite() || !(0.0..=8.0).contains(&analysis.entropy) {
782            return Err(AnalysisError::InvalidComponentOutput {
783                field: "analysis.entropy",
784                reason: "must be finite and between 0 and 8 inclusive".to_string(),
785            });
786        }
787        if analysis.categories.len() > self.config.max_categories_per_string {
788            return Err(AnalysisError::CapacityExceeded {
789                resource: "analyzer categories per string",
790                limit: self.config.max_categories_per_string,
791            });
792        }
793        for category in &analysis.categories {
794            validate_identifier("analyzer category", category)?;
795        }
796        analysis.categories = analysis
797            .categories
798            .into_iter()
799            .map(compact_string)
800            .collect();
801
802        let (retained_indicators, truncated_by_limit) = retain_highest_severity_indicators(
803            analysis.suspicious_indicators,
804            self.config.max_indicators_per_string,
805        );
806        analysis.indicators_truncated |= truncated_by_limit;
807        let mut normalized_indicators = Vec::with_capacity(retained_indicators.len());
808        for mut indicator in retained_indicators {
809            validate_identifier("indicator pattern", &indicator.pattern_name)?;
810            if indicator.severity > 10 {
811                return Err(AnalysisError::InvalidSeverity {
812                    severity: indicator.severity,
813                });
814            }
815            if indicator.description.len() > MAX_DESCRIPTION_BYTES {
816                return Err(AnalysisError::InputTooLarge {
817                    field: "indicator.description",
818                    actual: indicator.description.len(),
819                    limit: MAX_DESCRIPTION_BYTES,
820                });
821            }
822            if indicator.description.trim().is_empty() {
823                return Err(AnalysisError::InvalidComponentOutput {
824                    field: "indicator.description",
825                    reason: "must not be empty or whitespace-only".to_string(),
826                });
827            }
828            if indicator.description.chars().any(char::is_control) {
829                return Err(AnalysisError::InvalidComponentOutput {
830                    field: "indicator.description",
831                    reason: "must not contain control characters".to_string(),
832                });
833            }
834            indicator.pattern_name = compact_string(indicator.pattern_name);
835            indicator.description = compact_string(indicator.description);
836            if let Some(matched_text) = indicator.matched_text.take() {
837                let (matched_text, truncated) =
838                    bounded_owned_string(matched_text, INDICATOR_MATCHED_TEXT_LIMIT);
839                indicator.matched_text_truncated |= truncated;
840                indicator.matched_text = Some(matched_text);
841            } else {
842                indicator.matched_text_truncated = false;
843            }
844            normalized_indicators.push(indicator);
845        }
846        analysis.suspicious_indicators = normalized_indicators;
847        if !analysis.suspicious_indicators.is_empty() || analysis.indicators_truncated {
848            analysis.is_suspicious = true;
849        } else if analysis.is_suspicious {
850            analysis.indicators_truncated = true;
851        }
852        Ok(analysis)
853    }
854
855    fn normalize_categories(
856        &self,
857        categories: Vec<StringCategory>,
858    ) -> AnalysisResult<Vec<StringCategory>> {
859        if categories.len() > self.config.max_categories_per_string {
860            return Err(AnalysisError::CapacityExceeded {
861                resource: "categorizer categories per string",
862                limit: self.config.max_categories_per_string,
863            });
864        }
865
866        let mut normalized = Vec::with_capacity(categories.len());
867        for category in categories {
868            validate_category(&category)?;
869            normalized.push(category.compact());
870        }
871        Ok(normalized)
872    }
873
874    fn aggregate_categories(
875        &self,
876        analysis: &StringAnalysis,
877        categorized: &[StringCategory],
878        context_category: &str,
879    ) -> AnalysisResult<BTreeSet<String>> {
880        if categorized.len() > self.config.max_categories_per_string {
881            return Err(AnalysisError::CapacityExceeded {
882                resource: "categorizer categories per string",
883                limit: self.config.max_categories_per_string,
884            });
885        }
886
887        let mut categories = analysis.categories.clone();
888        validate_identifier("context category", context_category)?;
889        if !categories.contains(context_category)
890            && categories.len() >= self.config.max_categories_per_string
891        {
892            return Err(AnalysisError::CapacityExceeded {
893                resource: "categories per string",
894                limit: self.config.max_categories_per_string,
895            });
896        }
897        categories.insert(context_category.to_string());
898        for category in categorized {
899            validate_identifier("categorizer category", &category.name)?;
900            if !categories.contains(&category.name)
901                && categories.len() >= self.config.max_categories_per_string
902            {
903                return Err(AnalysisError::CapacityExceeded {
904                    resource: "categories per string",
905                    limit: self.config.max_categories_per_string,
906                });
907            }
908            categories.insert(category.name.clone());
909        }
910        Ok(categories)
911    }
912
913    fn lock_entries(&self) -> MutexGuard<'_, BTreeMap<String, StringEntry>> {
914        match self.entries.lock() {
915            Ok(entries) => entries,
916            Err(poisoned) => poisoned.into_inner(),
917        }
918    }
919}
920
921struct CompiledFilter<'a> {
922    filter: Option<&'a StringFilter>,
923    regex: Option<Regex>,
924}
925
926impl<'a> CompiledFilter<'a> {
927    fn new(filter: Option<&'a StringFilter>, config: &AnalysisConfig) -> AnalysisResult<Self> {
928        let Some(filter) = filter else {
929            return Ok(Self {
930                filter: None,
931                regex: None,
932            });
933        };
934
935        validate_ordered_filter(
936            "occurrences",
937            filter.min_occurrences,
938            filter.max_occurrences,
939        )?;
940        validate_ordered_filter("length", filter.min_length, filter.max_length)?;
941        validate_entropy_filter(filter.min_entropy, filter.max_entropy)?;
942        if let Some((start, end)) = &filter.date_range
943            && start > end
944        {
945            return Err(AnalysisError::InvalidFilter {
946                field: "date_range",
947                reason: "start must be earlier than or equal to end".to_string(),
948            });
949        }
950        validate_filter_values(
951            "categories",
952            filter.categories.as_deref(),
953            config.max_categories_per_string,
954            config.max_source_bytes,
955        )?;
956        validate_filter_values(
957            "file_paths",
958            filter.file_paths.as_deref(),
959            config.max_unique_file_identities_per_string,
960            config.max_source_bytes,
961        )?;
962        validate_filter_values(
963            "file_hashes",
964            filter.file_hashes.as_deref(),
965            config.max_unique_file_identities_per_string,
966            config.max_source_bytes,
967        )?;
968
969        let regex = filter
970            .regex_pattern
971            .as_ref()
972            .map(|pattern| {
973                let regex_source_limit = config.max_source_bytes.min(MAX_REGEX_BYTES);
974                if pattern.len() > regex_source_limit {
975                    return Err(AnalysisError::InvalidFilter {
976                        field: "regex_pattern",
977                        reason: format!(
978                            "is {} bytes; effective maximum is {}",
979                            pattern.len(),
980                            regex_source_limit
981                        ),
982                    });
983                }
984                RegexBuilder::new(pattern)
985                    .size_limit(FILTER_REGEX_COMPILED_SIZE_LIMIT)
986                    .dfa_size_limit(FILTER_REGEX_DFA_SIZE_LIMIT)
987                    .build()
988                    .map_err(|error| AnalysisError::InvalidRegex {
989                        context: "statistics filter",
990                        reason: regex_error_reason(&error),
991                    })
992            })
993            .transpose()?;
994
995        Ok(Self {
996            filter: Some(filter),
997            regex,
998        })
999    }
1000
1001    fn matches(&self, entry: &StringEntry) -> bool {
1002        let Some(filter) = self.filter else {
1003            return true;
1004        };
1005
1006        if filter
1007            .min_occurrences
1008            .is_some_and(|minimum| entry.total_occurrences < minimum)
1009            || filter
1010                .max_occurrences
1011                .is_some_and(|maximum| entry.total_occurrences > maximum)
1012            || filter
1013                .min_length
1014                .is_some_and(|minimum| entry.value.len() < minimum)
1015            || filter
1016                .max_length
1017                .is_some_and(|maximum| entry.value.len() > maximum)
1018            || filter
1019                .min_entropy
1020                .is_some_and(|minimum| entry.entropy < minimum)
1021            || filter
1022                .max_entropy
1023                .is_some_and(|maximum| entry.entropy > maximum)
1024        {
1025            return false;
1026        }
1027
1028        if let Some(categories) = &filter.categories
1029            && !categories
1030                .iter()
1031                .any(|category| entry.categories.contains(category))
1032        {
1033            return false;
1034        }
1035        if let Some(paths) = &filter.file_paths
1036            && !paths.iter().any(|path| {
1037                entry
1038                    .unique_file_identities
1039                    .iter()
1040                    .any(|identity| identity.file_path == *path)
1041            })
1042        {
1043            return false;
1044        }
1045        if let Some(hashes) = &filter.file_hashes
1046            && !hashes.iter().any(|hash| {
1047                entry
1048                    .unique_file_identities
1049                    .iter()
1050                    .any(|identity| identity.file_hash == *hash)
1051            })
1052        {
1053            return false;
1054        }
1055        if filter.suspicious_only == Some(true) && !entry.is_suspicious {
1056            return false;
1057        }
1058        if self
1059            .regex
1060            .as_ref()
1061            .is_some_and(|regex| !regex.is_match(&entry.value))
1062        {
1063            return false;
1064        }
1065        if let Some((start, end)) = &filter.date_range
1066            && (entry.first_seen < *start || entry.first_seen > *end)
1067        {
1068            return false;
1069        }
1070        true
1071    }
1072}
1073
1074fn inferred_context(
1075    value: &str,
1076    categories: &[StringCategory],
1077    max_source_bytes: usize,
1078) -> AnalysisResult<StringContext> {
1079    if categories.iter().any(|category| category.name == "url") {
1080        let protocol = value.split_once("://").map(|(protocol, _)| protocol);
1081        if let Some(protocol) = protocol
1082            && protocol.len() > max_source_bytes
1083        {
1084            return Err(AnalysisError::InputTooLarge {
1085                field: "context.url.protocol",
1086                actual: protocol.len(),
1087                limit: max_source_bytes,
1088            });
1089        }
1090        return Ok(StringContext::Url {
1091            protocol: protocol.map(str::to_ascii_lowercase),
1092        });
1093    }
1094    if categories.iter().any(|category| category.name == "path") {
1095        let path_type =
1096            if contains_ascii_case(value, "\\windows") || contains_ascii_case(value, "/usr") {
1097                "system"
1098            } else if contains_ascii_case(value, "\\temp") || contains_ascii_case(value, "/tmp") {
1099                "temporary"
1100            } else {
1101                "general"
1102            };
1103        return Ok(StringContext::Path {
1104            path_type: path_type.to_string(),
1105        });
1106    }
1107    if categories
1108        .iter()
1109        .any(|category| category.name == "registry")
1110    {
1111        let hive = value.split('\\').next();
1112        if let Some(hive) = hive
1113            && hive.len() > max_source_bytes
1114        {
1115            return Err(AnalysisError::InputTooLarge {
1116                field: "context.registry.hive",
1117                actual: hive.len(),
1118                limit: max_source_bytes,
1119            });
1120        }
1121        return Ok(StringContext::Registry {
1122            hive: hive.map(ToString::to_string),
1123        });
1124    }
1125    if categories.iter().any(|category| category.name == "library") {
1126        if value.len() > max_source_bytes {
1127            return Err(AnalysisError::InputTooLarge {
1128                field: "context.import.library",
1129                actual: value.len(),
1130                limit: max_source_bytes,
1131            });
1132        }
1133        return Ok(StringContext::Import {
1134            library: value.to_string(),
1135        });
1136    }
1137    if categories.iter().any(|category| category.name == "command") {
1138        return Ok(StringContext::Command {
1139            command_type: "shell".to_string(),
1140        });
1141    }
1142    Ok(StringContext::FileString { offset: None })
1143}
1144
1145fn validate_ordered_filter<T>(
1146    field: &'static str,
1147    minimum: Option<T>,
1148    maximum: Option<T>,
1149) -> AnalysisResult<()>
1150where
1151    T: PartialOrd,
1152{
1153    if minimum.zip(maximum).is_some_and(|(min, max)| min > max) {
1154        Err(AnalysisError::InvalidFilter {
1155            field,
1156            reason: "minimum must be less than or equal to maximum".to_string(),
1157        })
1158    } else {
1159        Ok(())
1160    }
1161}
1162
1163fn validate_entropy_filter(minimum: Option<f64>, maximum: Option<f64>) -> AnalysisResult<()> {
1164    for (field, value) in [("min_entropy", minimum), ("max_entropy", maximum)] {
1165        if let Some(value) = value
1166            && (!value.is_finite() || !(0.0..=8.0).contains(&value))
1167        {
1168            return Err(AnalysisError::InvalidFilter {
1169                field,
1170                reason: "must be finite and between 0 and 8 inclusive".to_string(),
1171            });
1172        }
1173    }
1174    validate_ordered_filter("entropy", minimum, maximum)
1175}
1176
1177fn validate_filter_values(
1178    field: &'static str,
1179    values: Option<&[String]>,
1180    count_limit: usize,
1181    byte_limit: usize,
1182) -> AnalysisResult<()> {
1183    let Some(values) = values else {
1184        return Ok(());
1185    };
1186    if values.len() > count_limit {
1187        return Err(AnalysisError::InvalidFilter {
1188            field,
1189            reason: format!(
1190                "contains {} values; configured maximum is {count_limit}",
1191                values.len()
1192            ),
1193        });
1194    }
1195    if let Some(value) = values.iter().find(|value| value.len() > byte_limit) {
1196        return Err(AnalysisError::InvalidFilter {
1197            field,
1198            reason: format!(
1199                "contains a {}-byte value; configured maximum is {byte_limit}",
1200                value.len()
1201            ),
1202        });
1203    }
1204    Ok(())
1205}
1206
1207fn calculate_similarity(left: &StringEntry, right: &StringEntry) -> f64 {
1208    const FILE_WEIGHT: f64 = 0.55;
1209    const CATEGORY_WEIGHT: f64 = 0.25;
1210    const ENTROPY_WEIGHT: f64 = 0.10;
1211    const LENGTH_WEIGHT: f64 = 0.10;
1212
1213    let file_similarity = jaccard(&left.unique_file_identities, &right.unique_file_identities);
1214    let left_categories = meaningful_categories(&left.categories);
1215    let right_categories = meaningful_categories(&right.categories);
1216    let category_similarity = jaccard(&left_categories, &right_categories);
1217    let entropy_similarity = 1.0 - ((left.entropy - right.entropy).abs() / 8.0).min(1.0);
1218    let max_length = left.value.len().max(right.value.len());
1219    let length_similarity = if max_length == 0 {
1220        1.0
1221    } else {
1222        left.value.len().min(right.value.len()) as f64 / max_length as f64
1223    };
1224
1225    file_similarity * FILE_WEIGHT
1226        + category_similarity * CATEGORY_WEIGHT
1227        + entropy_similarity * ENTROPY_WEIGHT
1228        + length_similarity * LENGTH_WEIGHT
1229}
1230
1231fn meaningful_categories(categories: &BTreeSet<String>) -> BTreeSet<&str> {
1232    categories
1233        .iter()
1234        .map(String::as_str)
1235        .filter(|category| !matches!(*category, "generic" | "file_string"))
1236        .collect()
1237}
1238
1239fn jaccard<T>(left: &BTreeSet<T>, right: &BTreeSet<T>) -> f64
1240where
1241    T: Ord,
1242{
1243    let union = left.union(right).count();
1244    if union == 0 {
1245        0.0
1246    } else {
1247        left.intersection(right).count() as f64 / union as f64
1248    }
1249}
1250
1251fn retain_highest_severity_indicators(
1252    indicators: Vec<SuspiciousIndicator>,
1253    limit: usize,
1254) -> (Vec<SuspiciousIndicator>, bool) {
1255    let truncated = indicators.len() > limit;
1256    let mut retained: Vec<(usize, SuspiciousIndicator)> =
1257        Vec::with_capacity(indicators.len().min(limit));
1258
1259    for (order, indicator) in indicators.into_iter().enumerate() {
1260        if retained.len() < limit {
1261            retained.push((order, indicator));
1262            continue;
1263        }
1264        let Some((lowest_index, (_, lowest))) = retained
1265            .iter()
1266            .enumerate()
1267            .min_by_key(|(_, (order, indicator))| (indicator.severity, Reverse(*order)))
1268        else {
1269            continue;
1270        };
1271        if indicator.severity > lowest.severity {
1272            retained[lowest_index] = (order, indicator);
1273        }
1274    }
1275
1276    retained.sort_by_key(|(order, _)| *order);
1277    let mut normalized = Vec::with_capacity(retained.len());
1278    normalized.extend(retained.into_iter().map(|(_, indicator)| indicator));
1279    (normalized, truncated)
1280}
1281
1282fn insert_bounded_sorted<T>(
1283    values: &mut Vec<T>,
1284    value: T,
1285    limit: usize,
1286    mut compare: impl FnMut(&T, &T) -> Ordering,
1287) {
1288    let index = values
1289        .binary_search_by(|existing| compare(existing, &value))
1290        .unwrap_or_else(|index| index);
1291    values.insert(index, value);
1292    if values.len() > limit {
1293        values.pop();
1294    }
1295}
1296
1297fn bounded_owned_string(value: String, limit: usize) -> (String, bool) {
1298    if value.len() <= limit {
1299        return (compact_string(value), false);
1300    }
1301
1302    let mut end = limit;
1303    while !value.is_char_boundary(end) {
1304        end -= 1;
1305    }
1306    (value[..end].to_string(), true)
1307}
1308
1309fn compact_in_place(value: &mut String) {
1310    *value = compact_string(std::mem::take(value));
1311}
1312
1313fn contains_ascii_case(value: &str, needle: &str) -> bool {
1314    value
1315        .as_bytes()
1316        .windows(needle.len())
1317        .any(|candidate| candidate.eq_ignore_ascii_case(needle.as_bytes()))
1318}
1319
1320fn usize_to_u64(value: usize) -> u64 {
1321    u64::try_from(value).unwrap_or(u64::MAX)
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326    use super::*;
1327
1328    #[test]
1329    fn normalization_rebuilds_callback_owned_allocations_to_retained_size() {
1330        let tracker = StringTracker::new();
1331        let mut category = String::with_capacity(1_000_000);
1332        category.push_str("custom");
1333        let mut pattern_name = String::with_capacity(1_000_000);
1334        pattern_name.push_str("custom_signal");
1335        let mut description = String::with_capacity(1_000_000);
1336        description.push_str("Custom signal");
1337        let mut matched_text = String::with_capacity(1_000_000);
1338        matched_text.push_str(&"é".repeat(300));
1339        let mut indicators = Vec::with_capacity(1_000_000);
1340        indicators.push(SuspiciousIndicator {
1341            pattern_name,
1342            description,
1343            severity: 5,
1344            matched_text: Some(matched_text),
1345            matched_text_truncated: false,
1346        });
1347
1348        let analysis = tracker
1349            .normalize_analysis(StringAnalysis {
1350                entropy: 1.0,
1351                categories: BTreeSet::from([category]),
1352                suspicious_indicators: indicators,
1353                indicators_truncated: false,
1354                is_suspicious: true,
1355            })
1356            .unwrap();
1357
1358        assert_eq!(analysis.suspicious_indicators.capacity(), 1);
1359        assert_eq!(
1360            analysis.categories.first().unwrap().capacity(),
1361            analysis.categories.first().unwrap().len()
1362        );
1363        let indicator = &analysis.suspicious_indicators[0];
1364        assert_eq!(
1365            indicator.pattern_name.capacity(),
1366            indicator.pattern_name.len()
1367        );
1368        assert_eq!(
1369            indicator.description.capacity(),
1370            indicator.description.len()
1371        );
1372        let matched_text = indicator.matched_text.as_ref().unwrap();
1373        assert_eq!(matched_text.len(), INDICATOR_MATCHED_TEXT_LIMIT);
1374        assert_eq!(matched_text.capacity(), matched_text.len());
1375        assert!(indicator.matched_text_truncated);
1376    }
1377
1378    #[test]
1379    fn tracking_compacts_occurrence_owned_allocations_before_retention() {
1380        let tracker = StringTracker::new();
1381        let mut file_path = String::with_capacity(1_000_000);
1382        file_path.push('p');
1383        let mut file_hash = String::with_capacity(1_000_000);
1384        file_hash.push('h');
1385        let mut tool_name = String::with_capacity(1_000_000);
1386        tool_name.push('t');
1387        let mut library = String::with_capacity(1_000_000);
1388        library.push('l');
1389
1390        tracker
1391            .track_occurrence(
1392                "value",
1393                StringOccurrence {
1394                    file_path,
1395                    file_hash,
1396                    tool_name,
1397                    timestamp: Utc::now(),
1398                    context: StringContext::Import { library },
1399                },
1400            )
1401            .unwrap();
1402
1403        let entries = tracker.lock_entries();
1404        let entry = entries.get("value").unwrap();
1405        let occurrence = &entry.occurrences[0];
1406        assert_eq!(occurrence.file_path.capacity(), occurrence.file_path.len());
1407        assert_eq!(occurrence.file_hash.capacity(), occurrence.file_hash.len());
1408        assert_eq!(occurrence.tool_name.capacity(), occurrence.tool_name.len());
1409        let StringContext::Import { library } = &occurrence.context else {
1410            panic!("expected import context");
1411        };
1412        assert_eq!(library.capacity(), library.len());
1413        let identity = entry.unique_file_identities.first().unwrap();
1414        assert_eq!(identity.file_path.capacity(), identity.file_path.len());
1415        assert_eq!(identity.file_hash.capacity(), identity.file_hash.len());
1416    }
1417}