tramex_tools/interface/association/
rules.rs

1//! Association rules for matching related traces
2
3use crate::data::Trace;
4use crate::interface::layer::Layer;
5use crate::interface::types::Direction;
6
7/// Preferred search direction when looking for related traces
8#[derive(Debug, Clone, Copy, PartialEq, Default)]
9pub enum SearchDirection {
10    /// Search backward first (previous traces), then forward
11    #[default]
12    BackwardFirst,
13    /// Search forward first (next traces), then backward  
14    ForwardFirst,
15    /// Only search backward
16    BackwardOnly,
17    /// Only search forward
18    ForwardOnly,
19}
20
21impl SearchDirection {
22    /// Determine preferred direction based on trace direction
23    /// UL messages: parent likely before (decapsulation happened)
24    /// DL messages: parent likely after (encapsulation will happen)
25    pub fn from_direction(direction: &Direction) -> Self {
26        match direction {
27            Direction::UL | Direction::FROM => SearchDirection::BackwardFirst,
28            Direction::DL | Direction::TO => SearchDirection::ForwardFirst,
29            Direction::NA => SearchDirection::BackwardFirst,
30        }
31    }
32}
33
34/// Trait for defining association rules between trace types
35pub trait AssociationRule: Send + Sync {
36    /// The layer this rule applies to (source layer when searching)
37    fn source_layer(&self) -> Layer;
38
39    /// The layer we're searching for (target layer)
40    fn target_layer(&self) -> Layer;
41
42    /// Check if two traces are related according to this rule.
43    ///
44    /// # Arguments
45    /// * `trace` - The source trace
46    /// * `candidate` - A potential related trace to check
47    ///
48    /// # Returns
49    /// * `true` if the traces are related
50    fn matches(&self, trace: &Trace, candidate: &Trace) -> bool;
51
52    /// Get the preferred search direction based on the source trace
53    fn preferred_direction(&self, source: &Trace) -> SearchDirection {
54        source
55            .additional_infos
56            .get_direction()
57            .map(|d| SearchDirection::from_direction(&d))
58            .unwrap_or_default()
59    }
60
61    /// Get the window size (max number of traces to search in each direction)
62    fn window_size(&self) -> usize {
63        10 // Default window size for searching related traces
64    }
65
66    /// Filter for valid source message names (if empty, all messages are valid)
67    /// This is checked before attempting to match
68    fn valid_source_messages(&self) -> &[&str] {
69        &[] // Empty means all source messages are valid
70    }
71
72    /// Filter for valid target message names (if empty, all messages are valid)
73    /// This is checked before attempting to match
74    fn valid_target_messages(&self) -> &[&str] {
75        &[] // Empty means all target messages are valid
76    }
77
78    /// Returns true if the source trace is the child in the relationship
79    /// - true: source is child, target is parent (e.g., NAS→RRC where NAS is child)
80    /// - false: source is parent, target is child (e.g., NGAP→NAS where NAS is child)
81    fn source_is_child(&self) -> bool {
82        true // Default: source trace is the child
83    }
84}
85
86/// Collection of all available association rules
87pub struct AssociationRules {
88    /// Rules
89    rules: Vec<Box<dyn AssociationRule>>,
90}
91
92impl Default for AssociationRules {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98impl AssociationRules {
99    /// Create a new rule set with default rules
100    pub fn new() -> Self {
101        let rules: Vec<Box<dyn AssociationRule>> = vec![Box::new(NasToRrcRule::new()), Box::new(NgapToNasRule::new())];
102        Self { rules }
103    }
104
105    /// Get all rules that apply to a given source layer
106    pub fn rules_for_layer(&self, layer: &Layer) -> Vec<&dyn AssociationRule> {
107        self.rules
108            .iter()
109            .filter(|r| &r.source_layer() == layer)
110            .map(|r| r.as_ref())
111            .collect()
112    }
113}
114
115// Concrete rules
116
117/// Rule for associating NAS messages with their parent RRC messages
118#[derive(Debug, Default)]
119pub struct NasToRrcRule {
120    /// Number of bytes to match from the NAS message binary
121    /// We match only the first 14 bytes because of potential ciphering differences
122    pub byte_match_length: usize,
123    /// Explicit list of RRC messages that can carry NAS
124    pub valid_rrc_messages: &'static [&'static str],
125    /// Explicit list of NAS messages that can be carried by RRC
126    pub valid_nas_messages: &'static [&'static str],
127}
128
129impl NasToRrcRule {
130    /// Create a new NAS to RRC rule
131    pub fn new() -> Self {
132        Self {
133            byte_match_length: 14, // Match first 14 bytes (does not need full match due to ciphering)
134            valid_rrc_messages: &[
135                "dl information transfer",
136                "ul information transfer",
137                "rrc setup complete",
138                "rrc reconfiguration",
139            ], // All RRC messages that can carry NAS
140            valid_nas_messages: &["service request", "registration accept", "deregistration request"], // All NAS messages that can be carried by RRC
141        }
142    }
143
144    /// Extract dedicated NAS message binary from RRC trace text
145    /// Looks for dedicatedNAS-Message field in ASN.1 structure and extracts the hex value
146    fn extract_rrc_dedicated_nas_binary(&self, trace: &Trace) -> Option<Vec<u8>> {
147        let text = trace.text.as_ref()?;
148
149        for line in text.iter() {
150            let trimmed = line.trim();
151
152            // Look for dedicatedNAS-Message field in ASN.1 structure
153            // The hex value is typically on the same line: "dedicatedNAS-Message '7E026ECD92EF637E0043'H"
154            if trimmed.contains("dedicatedNAS-Message") || trimmed.contains("nas-MessageContainer") {
155                // Extract hex value from the same line
156                // Format: "'7E026ECD92EF637E0043'H" or similar
157                if let Some(hex_start) = trimmed.find('\'')
158                    && let Some(hex_end) = trimmed[hex_start + 1..].find('\'')
159                {
160                    let hex_str = &trimmed[hex_start + 1..hex_start + 1 + hex_end];
161                    // Convert hex string to bytes
162                    return Self::hex_string_to_bytes(hex_str);
163                }
164            }
165        }
166        None
167    }
168
169    /// Convert hex string to bytes
170    fn hex_string_to_bytes(hex_str: &str) -> Option<Vec<u8>> {
171        let hex_clean: String = hex_str.chars().filter(|c| c.is_ascii_hexdigit()).collect();
172        if !hex_clean.len().is_multiple_of(2) {
173            return None;
174        }
175
176        let mut bytes = Vec::new();
177        for i in (0..hex_clean.len()).step_by(2) {
178            if let Ok(byte) = u8::from_str_radix(&hex_clean[i..i + 2], 16) {
179                bytes.push(byte);
180            } else {
181                return None;
182            }
183        }
184
185        Some(bytes)
186    }
187}
188
189impl AssociationRule for NasToRrcRule {
190    fn source_layer(&self) -> Layer {
191        Layer::NAS
192    }
193
194    fn target_layer(&self) -> Layer {
195        Layer::RRC
196    }
197
198    fn valid_source_messages(&self) -> &[&str] {
199        self.valid_nas_messages
200    }
201
202    fn valid_target_messages(&self) -> &[&str] {
203        self.valid_rrc_messages
204    }
205
206    fn matches(&self, trace: &Trace, candidate: &Trace) -> bool {
207        // Determine which trace is NAS and which is RRC (bidirectional matching)
208        let (nas_trace, rrc_trace) = if trace.layer == Layer::NAS && candidate.layer == Layer::RRC {
209            (trace, candidate)
210        } else if trace.layer == Layer::RRC && candidate.layer == Layer::NAS {
211            (candidate, trace)
212        } else {
213            return false;
214        };
215
216        // Check if RRC message type is a valid candidate for carrying NAS
217        let rrc_msg_name = rrc_trace
218            .additional_infos
219            .get_message_name()
220            .unwrap_or_default()
221            .to_lowercase();
222
223        let is_valid_candidate = self.valid_rrc_messages.iter().any(|&msg| rrc_msg_name == msg);
224
225        if !is_valid_candidate {
226            return false;
227        }
228
229        // Get NAS binary data
230        let nas_binary = match &nas_trace.binary {
231            Some(b) => b,
232            None => return false,
233        };
234
235        // Extract dedicated NAS message binary from RRC trace
236        let rrc_nas_binary = match self.extract_rrc_dedicated_nas_binary(rrc_trace) {
237            Some(b) => b,
238            None => return false,
239        };
240
241        // Compare first N bytes
242        // We only match the first 14 bytes because the rest may differ due to ciphering
243        let match_len = self.byte_match_length.min(nas_binary.len()).min(rrc_nas_binary.len());
244        if match_len == 0 {
245            return false;
246        }
247
248        // Binary comparison of first 14 bytes
249        log::debug!("match found");
250        nas_binary[..match_len] == rrc_nas_binary[..match_len]
251    }
252}
253
254/// Rule for associating NGAP messages with their child NAS messages
255#[derive(Debug, Default)]
256pub struct NgapToNasRule {
257    /// Number of bytes to match from the NAS message binary
258    /// We match only the first 14 bytes because of potential ciphering differences
259    pub byte_match_length: usize,
260    /// Explicit list of NGAP messages that can carry NAS (source filter)
261    pub valid_ngap_messages: &'static [&'static str],
262    /// Explicit list of NAS messages that can be carried by NGAP (target filter)
263    pub valid_nas_messages: &'static [&'static str],
264}
265
266impl NgapToNasRule {
267    /// Create a new NGAP to NAS rule
268    pub fn new() -> Self {
269        Self {
270            byte_match_length: 14, // Match first 14 bytes (does not need full match due to ciphering)
271            valid_ngap_messages: &[
272                "initial ue message",
273                "downlink nas transport",
274                "uplink nas transport",
275                "initial context setup request",
276            ], // NGAP messages that can carry NAS
277            valid_nas_messages: &[
278                "service request",
279                "registration accept",
280                "deregistration request",
281                "ul nas transport",
282                "dl nas transport",
283            ], // NAS messages that can be carried by NGAP
284        }
285    }
286
287    /// Extract NAS message binary from NGAP trace text
288    /// Looks for id-NAS-PDU field in ASN.1 structure and extracts the hex value from the following value line
289    ///
290    /// Format example:
291    /// ```text
292    /// {
293    ///   id id-NAS-PDU,
294    ///   criticality reject,
295    ///   value '7E017E49623C607E004509000BF200F110800101E6162E91'H
296    /// },
297    /// ```
298    fn extract_ngap_nas_binary(&self, trace: &Trace) -> Option<Vec<u8>> {
299        let text = trace.text.as_ref()?;
300
301        let mut found_nas_pdu = false;
302
303        for line in text.iter() {
304            let trimmed = line.trim();
305
306            // Look for id-NAS-PDU line
307            if trimmed.contains("id-NAS-PDU") {
308                found_nas_pdu = true;
309                continue;
310            }
311
312            // After finding id-NAS-PDU, look for the value line with hex string
313            if found_nas_pdu && trimmed.starts_with("value ") {
314                // Extract hex value: value '7E017E...'H
315                if let Some(hex_start) = trimmed.find('\'')
316                    && let Some(hex_end) = trimmed[hex_start + 1..].find('\'')
317                {
318                    let hex_str = &trimmed[hex_start + 1..hex_start + 1 + hex_end];
319                    return Self::hex_string_to_bytes(hex_str);
320                }
321                // Reset if value line didn't have hex (it's a different value field)
322                found_nas_pdu = false;
323            }
324
325            // Reset if we hit a closing brace without finding the value
326            if found_nas_pdu && trimmed.starts_with('}') {
327                found_nas_pdu = false;
328            }
329        }
330        None
331    }
332
333    /// Convert hex string to bytes
334    fn hex_string_to_bytes(hex_str: &str) -> Option<Vec<u8>> {
335        let hex_clean: String = hex_str.chars().filter(|c| c.is_ascii_hexdigit()).collect();
336
337        if !hex_clean.len().is_multiple_of(2) {
338            return None;
339        }
340
341        let mut bytes = Vec::new();
342        for i in (0..hex_clean.len()).step_by(2) {
343            if let Ok(byte) = u8::from_str_radix(&hex_clean[i..i + 2], 16) {
344                bytes.push(byte);
345            } else {
346                return None;
347            }
348        }
349
350        Some(bytes)
351    }
352}
353
354impl AssociationRule for NgapToNasRule {
355    fn source_layer(&self) -> Layer {
356        Layer::NGAP
357    }
358
359    fn target_layer(&self) -> Layer {
360        Layer::NAS
361    }
362
363    fn valid_source_messages(&self) -> &[&str] {
364        self.valid_ngap_messages
365    }
366
367    fn valid_target_messages(&self) -> &[&str] {
368        self.valid_nas_messages
369    }
370
371    fn source_is_child(&self) -> bool {
372        false // NGAP is parent, NAS is child
373    }
374
375    fn preferred_direction(&self, source: &Trace) -> SearchDirection {
376        if source.additional_infos.get_direction().unwrap() == Direction::UL
377            || source.additional_infos.get_direction().unwrap() == Direction::TO
378        {
379            SearchDirection::BackwardFirst
380        } else {
381            SearchDirection::ForwardFirst
382        }
383    }
384
385    fn matches(&self, trace: &Trace, candidate: &Trace) -> bool {
386        // trace is NGAP (source), candidate is NAS (target)
387        if trace.layer != Layer::NGAP || candidate.layer != Layer::NAS {
388            return false;
389        }
390        let (ngap_trace, nas_trace) = (trace, candidate);
391
392        // Get NAS binary data
393        let nas_binary = match &nas_trace.binary {
394            Some(b) => b,
395            None => return false,
396        };
397
398        // Extract dedicated NAS message binary from NGAP trace
399        let ngap_nas_binary = match self.extract_ngap_nas_binary(ngap_trace) {
400            Some(b) => b,
401            None => return false,
402        };
403
404        // Compare first N bytes
405        // We only match the first 14 bytes because the rest may differ due to ciphering
406        let match_len = self.byte_match_length.min(nas_binary.len()).min(ngap_nas_binary.len());
407        if match_len == 0 {
408            return false;
409        }
410
411        // Binary comparison of first 14 bytes
412        log::debug!("match found");
413        nas_binary[..match_len] == ngap_nas_binary[..match_len]
414    }
415}