Skip to main content

peat_mesh/security/
callsign.rs

1//! Callsign generation for tactical mesh networks.
2//!
3//! Generates NATO phonetic alphabet callsigns in the format:
4//! `{PHONETIC}-{NN}` (e.g., ALPHA-01, BRAVO-42, ZULU-99)
5//!
6//! Provides 26 x 100 = 2,600 unique callsigns per mesh.
7
8use rand_core::{OsRng, RngCore};
9use std::collections::HashSet;
10
11/// NATO phonetic alphabet words.
12pub const NATO_ALPHABET: [&str; 26] = [
13    "ALPHA", "BRAVO", "CHARLIE", "DELTA", "ECHO", "FOXTROT", "GOLF", "HOTEL", "INDIA", "JULIET",
14    "KILO", "LIMA", "MIKE", "NOVEMBER", "OSCAR", "PAPA", "QUEBEC", "ROMEO", "SIERRA", "TANGO",
15    "UNIFORM", "VICTOR", "WHISKEY", "XRAY", "YANKEE", "ZULU",
16];
17
18/// Maximum callsign length: "NOVEMBER-99" = 11 chars
19pub const MAX_CALLSIGN_LENGTH: usize = 11;
20
21/// Total unique callsigns: 26 letters x 100 numbers
22pub const TOTAL_CALLSIGNS: usize = 2600;
23
24/// Callsign generator with collision avoidance.
25///
26/// Tracks used callsigns to ensure uniqueness within a mesh.
27#[derive(Debug, Clone, Default)]
28pub struct CallsignGenerator {
29    used: HashSet<String>,
30}
31
32/// Error returned when callsign operations fail.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum CallsignError {
35    /// Callsign format is invalid
36    InvalidFormat(String),
37    /// Callsign is already in use
38    AlreadyInUse(String),
39    /// No more callsigns available (all 2,600 exhausted)
40    Exhausted,
41}
42
43impl std::fmt::Display for CallsignError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            CallsignError::InvalidFormat(s) => write!(f, "invalid callsign format: {}", s),
47            CallsignError::AlreadyInUse(s) => write!(f, "callsign already in use: {}", s),
48            CallsignError::Exhausted => write!(f, "all 2,600 callsigns exhausted"),
49        }
50    }
51}
52
53impl std::error::Error for CallsignError {}
54
55impl CallsignGenerator {
56    /// Create a new callsign generator.
57    pub fn new() -> Self {
58        Self {
59            used: HashSet::new(),
60        }
61    }
62
63    /// Generate a random unused callsign.
64    ///
65    /// Returns `Err(CallsignError::Exhausted)` if all 2,600 callsigns are in use.
66    pub fn generate(&mut self) -> Result<String, CallsignError> {
67        if self.used.len() >= TOTAL_CALLSIGNS {
68            return Err(CallsignError::Exhausted);
69        }
70
71        loop {
72            let callsign = Self::random_callsign();
73            if !self.used.contains(&callsign) {
74                self.used.insert(callsign.clone());
75                return Ok(callsign);
76            }
77            // Loop until we find an unused one
78            // This is efficient unless >99% of callsigns are used
79        }
80    }
81
82    /// Reserve a specific callsign for manual assignment.
83    ///
84    /// Returns `Ok(())` if the callsign was successfully reserved.
85    /// Returns `Err` if the callsign is invalid or already in use.
86    pub fn reserve(&mut self, callsign: &str) -> Result<(), CallsignError> {
87        let normalized = Self::normalize(callsign);
88
89        if !Self::is_valid_format(&normalized) {
90            return Err(CallsignError::InvalidFormat(callsign.to_string()));
91        }
92
93        if self.used.contains(&normalized) {
94            return Err(CallsignError::AlreadyInUse(callsign.to_string()));
95        }
96
97        self.used.insert(normalized);
98        Ok(())
99    }
100
101    /// Release a callsign for reuse.
102    ///
103    /// Returns `true` if the callsign was found and released.
104    pub fn release(&mut self, callsign: &str) -> bool {
105        let normalized = Self::normalize(callsign);
106        self.used.remove(&normalized)
107    }
108
109    /// Check if a callsign is available.
110    pub fn is_available(&self, callsign: &str) -> bool {
111        let normalized = Self::normalize(callsign);
112        Self::is_valid_format(&normalized) && !self.used.contains(&normalized)
113    }
114
115    /// Check if a callsign has valid format.
116    ///
117    /// Valid format: `{NATO_WORD}-{NN}` where NN is 00-99.
118    pub fn is_valid_format(callsign: &str) -> bool {
119        Self::parse(callsign).is_some()
120    }
121
122    /// Parse a callsign into (letter_index, number).
123    ///
124    /// Returns `None` if the format is invalid.
125    pub fn parse(callsign: &str) -> Option<(usize, u8)> {
126        let normalized = Self::normalize(callsign);
127        let parts: Vec<&str> = normalized.split('-').collect();
128
129        if parts.len() != 2 {
130            return None;
131        }
132
133        let word = parts[0];
134        let num_str = parts[1];
135
136        // Find NATO word index
137        let letter_idx = NATO_ALPHABET.iter().position(|&w| w == word)?;
138
139        // Parse number (00-99)
140        if num_str.len() != 2 {
141            return None;
142        }
143        let number: u8 = num_str.parse().ok()?;
144        if number > 99 {
145            return None;
146        }
147
148        Some((letter_idx, number))
149    }
150
151    /// Get count of used callsigns.
152    pub fn used_count(&self) -> usize {
153        self.used.len()
154    }
155
156    /// Get count of available callsigns.
157    pub fn available_count(&self) -> usize {
158        TOTAL_CALLSIGNS.saturating_sub(self.used.len())
159    }
160
161    /// Generate a random callsign (not checking availability).
162    fn random_callsign() -> String {
163        let mut bytes = [0u8; 2];
164        OsRng.fill_bytes(&mut bytes);
165
166        let letter_idx = (bytes[0] as usize) % 26;
167        let number = bytes[1] % 100;
168
169        format!("{}-{:02}", NATO_ALPHABET[letter_idx], number)
170    }
171
172    /// Normalize callsign to uppercase.
173    fn normalize(callsign: &str) -> String {
174        callsign.trim().to_uppercase()
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_generate_callsign() {
184        let mut gen = CallsignGenerator::new();
185        let callsign = gen.generate().unwrap();
186
187        // Should be in format WORD-NN
188        assert!(CallsignGenerator::is_valid_format(&callsign));
189        assert!(callsign.contains('-'));
190
191        let parts: Vec<&str> = callsign.split('-').collect();
192        assert_eq!(parts.len(), 2);
193        assert!(NATO_ALPHABET.contains(&parts[0]));
194
195        let num: u8 = parts[1].parse().unwrap();
196        assert!(num <= 99);
197    }
198
199    #[test]
200    fn test_generate_unique() {
201        let mut gen = CallsignGenerator::new();
202        let mut callsigns = HashSet::new();
203
204        for _ in 0..100 {
205            let cs = gen.generate().unwrap();
206            assert!(callsigns.insert(cs), "duplicate callsign generated");
207        }
208    }
209
210    #[test]
211    fn test_reserve() {
212        let mut gen = CallsignGenerator::new();
213
214        assert!(gen.reserve("ALPHA-01").is_ok());
215        assert!(gen.reserve("BRAVO-42").is_ok());
216
217        // Already in use
218        assert!(matches!(
219            gen.reserve("ALPHA-01"),
220            Err(CallsignError::AlreadyInUse(_))
221        ));
222
223        // Case insensitive
224        assert!(matches!(
225            gen.reserve("alpha-01"),
226            Err(CallsignError::AlreadyInUse(_))
227        ));
228    }
229
230    #[test]
231    fn test_reserve_invalid() {
232        let mut gen = CallsignGenerator::new();
233
234        // Invalid format
235        assert!(matches!(
236            gen.reserve("INVALID"),
237            Err(CallsignError::InvalidFormat(_))
238        ));
239        assert!(matches!(
240            gen.reserve("ALPHA-100"),
241            Err(CallsignError::InvalidFormat(_))
242        ));
243        assert!(matches!(
244            gen.reserve("ALPHA-1"),
245            Err(CallsignError::InvalidFormat(_))
246        ));
247        assert!(matches!(
248            gen.reserve("BADWORD-01"),
249            Err(CallsignError::InvalidFormat(_))
250        ));
251    }
252
253    #[test]
254    fn test_release() {
255        let mut gen = CallsignGenerator::new();
256
257        gen.reserve("ZULU-99").unwrap();
258        assert!(!gen.is_available("ZULU-99"));
259
260        assert!(gen.release("ZULU-99"));
261        assert!(gen.is_available("ZULU-99"));
262
263        // Release non-existent returns false
264        assert!(!gen.release("ZULU-99"));
265    }
266
267    #[test]
268    fn test_is_available() {
269        let mut gen = CallsignGenerator::new();
270
271        assert!(gen.is_available("CHARLIE-05"));
272        gen.reserve("CHARLIE-05").unwrap();
273        assert!(!gen.is_available("CHARLIE-05"));
274
275        // Invalid format is not available
276        assert!(!gen.is_available("INVALID-FORMAT"));
277    }
278
279    #[test]
280    fn test_parse() {
281        assert_eq!(CallsignGenerator::parse("ALPHA-00"), Some((0, 0)));
282        assert_eq!(CallsignGenerator::parse("BRAVO-01"), Some((1, 1)));
283        assert_eq!(CallsignGenerator::parse("ZULU-99"), Some((25, 99)));
284        assert_eq!(CallsignGenerator::parse("november-42"), Some((13, 42)));
285
286        assert_eq!(CallsignGenerator::parse("INVALID"), None);
287        assert_eq!(CallsignGenerator::parse("ALPHA-100"), None);
288        assert_eq!(CallsignGenerator::parse("ALPHA-1"), None);
289    }
290
291    #[test]
292    fn test_counts() {
293        let mut gen = CallsignGenerator::new();
294
295        assert_eq!(gen.used_count(), 0);
296        assert_eq!(gen.available_count(), TOTAL_CALLSIGNS);
297
298        gen.reserve("ALPHA-01").unwrap();
299        gen.reserve("BRAVO-02").unwrap();
300
301        assert_eq!(gen.used_count(), 2);
302        assert_eq!(gen.available_count(), TOTAL_CALLSIGNS - 2);
303    }
304
305    #[test]
306    fn test_nato_alphabet() {
307        assert_eq!(NATO_ALPHABET.len(), 26);
308        assert_eq!(NATO_ALPHABET[0], "ALPHA");
309        assert_eq!(NATO_ALPHABET[25], "ZULU");
310    }
311
312    #[test]
313    fn test_max_length() {
314        // NOVEMBER-99 is the longest possible callsign
315        assert_eq!("NOVEMBER-99".len(), MAX_CALLSIGN_LENGTH);
316    }
317}