Skip to main content

spn_core/
validation.rs

1//! Key format validation and masking utilities.
2
3use crate::find_provider;
4use core::fmt;
5
6/// Result of key format validation.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum ValidationResult {
9    /// Key format is valid.
10    Valid,
11    /// Key is empty.
12    Empty,
13    /// Key is too short (minimum 8 characters).
14    TooShort {
15        /// Actual length
16        actual: usize,
17        /// Minimum required length
18        minimum: usize,
19    },
20    /// Key has invalid prefix.
21    InvalidPrefix {
22        /// Expected prefix
23        expected: String,
24        /// Actual prefix found
25        actual: String,
26    },
27    /// Provider not found.
28    UnknownProvider {
29        /// The provider ID that wasn't found
30        provider: String,
31    },
32}
33
34impl ValidationResult {
35    /// Returns true if the validation passed.
36    #[must_use]
37    pub fn is_valid(&self) -> bool {
38        matches!(self, ValidationResult::Valid)
39    }
40}
41
42impl fmt::Display for ValidationResult {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::Valid => write!(f, "Valid"),
46            Self::Empty => write!(f, "Key is empty"),
47            Self::TooShort { actual, minimum } => {
48                write!(f, "Key too short ({} chars, minimum {})", actual, minimum)
49            }
50            Self::InvalidPrefix { expected, actual } => {
51                write!(f, "Invalid prefix (expected '{}', got '{}')", expected, actual)
52            }
53            Self::UnknownProvider { provider } => {
54                write!(f, "Unknown provider: {}", provider)
55            }
56        }
57    }
58}
59
60/// Validate an API key format for a specific provider.
61///
62/// This performs format validation only (prefix, length).
63/// It does NOT make API calls to verify the key works.
64///
65/// # Example
66///
67/// ```
68/// use spn_core::{validate_key_format, ValidationResult};
69///
70/// // Valid Anthropic key
71/// let result = validate_key_format("anthropic", "sk-ant-api03-xxxxx");
72/// assert!(result.is_valid());
73///
74/// // Invalid prefix
75/// let result = validate_key_format("anthropic", "sk-wrong-key");
76/// assert!(matches!(result, ValidationResult::InvalidPrefix { .. }));
77///
78/// // Too short
79/// let result = validate_key_format("anthropic", "short");
80/// assert!(matches!(result, ValidationResult::TooShort { .. }));
81/// ```
82#[must_use]
83pub fn validate_key_format(provider_id: &str, key: &str) -> ValidationResult {
84    // Check for empty key
85    if key.is_empty() {
86        return ValidationResult::Empty;
87    }
88
89    // Check minimum length
90    const MIN_KEY_LENGTH: usize = 8;
91    if key.len() < MIN_KEY_LENGTH {
92        return ValidationResult::TooShort {
93            actual: key.len(),
94            minimum: MIN_KEY_LENGTH,
95        };
96    }
97
98    // Find provider
99    let Some(provider) = find_provider(provider_id) else {
100        return ValidationResult::UnknownProvider {
101            provider: provider_id.to_string(),
102        };
103    };
104
105    // Check prefix if required
106    if let Some(expected_prefix) = provider.key_prefix {
107        if !key.starts_with(expected_prefix) {
108            // Get actual prefix (same length as expected)
109            let actual_prefix: String = key.chars().take(expected_prefix.len()).collect();
110            return ValidationResult::InvalidPrefix {
111                expected: expected_prefix.to_string(),
112                actual: actual_prefix,
113            };
114        }
115    }
116
117    ValidationResult::Valid
118}
119
120/// Mask an API key for safe display.
121///
122/// Shows the prefix (if identifiable) followed by bullets.
123/// Never exposes more than the first 7 characters.
124///
125/// # Example
126///
127/// ```
128/// use spn_core::mask_key;
129///
130/// assert_eq!(mask_key("sk-ant-api03-secret-key"), "sk-ant-••••••••");
131/// assert_eq!(mask_key("ghp_xxxxxxxxxxxxxxxxxxxx"), "ghp_xxx••••••••");
132/// assert_eq!(mask_key("short"), "short••••••••");
133/// assert_eq!(mask_key(""), "••••••••");
134/// ```
135#[must_use]
136pub fn mask_key(key: &str) -> String {
137    const MASK: &str = "••••••••";
138    const MAX_VISIBLE: usize = 7;
139
140    if key.is_empty() {
141        return MASK.to_string();
142    }
143
144    // Show up to MAX_VISIBLE characters, then mask
145    let visible_len = key.len().min(MAX_VISIBLE);
146    let visible: String = key.chars().take(visible_len).collect();
147
148    format!("{}{}", visible, MASK)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_validate_empty() {
157        assert_eq!(
158            validate_key_format("anthropic", ""),
159            ValidationResult::Empty
160        );
161    }
162
163    #[test]
164    fn test_validate_too_short() {
165        let result = validate_key_format("anthropic", "short");
166        assert!(matches!(result, ValidationResult::TooShort { actual: 5, minimum: 8 }));
167    }
168
169    #[test]
170    fn test_validate_invalid_prefix() {
171        let result = validate_key_format("anthropic", "sk-wrong-key-here");
172        assert!(matches!(result, ValidationResult::InvalidPrefix { .. }));
173
174        if let ValidationResult::InvalidPrefix { expected, actual } = result {
175            assert_eq!(expected, "sk-ant-");
176            assert_eq!(actual, "sk-wron");
177        }
178    }
179
180    #[test]
181    fn test_validate_valid_anthropic() {
182        let result = validate_key_format("anthropic", "sk-ant-api03-xxxxxxxxxxxxxxxx");
183        assert!(result.is_valid());
184    }
185
186    #[test]
187    fn test_validate_valid_openai() {
188        let result = validate_key_format("openai", "sk-xxxxxxxxxxxxxxxxxxxxxxxx");
189        assert!(result.is_valid());
190    }
191
192    #[test]
193    fn test_validate_valid_groq() {
194        let result = validate_key_format("groq", "gsk_xxxxxxxxxxxxxxxxxxxx");
195        assert!(result.is_valid());
196    }
197
198    #[test]
199    fn test_validate_valid_github() {
200        let result = validate_key_format("github", "ghp_xxxxxxxxxxxxxxxxxxxx");
201        assert!(result.is_valid());
202    }
203
204    #[test]
205    fn test_validate_no_prefix_required() {
206        // Mistral doesn't have a required prefix
207        let result = validate_key_format("mistral", "any-valid-key-format");
208        assert!(result.is_valid());
209    }
210
211    #[test]
212    fn test_validate_unknown_provider() {
213        let result = validate_key_format("unknown_provider", "some-key");
214        assert!(matches!(result, ValidationResult::UnknownProvider { .. }));
215    }
216
217    #[test]
218    fn test_mask_key() {
219        assert_eq!(mask_key("sk-ant-api03-secret"), "sk-ant-••••••••");
220        assert_eq!(mask_key("ghp_xxxxxxxxxxxx"), "ghp_xxx••••••••");
221        assert_eq!(mask_key("short"), "short••••••••");
222        assert_eq!(mask_key(""), "••••••••");
223    }
224
225    #[test]
226    fn test_mask_key_max_visible() {
227        // Should show at most 7 characters
228        let long_key = "1234567890123456789";
229        let masked = mask_key(long_key);
230        assert_eq!(masked, "1234567••••••••");
231    }
232}