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!(
52                    f,
53                    "Invalid prefix (expected '{}', got '{}')",
54                    expected, actual
55                )
56            }
57            Self::UnknownProvider { provider } => {
58                write!(f, "Unknown provider: {}", provider)
59            }
60        }
61    }
62}
63
64/// Validate an API key format for a specific provider.
65///
66/// This performs format validation only (prefix, length).
67/// It does NOT make API calls to verify the key works.
68///
69/// # Example
70///
71/// ```
72/// use spn_core::{validate_key_format, ValidationResult};
73///
74/// // Valid Anthropic key
75/// let result = validate_key_format("anthropic", "sk-ant-api03-xxxxx");
76/// assert!(result.is_valid());
77///
78/// // Invalid prefix
79/// let result = validate_key_format("anthropic", "sk-wrong-key");
80/// assert!(matches!(result, ValidationResult::InvalidPrefix { .. }));
81///
82/// // Too short
83/// let result = validate_key_format("anthropic", "short");
84/// assert!(matches!(result, ValidationResult::TooShort { .. }));
85/// ```
86#[must_use]
87pub fn validate_key_format(provider_id: &str, key: &str) -> ValidationResult {
88    // Check for empty key
89    if key.is_empty() {
90        return ValidationResult::Empty;
91    }
92
93    // Check minimum length
94    const MIN_KEY_LENGTH: usize = 8;
95    if key.len() < MIN_KEY_LENGTH {
96        return ValidationResult::TooShort {
97            actual: key.len(),
98            minimum: MIN_KEY_LENGTH,
99        };
100    }
101
102    // Find provider
103    let Some(provider) = find_provider(provider_id) else {
104        return ValidationResult::UnknownProvider {
105            provider: provider_id.to_string(),
106        };
107    };
108
109    // Check prefix if required
110    if let Some(expected_prefix) = provider.key_prefix {
111        if !key.starts_with(expected_prefix) {
112            // Get actual prefix (same length as expected)
113            let actual_prefix: String = key.chars().take(expected_prefix.len()).collect();
114            return ValidationResult::InvalidPrefix {
115                expected: expected_prefix.to_string(),
116                actual: actual_prefix,
117            };
118        }
119    }
120
121    ValidationResult::Valid
122}
123
124/// Mask an API key for safe display.
125///
126/// Shows the prefix (if identifiable) followed by bullets.
127/// Never exposes more than the first 7 characters.
128///
129/// # Example
130///
131/// ```
132/// use spn_core::mask_key;
133///
134/// assert_eq!(mask_key("sk-ant-api03-secret-key"), "sk-ant-••••••••");
135/// assert_eq!(mask_key("ghp_xxxxxxxxxxxxxxxxxxxx"), "ghp_xxx••••••••");
136/// assert_eq!(mask_key("short"), "short••••••••");
137/// assert_eq!(mask_key(""), "••••••••");
138/// ```
139#[must_use]
140pub fn mask_key(key: &str) -> String {
141    const MASK: &str = "••••••••";
142    const MAX_VISIBLE: usize = 7;
143
144    if key.is_empty() {
145        return MASK.to_string();
146    }
147
148    // Show up to MAX_VISIBLE characters, then mask
149    let visible_len = key.len().min(MAX_VISIBLE);
150    let visible: String = key.chars().take(visible_len).collect();
151
152    format!("{}{}", visible, MASK)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_validate_empty() {
161        assert_eq!(
162            validate_key_format("anthropic", ""),
163            ValidationResult::Empty
164        );
165    }
166
167    #[test]
168    fn test_validate_too_short() {
169        let result = validate_key_format("anthropic", "short");
170        assert!(matches!(
171            result,
172            ValidationResult::TooShort {
173                actual: 5,
174                minimum: 8
175            }
176        ));
177    }
178
179    #[test]
180    fn test_validate_invalid_prefix() {
181        let result = validate_key_format("anthropic", "sk-wrong-key-here");
182        assert!(matches!(result, ValidationResult::InvalidPrefix { .. }));
183
184        if let ValidationResult::InvalidPrefix { expected, actual } = result {
185            assert_eq!(expected, "sk-ant-");
186            assert_eq!(actual, "sk-wron");
187        }
188    }
189
190    #[test]
191    fn test_validate_valid_anthropic() {
192        let result = validate_key_format("anthropic", "sk-ant-api03-xxxxxxxxxxxxxxxx");
193        assert!(result.is_valid());
194    }
195
196    #[test]
197    fn test_validate_valid_openai() {
198        let result = validate_key_format("openai", "sk-xxxxxxxxxxxxxxxxxxxxxxxx");
199        assert!(result.is_valid());
200    }
201
202    #[test]
203    fn test_validate_valid_groq() {
204        let result = validate_key_format("groq", "gsk_xxxxxxxxxxxxxxxxxxxx");
205        assert!(result.is_valid());
206    }
207
208    #[test]
209    fn test_validate_valid_github() {
210        let result = validate_key_format("github", "ghp_xxxxxxxxxxxxxxxxxxxx");
211        assert!(result.is_valid());
212    }
213
214    #[test]
215    fn test_validate_no_prefix_required() {
216        // Mistral doesn't have a required prefix
217        let result = validate_key_format("mistral", "any-valid-key-format");
218        assert!(result.is_valid());
219    }
220
221    #[test]
222    fn test_validate_unknown_provider() {
223        let result = validate_key_format("unknown_provider", "some-key");
224        assert!(matches!(result, ValidationResult::UnknownProvider { .. }));
225    }
226
227    #[test]
228    fn test_mask_key() {
229        assert_eq!(mask_key("sk-ant-api03-secret"), "sk-ant-••••••••");
230        assert_eq!(mask_key("ghp_xxxxxxxxxxxx"), "ghp_xxx••••••••");
231        assert_eq!(mask_key("short"), "short••••••••");
232        assert_eq!(mask_key(""), "••••••••");
233    }
234
235    #[test]
236    fn test_mask_key_max_visible() {
237        // Should show at most 7 characters
238        let long_key = "1234567890123456789";
239        let masked = mask_key(long_key);
240        assert_eq!(masked, "1234567••••••••");
241    }
242}