arch_toolkit/aur/
validation.rs

1//! Input validation for AUR operations.
2
3use crate::error::{ArchToolkitError, Result};
4use std::sync::LazyLock;
5
6/// Default validation configuration (lazy static).
7static DEFAULT_VALIDATION_CONFIG: LazyLock<ValidationConfig> =
8    LazyLock::new(ValidationConfig::default);
9
10/// What: Configuration for input validation behavior.
11///
12/// Inputs: None (created via `ValidationConfig::default()` or builder methods)
13///
14/// Output: `ValidationConfig` instance with validation settings
15///
16/// Details:
17/// - Controls validation strictness for empty inputs
18/// - Configures maximum length limits for inputs
19/// - Can be customized via `ArchClientBuilder`
20#[derive(Debug, Clone)]
21pub struct ValidationConfig {
22    /// Whether to return errors for empty inputs (strict) or empty results (lenient).
23    pub strict_empty: bool,
24    /// Maximum search query length in characters (default: 256).
25    pub max_query_length: usize,
26    /// Maximum package name length in characters (default: 127).
27    pub max_package_name_length: usize,
28}
29
30impl Default for ValidationConfig {
31    fn default() -> Self {
32        Self {
33            strict_empty: true,
34            max_query_length: 256,
35            max_package_name_length: 127,
36        }
37    }
38}
39
40/// What: Validate a package name according to Arch Linux packaging standards.
41///
42/// Inputs:
43/// - `name`: Package name to validate
44/// - `config`: Optional validation configuration (uses defaults if None)
45///
46/// Output:
47/// - `Result<&str>` containing the validated name, or an error
48///
49/// Details:
50/// - Validates against PKGBUILD naming rules:
51///   - Allowed characters: lowercase letters (a-z), digits (0-9), `@`, `.`, `_`, `+`, `-`
52///   - Cannot start with hyphen (`-`) or period (`.`)
53///   - Must be non-empty
54///   - Maximum length: 127 characters (default, configurable)
55/// - Returns the input string on success for method chaining
56///
57/// # Errors
58/// - Returns `Err(ArchToolkitError::EmptyInput)` if name is empty and strict mode is enabled
59/// - Returns `Err(ArchToolkitError::InvalidPackageName)` if name contains invalid characters
60/// - Returns `Err(ArchToolkitError::InputTooLong)` if name exceeds maximum length
61pub fn validate_package_name<'a>(
62    name: &'a str,
63    config: Option<&ValidationConfig>,
64) -> Result<&'a str> {
65    let config = config.unwrap_or(&DEFAULT_VALIDATION_CONFIG);
66
67    // Check for empty input
68    if name.is_empty() {
69        if config.strict_empty {
70            return Err(ArchToolkitError::EmptyInput {
71                field: "package name".to_string(),
72                message: "package name cannot be empty".to_string(),
73            });
74        }
75        return Ok(name); // Lenient mode: allow empty
76    }
77
78    // Check length
79    if name.len() > config.max_package_name_length {
80        return Err(ArchToolkitError::InputTooLong {
81            field: "package name".to_string(),
82            max_length: config.max_package_name_length,
83            actual_length: name.len(),
84        });
85    }
86
87    // Check for invalid starting characters
88    if name.starts_with('-') {
89        return Err(ArchToolkitError::InvalidPackageName {
90            name: name.to_string(),
91            reason: "package name cannot start with a hyphen (-)".to_string(),
92        });
93    }
94
95    if name.starts_with('.') {
96        return Err(ArchToolkitError::InvalidPackageName {
97            name: name.to_string(),
98            reason: "package name cannot start with a period (.)".to_string(),
99        });
100    }
101
102    // Check for invalid characters
103    // Allowed: lowercase letters (a-z), digits (0-9), @, ., _, +, -
104    for (idx, ch) in name.char_indices() {
105        let is_valid = matches!(ch,
106            'a'..='z' | '0'..='9' | '@' | '.' | '_' | '+' | '-'
107        );
108
109        if !is_valid {
110            return Err(ArchToolkitError::InvalidPackageName {
111                name: name.to_string(),
112                reason: format!(
113                    "package name contains invalid character '{ch}' at position {idx} (allowed: lowercase letters, digits, @, ., _, +, -)"
114                ),
115            });
116        }
117    }
118
119    Ok(name)
120}
121
122/// What: Validate multiple package names.
123///
124/// Inputs:
125/// - `names`: Slice of package names to validate
126/// - `config`: Optional validation configuration (uses defaults if None)
127///
128/// Output:
129/// - `Result<()>` indicating success, or the first validation error encountered
130///
131/// Details:
132/// - Validates each package name in the slice
133/// - Returns the first error encountered, or `Ok(())` if all are valid
134/// - In strict mode, empty slice returns an error; in lenient mode, it's allowed
135///
136/// # Errors
137/// - Returns `Err(ArchToolkitError::EmptyInput)` if names slice is empty and strict mode is enabled
138/// - Returns `Err(ArchToolkitError::InvalidPackageName)` for the first invalid package name
139/// - Returns `Err(ArchToolkitError::InputTooLong)` for the first package name exceeding maximum length
140pub fn validate_package_names(names: &[&str], config: Option<&ValidationConfig>) -> Result<()> {
141    let config = config.unwrap_or(&DEFAULT_VALIDATION_CONFIG);
142
143    // Check for empty slice
144    if names.is_empty() {
145        if config.strict_empty {
146            return Err(ArchToolkitError::EmptyInput {
147                field: "package names".to_string(),
148                message: "at least one package name is required".to_string(),
149            });
150        }
151        return Ok(()); // Lenient mode: allow empty
152    }
153
154    // Validate each package name
155    for name in names {
156        validate_package_name(name, Some(config))?;
157    }
158
159    Ok(())
160}
161
162/// What: Validate a search query string.
163///
164/// Inputs:
165/// - `query`: Search query to validate
166/// - `config`: Optional validation configuration (uses defaults if None)
167///
168/// Output:
169/// - `Result<&str>` containing the trimmed query, or an error
170///
171/// Details:
172/// - Trims whitespace from the query
173/// - In strict mode, empty queries after trimming return an error
174/// - In lenient mode, empty queries are allowed
175/// - Checks maximum length (default: 256 characters)
176/// - Any characters are allowed (will be percent-encoded)
177///
178/// # Errors
179/// - Returns `Err(ArchToolkitError::EmptyInput)` if query is empty after trimming and strict mode is enabled
180/// - Returns `Err(ArchToolkitError::InputTooLong)` if query exceeds maximum length
181pub fn validate_search_query<'a>(
182    query: &'a str,
183    config: Option<&ValidationConfig>,
184) -> Result<&'a str> {
185    let config = config.unwrap_or(&DEFAULT_VALIDATION_CONFIG);
186    let trimmed = query.trim();
187
188    // Check for empty input
189    if trimmed.is_empty() {
190        if config.strict_empty {
191            return Err(ArchToolkitError::EmptyInput {
192                field: "search query".to_string(),
193                message: "search query cannot be empty".to_string(),
194            });
195        }
196        return Ok(trimmed); // Lenient mode: allow empty
197    }
198
199    // Check length
200    if trimmed.len() > config.max_query_length {
201        return Err(ArchToolkitError::InputTooLong {
202            field: "search query".to_string(),
203            max_length: config.max_query_length,
204            actual_length: trimmed.len(),
205        });
206    }
207
208    Ok(trimmed)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_validate_package_name_valid() {
217        let valid_names = [
218            "yay",
219            "paru",
220            "linux-zen",
221            "lib32-mesa",
222            "python-numpy",
223            "gcc@12",
224            "package_name",
225            "pkg+plus",
226            "123package",
227        ];
228
229        for name in &valid_names {
230            assert!(
231                validate_package_name(name, None).is_ok(),
232                "Package name '{name}' should be valid"
233            );
234        }
235    }
236
237    #[test]
238    fn test_validate_package_name_empty() {
239        // Strict mode (default)
240        let result = validate_package_name("", None);
241        assert!(result.is_err());
242        match result.expect_err("Expected validation error") {
243            ArchToolkitError::EmptyInput { field, .. } => {
244                assert_eq!(field, "package name");
245            }
246            _ => panic!("Expected EmptyInput error"),
247        }
248
249        // Lenient mode
250        let config = ValidationConfig {
251            strict_empty: false,
252            ..Default::default()
253        };
254        assert!(validate_package_name("", Some(&config)).is_ok());
255    }
256
257    #[test]
258    fn test_validate_package_name_starts_with_hyphen() {
259        let result = validate_package_name("-invalid", None);
260        assert!(result.is_err());
261        match result.expect_err("Expected validation error") {
262            ArchToolkitError::InvalidPackageName { name, reason } => {
263                assert_eq!(name, "-invalid");
264                assert!(reason.contains("hyphen"));
265            }
266            _ => panic!("Expected InvalidPackageName error"),
267        }
268    }
269
270    #[test]
271    fn test_validate_package_name_starts_with_period() {
272        let result = validate_package_name(".invalid", None);
273        assert!(result.is_err());
274        match result.expect_err("Expected validation error") {
275            ArchToolkitError::InvalidPackageName { name, reason } => {
276                assert_eq!(name, ".invalid");
277                assert!(reason.contains("period"));
278            }
279            _ => panic!("Expected InvalidPackageName error"),
280        }
281    }
282
283    #[test]
284    fn test_validate_package_name_uppercase() {
285        let result = validate_package_name("Invalid", None);
286        assert!(result.is_err());
287        match result.expect_err("Expected validation error") {
288            ArchToolkitError::InvalidPackageName { name, reason } => {
289                assert_eq!(name, "Invalid");
290                assert!(reason.contains("invalid character"));
291            }
292            _ => panic!("Expected InvalidPackageName error"),
293        }
294    }
295
296    #[test]
297    fn test_validate_package_name_special_chars() {
298        let invalid = ["package#name", "package name", "package!"];
299
300        for name in &invalid {
301            let result = validate_package_name(name, None);
302            assert!(result.is_err(), "Package name '{name}' should be invalid");
303            match result.expect_err("Expected validation error") {
304                ArchToolkitError::InvalidPackageName { .. } => {}
305                _ => panic!("Expected InvalidPackageName error for '{name}'"),
306            }
307        }
308    }
309
310    #[test]
311    fn test_validate_package_name_too_long() {
312        let long_name = "a".repeat(128); // Exceeds default max of 127
313        let result = validate_package_name(&long_name, None);
314        assert!(result.is_err());
315        match result.expect_err("Expected validation error") {
316            ArchToolkitError::InputTooLong {
317                field,
318                max_length,
319                actual_length,
320            } => {
321                assert_eq!(field, "package name");
322                assert_eq!(max_length, 127);
323                assert_eq!(actual_length, 128);
324            }
325            _ => panic!("Expected InputTooLong error"),
326        }
327    }
328
329    #[test]
330    fn test_validate_package_name_custom_max_length() {
331        let config = ValidationConfig {
332            max_package_name_length: 10,
333            ..Default::default()
334        };
335        let name = "a".repeat(11);
336        let result = validate_package_name(&name, Some(&config));
337        assert!(result.is_err());
338        match result.expect_err("Expected validation error") {
339            ArchToolkitError::InputTooLong { max_length, .. } => {
340                assert_eq!(max_length, 10);
341            }
342            _ => panic!("Expected InputTooLong error"),
343        }
344    }
345
346    #[test]
347    fn test_validate_package_names_valid() {
348        let names = &["yay", "paru", "linux-zen"];
349        assert!(validate_package_names(names, None).is_ok());
350    }
351
352    #[test]
353    fn test_validate_package_names_empty() {
354        // Strict mode (default)
355        let result = validate_package_names(&[], None);
356        assert!(result.is_err());
357        match result.expect_err("Expected validation error") {
358            ArchToolkitError::EmptyInput { field, .. } => {
359                assert_eq!(field, "package names");
360            }
361            _ => panic!("Expected EmptyInput error"),
362        }
363
364        // Lenient mode
365        let config = ValidationConfig {
366            strict_empty: false,
367            ..Default::default()
368        };
369        assert!(validate_package_names(&[], Some(&config)).is_ok());
370    }
371
372    #[test]
373    fn test_validate_package_names_invalid() {
374        let names = &["yay", "-invalid", "paru"];
375        let result = validate_package_names(names, None);
376        assert!(result.is_err());
377        match result.expect_err("Expected validation error") {
378            ArchToolkitError::InvalidPackageName { name, .. } => {
379                assert_eq!(name, "-invalid");
380            }
381            _ => panic!("Expected InvalidPackageName error"),
382        }
383    }
384
385    #[test]
386    fn test_validate_search_query_valid() {
387        let queries = ["yay", "paru helper", "linux", "  trimmed  "];
388
389        for query in &queries {
390            let result = validate_search_query(query, None);
391            assert!(result.is_ok(), "Query '{query}' should be valid");
392            // Should return trimmed version
393            if let Ok(trimmed) = result {
394                assert_eq!(trimmed, query.trim());
395            }
396        }
397    }
398
399    #[test]
400    fn test_validate_search_query_empty() {
401        // Strict mode (default)
402        let result = validate_search_query("", None);
403        assert!(result.is_err());
404        match result.expect_err("Expected validation error") {
405            ArchToolkitError::EmptyInput { field, .. } => {
406                assert_eq!(field, "search query");
407            }
408            _ => panic!("Expected EmptyInput error"),
409        }
410
411        // Whitespace-only
412        let result = validate_search_query("   ", None);
413        assert!(result.is_err());
414
415        // Lenient mode
416        let config = ValidationConfig {
417            strict_empty: false,
418            ..Default::default()
419        };
420        assert!(validate_search_query("", Some(&config)).is_ok());
421        assert!(validate_search_query("   ", Some(&config)).is_ok());
422    }
423
424    #[test]
425    fn test_validate_search_query_too_long() {
426        let long_query = "a".repeat(257); // Exceeds default max of 256
427        let result = validate_search_query(&long_query, None);
428        assert!(result.is_err());
429        match result.expect_err("Expected validation error") {
430            ArchToolkitError::InputTooLong {
431                field,
432                max_length,
433                actual_length,
434            } => {
435                assert_eq!(field, "search query");
436                assert_eq!(max_length, 256);
437                assert_eq!(actual_length, 257);
438            }
439            _ => panic!("Expected InputTooLong error"),
440        }
441    }
442
443    #[test]
444    fn test_validate_search_query_custom_max_length() {
445        let config = ValidationConfig {
446            max_query_length: 10,
447            ..Default::default()
448        };
449        let query = "a".repeat(11);
450        let result = validate_search_query(&query, Some(&config));
451        assert!(result.is_err());
452        match result.expect_err("Expected validation error") {
453            ArchToolkitError::InputTooLong { max_length, .. } => {
454                assert_eq!(max_length, 10);
455            }
456            _ => panic!("Expected InputTooLong error"),
457        }
458    }
459
460    #[test]
461    fn test_validation_config_default() {
462        let config = ValidationConfig::default();
463        assert!(config.strict_empty);
464        assert_eq!(config.max_query_length, 256);
465        assert_eq!(config.max_package_name_length, 127);
466    }
467}