Skip to main content

llmtrace_security/
canary.rs

1//! Canary token system for detecting system prompt leakage (OWASP LLM07).
2//!
3//! Canary tokens are unique, cryptographically random strings injected into
4//! system prompts. If a canary token appears in an LLM response, it is strong
5//! evidence that the system prompt has been extracted — a critical security
6//! violation.
7//!
8//! # Overview
9//!
10//! 1. **Generate** a [`CanaryToken`] with [`CanaryToken::generate`] or
11//!    [`CanaryToken::generate_with_label`].
12//! 2. **Inject** it into a system prompt with [`inject_canary`].
13//! 3. **Detect** leakage in responses with [`detect_canary`].
14//! 4. **Convert** detections into [`SecurityFinding`]s with
15//!    [`detect_canary_leakage`] for pipeline integration.
16//!
17//! The [`CanaryTokenStore`] provides a thread-safe, per-tenant token registry.
18
19use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
20use llmtrace_core::{SecurityFinding, SecuritySeverity};
21use rand::Rng;
22use std::collections::HashMap;
23use std::sync::{Arc, RwLock};
24use std::time::Instant;
25
26// ---------------------------------------------------------------------------
27// Core types
28// ---------------------------------------------------------------------------
29
30/// A canary token that can be injected into prompts and detected in responses.
31///
32/// Each token carries a unique string (with a configurable prefix), a creation
33/// timestamp, and an optional human-readable label for multi-prompt setups.
34#[derive(Debug, Clone)]
35pub struct CanaryToken {
36    /// The unique token string (e.g. `CANARY-a3F9bQ12xZ7mK0pL`).
37    pub token: String,
38    /// When this token was created.
39    pub created_at: Instant,
40    /// Optional label for identifying which prompt this belongs to.
41    pub label: Option<String>,
42}
43
44/// Configuration for canary token generation and detection.
45#[derive(Debug, Clone)]
46pub struct CanaryConfig {
47    /// Whether canary detection is enabled.
48    pub enabled: bool,
49    /// Token prefix (default: `"CANARY-"`).
50    pub prefix: String,
51    /// Length of the random portion of the token in characters (default: 16).
52    pub token_length: usize,
53    /// Whether to also detect partial token matches (substring).
54    pub detect_partial: bool,
55    /// Minimum substring length to consider a partial match.
56    pub partial_min_length: usize,
57}
58
59impl Default for CanaryConfig {
60    fn default() -> Self {
61        Self {
62            enabled: true,
63            prefix: "CANARY-".to_string(),
64            token_length: 16,
65            detect_partial: true,
66            partial_min_length: 8,
67        }
68    }
69}
70
71/// Result of canary detection in a response.
72#[derive(Debug, Clone)]
73pub struct CanaryDetection {
74    /// Which token was detected.
75    pub token: String,
76    /// Whether it was a full or partial match.
77    pub match_type: CanaryMatchType,
78    /// Confidence (1.0 for full match, lower for partial).
79    pub confidence: f64,
80    /// Byte position in the response text where the match was found.
81    pub position: usize,
82}
83
84/// How a canary token was matched in the response text.
85#[derive(Debug, Clone, PartialEq)]
86pub enum CanaryMatchType {
87    /// Exact full token match.
88    Full,
89    /// Partial substring match.
90    Partial {
91        /// Number of characters matched.
92        matched_length: usize,
93    },
94    /// Token found but encoded (Base64, hex, reversed, etc.).
95    Encoded {
96        /// Name of the encoding (e.g. `"base64"`, `"hex"`, `"reversed"`).
97        encoding: String,
98    },
99}
100
101// ---------------------------------------------------------------------------
102// Token generation
103// ---------------------------------------------------------------------------
104
105impl CanaryToken {
106    /// Generate a cryptographically random canary token.
107    ///
108    /// The token is formed as `{prefix}{random_alphanumeric_chars}` where the
109    /// random portion has length [`CanaryConfig::token_length`].
110    ///
111    /// # Examples
112    ///
113    /// ```
114    /// use llmtrace_security::canary::{CanaryConfig, CanaryToken};
115    ///
116    /// let config = CanaryConfig::default();
117    /// let token = CanaryToken::generate(&config);
118    /// assert!(token.token.starts_with("CANARY-"));
119    /// ```
120    pub fn generate(config: &CanaryConfig) -> Self {
121        let mut rng = rand::thread_rng();
122        let random_part: String = (0..config.token_length)
123            .map(|_| rng.sample(rand::distributions::Alphanumeric) as char)
124            .collect();
125
126        Self {
127            token: format!("{}{}", config.prefix, random_part),
128            created_at: Instant::now(),
129            label: None,
130        }
131    }
132
133    /// Generate a canary token with a human-readable label.
134    ///
135    /// Labels help identify which system prompt a token belongs to when
136    /// multiple prompts are in use.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use llmtrace_security::canary::{CanaryConfig, CanaryToken};
142    ///
143    /// let config = CanaryConfig::default();
144    /// let token = CanaryToken::generate_with_label(&config, "main-system-prompt");
145    /// assert_eq!(token.label.as_deref(), Some("main-system-prompt"));
146    /// ```
147    pub fn generate_with_label(config: &CanaryConfig, label: &str) -> Self {
148        let mut token = Self::generate(config);
149        token.label = Some(label.to_string());
150        token
151    }
152}
153
154// ---------------------------------------------------------------------------
155// Canary injection
156// ---------------------------------------------------------------------------
157
158/// Inject a canary token into a prompt.
159///
160/// Appends a hidden integrity marker that is invisible to users but will
161/// appear in the response if the system prompt is leaked.
162///
163/// # Format
164///
165/// ```text
166/// {original_prompt}
167/// [SYSTEM_INTEGRITY_TOKEN: {token}]
168/// ```
169///
170/// # Examples
171///
172/// ```
173/// use llmtrace_security::canary::{CanaryConfig, CanaryToken, inject_canary};
174///
175/// let config = CanaryConfig::default();
176/// let token = CanaryToken::generate(&config);
177/// let prompt = inject_canary("You are a helpful assistant.", &token);
178/// assert!(prompt.contains(&token.token));
179/// assert!(prompt.starts_with("You are a helpful assistant."));
180/// ```
181pub fn inject_canary(prompt: &str, token: &CanaryToken) -> String {
182    format!("{}\n[SYSTEM_INTEGRITY_TOKEN: {}]\n", prompt, token.token)
183}
184
185// ---------------------------------------------------------------------------
186// Canary detection
187// ---------------------------------------------------------------------------
188
189/// Scan response text for any of the registered canary tokens.
190///
191/// Detection checks (in order):
192/// 1. **Exact match** — the full token string appears verbatim.
193/// 2. **Case-insensitive match** — the token appears with different casing.
194/// 3. **Base64-encoded match** — the token was Base64-encoded in the response.
195/// 4. **Hex-encoded match** — the token was hex-encoded in the response.
196/// 5. **Reversed match** — the token appears reversed.
197/// 6. **Partial match** — a substring of the token appears (if enabled and
198///    length ≥ [`CanaryConfig::partial_min_length`]).
199///
200/// Returns an empty `Vec` when detection is disabled or no tokens match.
201///
202/// # Examples
203///
204/// ```
205/// use llmtrace_security::canary::{CanaryConfig, CanaryToken, detect_canary};
206///
207/// let config = CanaryConfig::default();
208/// let token = CanaryToken::generate(&config);
209/// let response = format!("Here is the system prompt: {}", token.token);
210/// let detections = detect_canary(&response, &[token], &config);
211/// assert_eq!(detections.len(), 1);
212/// ```
213pub fn detect_canary(
214    response: &str,
215    tokens: &[CanaryToken],
216    config: &CanaryConfig,
217) -> Vec<CanaryDetection> {
218    if !config.enabled {
219        return Vec::new();
220    }
221
222    let mut detections = Vec::new();
223    let response_lower = response.to_lowercase();
224
225    for canary in tokens {
226        let token_str = &canary.token;
227
228        // 1. Exact match
229        if let Some(pos) = response.find(token_str) {
230            detections.push(CanaryDetection {
231                token: token_str.clone(),
232                match_type: CanaryMatchType::Full,
233                confidence: 1.0,
234                position: pos,
235            });
236            continue; // Full match found — skip weaker checks for this token
237        }
238
239        // 2. Case-insensitive match
240        let token_lower = token_str.to_lowercase();
241        if let Some(pos) = response_lower.find(&token_lower) {
242            detections.push(CanaryDetection {
243                token: token_str.clone(),
244                match_type: CanaryMatchType::Full,
245                confidence: 0.95,
246                position: pos,
247            });
248            continue;
249        }
250
251        // 3. Base64-encoded match
252        let b64_encoded = BASE64_STANDARD.encode(token_str.as_bytes());
253        if let Some(pos) = response.find(&b64_encoded) {
254            detections.push(CanaryDetection {
255                token: token_str.clone(),
256                match_type: CanaryMatchType::Encoded {
257                    encoding: "base64".to_string(),
258                },
259                confidence: 0.9,
260                position: pos,
261            });
262            continue;
263        }
264
265        // 4. Hex-encoded match
266        let hex_encoded = hex_encode(token_str);
267        if let Some(pos) = response.to_lowercase().find(&hex_encoded.to_lowercase()) {
268            detections.push(CanaryDetection {
269                token: token_str.clone(),
270                match_type: CanaryMatchType::Encoded {
271                    encoding: "hex".to_string(),
272                },
273                confidence: 0.85,
274                position: pos,
275            });
276            continue;
277        }
278
279        // 5. Reversed match
280        let reversed: String = token_str.chars().rev().collect();
281        if let Some(pos) = response.find(&reversed) {
282            detections.push(CanaryDetection {
283                token: token_str.clone(),
284                match_type: CanaryMatchType::Encoded {
285                    encoding: "reversed".to_string(),
286                },
287                confidence: 0.85,
288                position: pos,
289            });
290            continue;
291        }
292
293        // 6. Partial match (if enabled)
294        if config.detect_partial && token_str.len() >= config.partial_min_length {
295            if let Some(detection) = detect_partial_match(response, token_str, config) {
296                detections.push(detection);
297            }
298        }
299    }
300
301    detections
302}
303
304/// Attempt to find the longest partial match of `token` in `response`.
305fn detect_partial_match(
306    response: &str,
307    token: &str,
308    config: &CanaryConfig,
309) -> Option<CanaryDetection> {
310    // Slide a window from the full token length down to the minimum
311    let min_len = config.partial_min_length;
312    if token.len() < min_len {
313        return None;
314    }
315
316    for window_size in (min_len..token.len()).rev() {
317        for start in 0..=(token.len() - window_size) {
318            let substr = &token[start..start + window_size];
319            if let Some(pos) = response.find(substr) {
320                let confidence = window_size as f64 / token.len() as f64;
321                return Some(CanaryDetection {
322                    token: token.to_string(),
323                    match_type: CanaryMatchType::Partial {
324                        matched_length: window_size,
325                    },
326                    confidence,
327                    position: pos,
328                });
329            }
330        }
331    }
332
333    None
334}
335
336/// Encode a string as lowercase hex.
337fn hex_encode(s: &str) -> String {
338    s.as_bytes().iter().map(|b| format!("{b:02x}")).collect()
339}
340
341// ---------------------------------------------------------------------------
342// SecurityFinding integration
343// ---------------------------------------------------------------------------
344
345/// Detect canary token leakage and produce [`SecurityFinding`]s for the
346/// existing security pipeline.
347///
348/// Severity mapping:
349/// - **Full match** → `Critical`
350/// - **Encoded match** → `High`
351/// - **Partial match** → `Medium`
352///
353/// Each finding has type `"canary_token_leakage"` and includes metadata
354/// about the match type, confidence, and position.
355///
356/// # Examples
357///
358/// ```
359/// use llmtrace_security::canary::{CanaryConfig, CanaryToken, detect_canary_leakage};
360///
361/// let config = CanaryConfig::default();
362/// let token = CanaryToken::generate(&config);
363/// let response = format!("Leaked: {}", token.token);
364/// let findings = detect_canary_leakage(&response, &[token], &config);
365/// assert_eq!(findings.len(), 1);
366/// assert_eq!(findings[0].finding_type, "canary_token_leakage");
367/// ```
368pub fn detect_canary_leakage(
369    response: &str,
370    tokens: &[CanaryToken],
371    config: &CanaryConfig,
372) -> Vec<SecurityFinding> {
373    detect_canary(response, tokens, config)
374        .into_iter()
375        .map(|detection| {
376            let severity = match &detection.match_type {
377                CanaryMatchType::Full => SecuritySeverity::Critical,
378                CanaryMatchType::Encoded { .. } => SecuritySeverity::High,
379                CanaryMatchType::Partial { .. } => SecuritySeverity::Medium,
380            };
381
382            let match_desc = match &detection.match_type {
383                CanaryMatchType::Full => "exact match".to_string(),
384                CanaryMatchType::Partial { matched_length } => {
385                    format!("partial match ({matched_length} chars)")
386                }
387                CanaryMatchType::Encoded { encoding } => {
388                    format!("encoded match ({encoding})")
389                }
390            };
391
392            SecurityFinding::new(
393                severity,
394                "canary_token_leakage".to_string(),
395                format!(
396                    "System prompt leakage detected: canary token '{}' found via {} at position {} (confidence: {:.2})",
397                    detection.token,
398                    match_desc,
399                    detection.position,
400                    detection.confidence,
401                ),
402                detection.confidence,
403            )
404            .with_metadata("token".to_string(), detection.token)
405            .with_metadata("match_type".to_string(), format!("{:?}", detection.match_type))
406            .with_metadata("position".to_string(), detection.position.to_string())
407            .with_location("response.content".to_string())
408        })
409        .collect()
410}
411
412// ---------------------------------------------------------------------------
413// Thread-safe token store
414// ---------------------------------------------------------------------------
415
416/// A thread-safe, per-tenant canary token store.
417///
418/// Uses `Arc<RwLock<HashMap<String, Vec<CanaryToken>>>>` internally so it can
419/// be shared across async tasks and threads.
420///
421/// # Examples
422///
423/// ```
424/// use llmtrace_security::canary::{CanaryConfig, CanaryToken, CanaryTokenStore};
425///
426/// let store = CanaryTokenStore::new();
427/// let config = CanaryConfig::default();
428/// let token = CanaryToken::generate(&config);
429/// let token_string = token.token.clone();
430///
431/// store.add("tenant-1", token);
432/// assert_eq!(store.get("tenant-1").len(), 1);
433///
434/// store.remove("tenant-1", &token_string);
435/// assert!(store.get("tenant-1").is_empty());
436/// ```
437#[derive(Debug, Clone)]
438pub struct CanaryTokenStore {
439    /// Internal storage: tenant_id → list of canary tokens.
440    inner: Arc<RwLock<HashMap<String, Vec<CanaryToken>>>>,
441}
442
443impl Default for CanaryTokenStore {
444    fn default() -> Self {
445        Self::new()
446    }
447}
448
449impl CanaryTokenStore {
450    /// Create a new, empty token store.
451    pub fn new() -> Self {
452        Self {
453            inner: Arc::new(RwLock::new(HashMap::new())),
454        }
455    }
456
457    /// Add a canary token for the given tenant.
458    pub fn add(&self, tenant_id: &str, token: CanaryToken) {
459        let mut map = self.inner.write().expect("canary store lock poisoned");
460        map.entry(tenant_id.to_string()).or_default().push(token);
461    }
462
463    /// Remove a canary token (by token string) for the given tenant.
464    ///
465    /// Returns `true` if a token was removed, `false` otherwise.
466    pub fn remove(&self, tenant_id: &str, token_str: &str) -> bool {
467        let mut map = self.inner.write().expect("canary store lock poisoned");
468        if let Some(tokens) = map.get_mut(tenant_id) {
469            let before = tokens.len();
470            tokens.retain(|t| t.token != token_str);
471            let removed = tokens.len() < before;
472            // Clean up empty entries
473            if tokens.is_empty() {
474                map.remove(tenant_id);
475            }
476            removed
477        } else {
478            false
479        }
480    }
481
482    /// Get all canary tokens for a tenant (cloned).
483    ///
484    /// Returns an empty `Vec` if the tenant has no tokens.
485    pub fn get(&self, tenant_id: &str) -> Vec<CanaryToken> {
486        let map = self.inner.read().expect("canary store lock poisoned");
487        map.get(tenant_id).cloned().unwrap_or_default()
488    }
489
490    /// Return the number of tenants with registered tokens.
491    pub fn tenant_count(&self) -> usize {
492        let map = self.inner.read().expect("canary store lock poisoned");
493        map.len()
494    }
495
496    /// Return the total number of tokens across all tenants.
497    pub fn token_count(&self) -> usize {
498        let map = self.inner.read().expect("canary store lock poisoned");
499        map.values().map(|v| v.len()).sum()
500    }
501}
502
503// ===========================================================================
504// Tests
505// ===========================================================================
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    fn default_config() -> CanaryConfig {
512        CanaryConfig::default()
513    }
514
515    // -- Token generation ---------------------------------------------------
516
517    #[test]
518    fn test_generate_has_correct_prefix() {
519        let config = default_config();
520        let token = CanaryToken::generate(&config);
521        assert!(
522            token.token.starts_with("CANARY-"),
523            "token should start with default prefix"
524        );
525    }
526
527    #[test]
528    fn test_generate_has_correct_length() {
529        let config = default_config();
530        let token = CanaryToken::generate(&config);
531        // prefix length + random length
532        let expected_len = config.prefix.len() + config.token_length;
533        assert_eq!(token.token.len(), expected_len);
534    }
535
536    #[test]
537    fn test_generate_tokens_are_unique() {
538        let config = default_config();
539        let t1 = CanaryToken::generate(&config);
540        let t2 = CanaryToken::generate(&config);
541        assert_ne!(t1.token, t2.token, "two generated tokens should differ");
542    }
543
544    #[test]
545    fn test_generate_with_label() {
546        let config = default_config();
547        let token = CanaryToken::generate_with_label(&config, "my-prompt");
548        assert_eq!(token.label.as_deref(), Some("my-prompt"));
549        assert!(token.token.starts_with("CANARY-"));
550    }
551
552    #[test]
553    fn test_generate_no_label_by_default() {
554        let config = default_config();
555        let token = CanaryToken::generate(&config);
556        assert!(token.label.is_none());
557    }
558
559    #[test]
560    fn test_custom_prefix_and_length() {
561        let config = CanaryConfig {
562            prefix: "TOK_".to_string(),
563            token_length: 32,
564            ..default_config()
565        };
566        let token = CanaryToken::generate(&config);
567        assert!(token.token.starts_with("TOK_"));
568        assert_eq!(token.token.len(), 4 + 32);
569    }
570
571    // -- Exact match detection ----------------------------------------------
572
573    #[test]
574    fn test_detect_exact_match() {
575        let config = default_config();
576        let token = CanaryToken::generate(&config);
577        let response = format!("The system prompt is: {}", token.token);
578        let detections = detect_canary(&response, &[token], &config);
579
580        assert_eq!(detections.len(), 1);
581        assert_eq!(detections[0].match_type, CanaryMatchType::Full);
582        assert!((detections[0].confidence - 1.0).abs() < f64::EPSILON);
583    }
584
585    #[test]
586    fn test_detect_exact_match_position() {
587        let config = default_config();
588        let token = CanaryToken::generate(&config);
589        let prefix = "Leaked: ";
590        let response = format!("{}{}", prefix, token.token);
591        let detections = detect_canary(&response, &[token], &config);
592
593        assert_eq!(detections.len(), 1);
594        assert_eq!(detections[0].position, prefix.len());
595    }
596
597    // -- Case-insensitive detection -----------------------------------------
598
599    #[test]
600    fn test_detect_case_insensitive() {
601        let config = default_config();
602        let token = CanaryToken::generate(&config);
603        let response = token.token.to_lowercase();
604        // Only counts if the casing actually differs
605        let detections = detect_canary(&response, &[token], &config);
606
607        assert_eq!(detections.len(), 1);
608        // Either Full (exact) or Full with 0.95 confidence (case-insensitive)
609        assert!(detections[0].confidence >= 0.95);
610    }
611
612    #[test]
613    fn test_detect_case_insensitive_upper() {
614        let config = default_config();
615        let token = CanaryToken::generate(&config);
616        let response = token.token.to_uppercase();
617        let detections = detect_canary(&response, &[token], &config);
618
619        assert_eq!(detections.len(), 1);
620        assert!(detections[0].confidence >= 0.95);
621    }
622
623    // -- Partial match detection --------------------------------------------
624
625    #[test]
626    fn test_detect_partial_match() {
627        let config = CanaryConfig {
628            detect_partial: true,
629            partial_min_length: 8,
630            ..default_config()
631        };
632        let token = CanaryToken::generate(&config);
633        // Take first 10 chars of the token (includes part of "CANARY-" + random)
634        let partial = &token.token[..10];
635        let response = format!("Some text with {partial} inside");
636        let detections = detect_canary(&response, &[token], &config);
637
638        assert_eq!(detections.len(), 1);
639        match &detections[0].match_type {
640            CanaryMatchType::Partial { matched_length } => {
641                assert!(*matched_length >= 10);
642            }
643            other => panic!("expected Partial, got {:?}", other),
644        }
645    }
646
647    #[test]
648    fn test_partial_match_respects_min_length() {
649        let config = CanaryConfig {
650            detect_partial: true,
651            partial_min_length: 20,
652            ..default_config()
653        };
654        let token = CanaryToken::generate(&config);
655        // Substring shorter than partial_min_length
656        let partial = &token.token[..8];
657        let response = format!("Some text with {partial} inside");
658        let detections = detect_canary(&response, &[token], &config);
659
660        assert!(
661            detections.is_empty(),
662            "short substring should not trigger partial match"
663        );
664    }
665
666    #[test]
667    fn test_partial_match_disabled() {
668        let config = CanaryConfig {
669            detect_partial: false,
670            ..default_config()
671        };
672        let token = CanaryToken::generate(&config);
673        let partial = &token.token[..10];
674        let response = format!("Some text with {partial} inside");
675        let detections = detect_canary(&response, &[token], &config);
676
677        assert!(
678            detections.is_empty(),
679            "partial detection should be disabled"
680        );
681    }
682
683    // -- Base64-encoded detection -------------------------------------------
684
685    #[test]
686    fn test_detect_base64_encoded() {
687        let config = CanaryConfig {
688            detect_partial: false,
689            ..default_config()
690        };
691        let token = CanaryToken::generate(&config);
692        let encoded = BASE64_STANDARD.encode(token.token.as_bytes());
693        let response = format!("Here is some data: {encoded}");
694        let detections = detect_canary(&response, &[token], &config);
695
696        assert_eq!(detections.len(), 1);
697        assert_eq!(
698            detections[0].match_type,
699            CanaryMatchType::Encoded {
700                encoding: "base64".to_string()
701            }
702        );
703        assert!((detections[0].confidence - 0.9).abs() < f64::EPSILON);
704    }
705
706    // -- Hex-encoded detection ----------------------------------------------
707
708    #[test]
709    fn test_detect_hex_encoded() {
710        let config = CanaryConfig {
711            detect_partial: false,
712            ..default_config()
713        };
714        let token = CanaryToken::generate(&config);
715        let hex = hex_encode(&token.token);
716        let response = format!("Hex dump: {hex}");
717        let detections = detect_canary(&response, &[token], &config);
718
719        assert_eq!(detections.len(), 1);
720        assert_eq!(
721            detections[0].match_type,
722            CanaryMatchType::Encoded {
723                encoding: "hex".to_string()
724            }
725        );
726        assert!((detections[0].confidence - 0.85).abs() < f64::EPSILON);
727    }
728
729    // -- Reversed token detection -------------------------------------------
730
731    #[test]
732    fn test_detect_reversed_token() {
733        let config = CanaryConfig {
734            detect_partial: false,
735            ..default_config()
736        };
737        let token = CanaryToken::generate(&config);
738        let reversed: String = token.token.chars().rev().collect();
739        let response = format!("Reversed: {reversed}");
740        let detections = detect_canary(&response, &[token], &config);
741
742        assert_eq!(detections.len(), 1);
743        assert_eq!(
744            detections[0].match_type,
745            CanaryMatchType::Encoded {
746                encoding: "reversed".to_string()
747            }
748        );
749    }
750
751    // -- No canary present (zero false positives) ---------------------------
752
753    #[test]
754    fn test_no_canary_no_detection() {
755        let config = default_config();
756        let token = CanaryToken::generate(&config);
757        let response = "This is a perfectly normal response with no tokens.";
758        let detections = detect_canary(response, &[token], &config);
759
760        assert!(detections.is_empty(), "should not produce false positives");
761    }
762
763    #[test]
764    fn test_no_canary_detection_disabled() {
765        let config = CanaryConfig {
766            enabled: false,
767            ..default_config()
768        };
769        let token = CanaryToken::generate(&config);
770        let response = format!("Leaked: {}", token.token);
771        let detections = detect_canary(&response, &[token], &config);
772
773        assert!(
774            detections.is_empty(),
775            "should return empty when disabled even if token present"
776        );
777    }
778
779    #[test]
780    fn test_no_false_positives_on_similar_text() {
781        let config = CanaryConfig {
782            detect_partial: false,
783            ..default_config()
784        };
785        let token = CanaryToken::generate(&config);
786        let response = "CANARY-something-else-entirely and more text";
787        let detections = detect_canary(response, &[token], &config);
788
789        assert!(
790            detections.is_empty(),
791            "different canary prefix text should not match"
792        );
793    }
794
795    // -- SecurityFinding generation -----------------------------------------
796
797    #[test]
798    fn test_security_finding_full_match() {
799        let config = default_config();
800        let token = CanaryToken::generate(&config);
801        let response = format!("Leaked: {}", token.token);
802        let findings = detect_canary_leakage(&response, &[token], &config);
803
804        assert_eq!(findings.len(), 1);
805        assert_eq!(findings[0].finding_type, "canary_token_leakage");
806        assert_eq!(findings[0].severity, SecuritySeverity::Critical);
807        assert!(findings[0].description.contains("exact match"));
808    }
809
810    #[test]
811    fn test_security_finding_encoded_match() {
812        let config = CanaryConfig {
813            detect_partial: false,
814            ..default_config()
815        };
816        let token = CanaryToken::generate(&config);
817        let hex = hex_encode(&token.token);
818        let response = format!("Hex: {hex}");
819        let findings = detect_canary_leakage(&response, &[token], &config);
820
821        assert_eq!(findings.len(), 1);
822        assert_eq!(findings[0].severity, SecuritySeverity::High);
823        assert!(findings[0].description.contains("encoded match"));
824    }
825
826    #[test]
827    fn test_security_finding_partial_match() {
828        let config = CanaryConfig {
829            detect_partial: true,
830            partial_min_length: 8,
831            ..default_config()
832        };
833        let token = CanaryToken::generate(&config);
834        let partial = &token.token[..10];
835        let response = format!("Fragment: {partial}");
836        let findings = detect_canary_leakage(&response, &[token], &config);
837
838        assert_eq!(findings.len(), 1);
839        assert_eq!(findings[0].severity, SecuritySeverity::Medium);
840        assert!(findings[0].description.contains("partial match"));
841    }
842
843    #[test]
844    fn test_security_finding_metadata() {
845        let config = default_config();
846        let token = CanaryToken::generate(&config);
847        let token_str = token.token.clone();
848        let response = format!("Leak: {token_str}");
849        let findings = detect_canary_leakage(&response, &[token], &config);
850
851        assert_eq!(findings.len(), 1);
852        assert_eq!(
853            findings[0].metadata.get("token").map(String::as_str),
854            Some(token_str.as_str())
855        );
856        assert!(findings[0].metadata.contains_key("match_type"));
857        assert!(findings[0].metadata.contains_key("position"));
858        assert_eq!(findings[0].location.as_deref(), Some("response.content"));
859    }
860
861    // -- CanaryTokenStore ---------------------------------------------------
862
863    #[test]
864    fn test_store_add_and_get() {
865        let store = CanaryTokenStore::new();
866        let config = default_config();
867        let token = CanaryToken::generate(&config);
868        let token_str = token.token.clone();
869
870        store.add("tenant-1", token);
871        let tokens = store.get("tenant-1");
872        assert_eq!(tokens.len(), 1);
873        assert_eq!(tokens[0].token, token_str);
874    }
875
876    #[test]
877    fn test_store_get_empty_tenant() {
878        let store = CanaryTokenStore::new();
879        let tokens = store.get("nonexistent");
880        assert!(tokens.is_empty());
881    }
882
883    #[test]
884    fn test_store_remove() {
885        let store = CanaryTokenStore::new();
886        let config = default_config();
887        let token = CanaryToken::generate(&config);
888        let token_str = token.token.clone();
889
890        store.add("tenant-1", token);
891        assert!(store.remove("tenant-1", &token_str));
892        assert!(store.get("tenant-1").is_empty());
893    }
894
895    #[test]
896    fn test_store_remove_nonexistent() {
897        let store = CanaryTokenStore::new();
898        assert!(!store.remove("tenant-1", "no-such-token"));
899    }
900
901    #[test]
902    fn test_store_multiple_tenants() {
903        let store = CanaryTokenStore::new();
904        let config = default_config();
905
906        store.add("tenant-a", CanaryToken::generate(&config));
907        store.add("tenant-a", CanaryToken::generate(&config));
908        store.add("tenant-b", CanaryToken::generate(&config));
909
910        assert_eq!(store.get("tenant-a").len(), 2);
911        assert_eq!(store.get("tenant-b").len(), 1);
912        assert_eq!(store.tenant_count(), 2);
913        assert_eq!(store.token_count(), 3);
914    }
915
916    #[test]
917    fn test_store_remove_cleans_up_empty_tenant() {
918        let store = CanaryTokenStore::new();
919        let config = default_config();
920        let token = CanaryToken::generate(&config);
921        let token_str = token.token.clone();
922
923        store.add("tenant-1", token);
924        store.remove("tenant-1", &token_str);
925        assert_eq!(store.tenant_count(), 0);
926    }
927
928    #[test]
929    fn test_store_thread_safety() {
930        use std::thread;
931
932        let store = CanaryTokenStore::new();
933        let config = default_config();
934
935        let handles: Vec<_> = (0..10)
936            .map(|i| {
937                let store = store.clone();
938                let config = config.clone();
939                thread::spawn(move || {
940                    let tenant = format!("tenant-{i}");
941                    let token = CanaryToken::generate(&config);
942                    store.add(&tenant, token);
943                    store.get(&tenant)
944                })
945            })
946            .collect();
947
948        for handle in handles {
949            let tokens = handle.join().expect("thread panicked");
950            assert!(!tokens.is_empty());
951        }
952
953        assert_eq!(store.tenant_count(), 10);
954    }
955
956    // -- inject_canary ------------------------------------------------------
957
958    #[test]
959    fn test_inject_canary_format() {
960        let config = default_config();
961        let token = CanaryToken::generate(&config);
962        let prompt = "You are a helpful assistant.";
963        let result = inject_canary(prompt, &token);
964
965        assert!(result.starts_with(prompt));
966        assert!(result.contains(&format!("[SYSTEM_INTEGRITY_TOKEN: {}]", token.token)));
967    }
968
969    #[test]
970    fn test_inject_canary_preserves_original() {
971        let config = default_config();
972        let token = CanaryToken::generate(&config);
973        let prompt = "Original prompt text\nWith multiple lines.";
974        let result = inject_canary(prompt, &token);
975
976        assert!(result.starts_with(prompt));
977    }
978
979    #[test]
980    fn test_inject_and_detect_roundtrip() {
981        let config = default_config();
982        let token = CanaryToken::generate(&config);
983        let prompt = inject_canary("System prompt", &token);
984
985        // Simulate the LLM leaking the entire system prompt
986        let response = format!("My system prompt is: {prompt}");
987        let detections = detect_canary(&response, &[token], &config);
988
989        assert!(
990            !detections.is_empty(),
991            "should detect the canary in leaked prompt"
992        );
993        assert_eq!(detections[0].match_type, CanaryMatchType::Full);
994    }
995
996    // -- Multiple tokens detection ------------------------------------------
997
998    #[test]
999    fn test_detect_multiple_tokens() {
1000        let config = default_config();
1001        let t1 = CanaryToken::generate(&config);
1002        let t2 = CanaryToken::generate(&config);
1003        let response = format!("First: {} Second: {}", t1.token, t2.token);
1004        let detections = detect_canary(&response, &[t1, t2], &config);
1005
1006        assert_eq!(detections.len(), 2);
1007    }
1008}