guts_node/validation/
mod.rs

1//! # Input Validation Module
2//!
3//! Production-grade input validation for all API endpoints including:
4//!
5//! - Repository name validation
6//! - Organization and team name validation
7//! - Branch and tag name validation
8//! - Path and content validation
9//! - Request size limits
10//!
11//! ## Usage
12//!
13//! ```rust,no_run
14//! use guts_node::validation::validate_name;
15//!
16//! // Validate a repository name
17//! if let Err(e) = validate_name("my-repo") {
18//!     println!("Invalid name: {}", e);
19//! }
20//! ```
21
22use axum::{
23    body::Body,
24    extract::Request,
25    http::StatusCode,
26    response::{IntoResponse, Response},
27    Json,
28};
29use once_cell::sync::Lazy;
30use regex::Regex;
31use serde::Serialize;
32use validator::{ValidationError, ValidationErrors};
33
34/// Regex for valid repository/organization names.
35/// Must start with alphanumeric, can contain alphanumeric, hyphens, and underscores.
36pub static NAME_REGEX: Lazy<Regex> =
37    Lazy::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$").expect("Invalid regex"));
38
39/// Regex for valid branch/tag names.
40/// Git reference names with common restrictions.
41pub static REF_NAME_REGEX: Lazy<Regex> =
42    Lazy::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9/_.-]*$").expect("Invalid regex"));
43
44/// Reserved names that cannot be used for repositories or organizations.
45pub static RESERVED_NAMES: Lazy<Vec<&str>> = Lazy::new(|| {
46    vec![
47        "api",
48        "git",
49        "admin",
50        "administrator",
51        "root",
52        "system",
53        "health",
54        "metrics",
55        "status",
56        "settings",
57        "help",
58        "about",
59        "login",
60        "logout",
61        "signup",
62        "register",
63        "new",
64        "edit",
65        "delete",
66        "create",
67        "update",
68        "organizations",
69        "orgs",
70        "users",
71        "teams",
72        "repos",
73        "repositories",
74        "pulls",
75        "issues",
76        "commits",
77        "branches",
78        "tags",
79        "releases",
80        "actions",
81        "workflows",
82        "runs",
83        "artifacts",
84        "search",
85        "explore",
86        "trending",
87        "notifications",
88        "webhooks",
89        "tokens",
90        "keys",
91        "ssh",
92        "gpg",
93    ]
94});
95
96/// Maximum lengths for various fields.
97pub const MAX_NAME_LENGTH: usize = 100;
98pub const MAX_DESCRIPTION_LENGTH: usize = 1000;
99pub const MAX_TITLE_LENGTH: usize = 256;
100pub const MAX_BODY_LENGTH: usize = 65536;
101pub const MAX_PATH_LENGTH: usize = 4096;
102
103/// Validation error response.
104#[derive(Debug, Serialize)]
105pub struct ValidationErrorResponse {
106    /// Error type.
107    pub error: String,
108    /// Human-readable message.
109    pub message: String,
110    /// Field-level error details.
111    pub details: Vec<FieldError>,
112}
113
114/// Field-level validation error.
115#[derive(Debug, Serialize)]
116pub struct FieldError {
117    /// Field name.
118    pub field: String,
119    /// Error code.
120    pub code: String,
121    /// Human-readable message.
122    pub message: String,
123}
124
125impl IntoResponse for ValidationErrorResponse {
126    fn into_response(self) -> Response {
127        (StatusCode::UNPROCESSABLE_ENTITY, Json(self)).into_response()
128    }
129}
130
131/// Convert ValidationErrors to our error response.
132impl From<ValidationErrors> for ValidationErrorResponse {
133    fn from(errors: ValidationErrors) -> Self {
134        let details: Vec<FieldError> = errors
135            .field_errors()
136            .iter()
137            .flat_map(|(field, errs)| {
138                errs.iter().map(move |e| FieldError {
139                    field: field.to_string(),
140                    code: e.code.to_string(),
141                    message: e
142                        .message
143                        .as_ref()
144                        .map(|m| m.to_string())
145                        .unwrap_or_else(|| format!("Validation failed for field '{}'", field)),
146                })
147            })
148            .collect();
149
150        ValidationErrorResponse {
151            error: "validation_error".to_string(),
152            message: "Validation failed".to_string(),
153            details,
154        }
155    }
156}
157
158/// Validate a repository or organization name.
159pub fn validate_name(name: &str) -> Result<(), ValidationError> {
160    // Check length
161    if name.is_empty() {
162        let mut err = ValidationError::new("length");
163        err.message = Some("Name cannot be empty".into());
164        return Err(err);
165    }
166
167    if name.len() > MAX_NAME_LENGTH {
168        let mut err = ValidationError::new("length");
169        err.message = Some(format!("Name must be at most {} characters", MAX_NAME_LENGTH).into());
170        return Err(err);
171    }
172
173    // Check pattern
174    if !NAME_REGEX.is_match(name) {
175        let mut err = ValidationError::new("pattern");
176        err.message = Some(
177            "Name must start with a letter or number and contain only letters, numbers, hyphens, and underscores".into()
178        );
179        return Err(err);
180    }
181
182    // Check reserved names
183    if RESERVED_NAMES.contains(&name.to_lowercase().as_str()) {
184        let mut err = ValidationError::new("reserved");
185        err.message = Some("This name is reserved and cannot be used".into());
186        return Err(err);
187    }
188
189    Ok(())
190}
191
192/// Validate a git reference name (branch/tag).
193pub fn validate_ref_name(name: &str) -> Result<(), ValidationError> {
194    if name.is_empty() {
195        let mut err = ValidationError::new("length");
196        err.message = Some("Reference name cannot be empty".into());
197        return Err(err);
198    }
199
200    if name.len() > MAX_NAME_LENGTH {
201        let mut err = ValidationError::new("length");
202        err.message = Some(
203            format!(
204                "Reference name must be at most {} characters",
205                MAX_NAME_LENGTH
206            )
207            .into(),
208        );
209        return Err(err);
210    }
211
212    if !REF_NAME_REGEX.is_match(name) {
213        let mut err = ValidationError::new("pattern");
214        err.message = Some("Invalid reference name format".into());
215        return Err(err);
216    }
217
218    // Git-specific restrictions
219    if name.contains("..") || name.starts_with('/') || name.ends_with('/') || name.ends_with('.') {
220        let mut err = ValidationError::new("git_restriction");
221        err.message = Some("Reference name contains invalid Git sequences".into());
222        return Err(err);
223    }
224
225    Ok(())
226}
227
228/// Validate a file path.
229pub fn validate_path(path: &str) -> Result<(), ValidationError> {
230    if path.len() > MAX_PATH_LENGTH {
231        let mut err = ValidationError::new("length");
232        err.message = Some(format!("Path must be at most {} characters", MAX_PATH_LENGTH).into());
233        return Err(err);
234    }
235
236    // Security checks
237    if path.contains("..") {
238        let mut err = ValidationError::new("security");
239        err.message = Some("Path cannot contain '..' sequences".into());
240        return Err(err);
241    }
242
243    if path.contains('\0') {
244        let mut err = ValidationError::new("security");
245        err.message = Some("Path cannot contain null bytes".into());
246        return Err(err);
247    }
248
249    Ok(())
250}
251
252/// Request body size limit middleware.
253pub async fn body_size_limit_middleware(
254    request: Request,
255    next: axum::middleware::Next,
256) -> Response {
257    // Get content length if available
258    if let Some(content_length) = request
259        .headers()
260        .get("content-length")
261        .and_then(|v| v.to_str().ok())
262        .and_then(|s| s.parse::<usize>().ok())
263    {
264        // 50MB limit for most requests
265        const MAX_BODY_SIZE: usize = 50 * 1024 * 1024;
266
267        if content_length > MAX_BODY_SIZE {
268            return Response::builder()
269                .status(StatusCode::PAYLOAD_TOO_LARGE)
270                .body(Body::from(
271                    r#"{"error":"payload_too_large","message":"Request body exceeds maximum size"}"#,
272                ))
273                .unwrap_or_else(|_| {
274                    Response::builder()
275                        .status(StatusCode::INTERNAL_SERVER_ERROR)
276                        .body(Body::empty())
277                        .expect("Failed to build response")
278                });
279        }
280    }
281
282    next.run(request).await
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_validate_name() {
291        // Valid names
292        assert!(validate_name("myrepo").is_ok());
293        assert!(validate_name("my-repo").is_ok());
294        assert!(validate_name("my_repo").is_ok());
295        assert!(validate_name("MyRepo123").is_ok());
296        assert!(validate_name("a").is_ok());
297
298        // Invalid names
299        assert!(validate_name("").is_err());
300        assert!(validate_name("-myrepo").is_err());
301        assert!(validate_name("_myrepo").is_err());
302        assert!(validate_name("my repo").is_err());
303        assert!(validate_name("my.repo").is_err());
304        assert!(validate_name("api").is_err()); // reserved
305        assert!(validate_name("admin").is_err()); // reserved
306    }
307
308    #[test]
309    fn test_validate_ref_name() {
310        // Valid refs
311        assert!(validate_ref_name("main").is_ok());
312        assert!(validate_ref_name("feature/test").is_ok());
313        assert!(validate_ref_name("v1.0.0").is_ok());
314        assert!(validate_ref_name("release-1.0").is_ok());
315
316        // Invalid refs
317        assert!(validate_ref_name("").is_err());
318        assert!(validate_ref_name("..").is_err());
319        assert!(validate_ref_name("/main").is_err());
320        assert!(validate_ref_name("main/").is_err());
321        assert!(validate_ref_name("main.").is_err());
322    }
323
324    #[test]
325    fn test_validate_path() {
326        // Valid paths
327        assert!(validate_path("src/main.rs").is_ok());
328        assert!(validate_path("README.md").is_ok());
329        assert!(validate_path("path/to/file.txt").is_ok());
330
331        // Invalid paths
332        assert!(validate_path("../etc/passwd").is_err());
333        assert!(validate_path("path/../secret").is_err());
334    }
335}