guts_node/validation/
mod.rs1use 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
34pub static NAME_REGEX: Lazy<Regex> =
37 Lazy::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$").expect("Invalid regex"));
38
39pub static REF_NAME_REGEX: Lazy<Regex> =
42 Lazy::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9/_.-]*$").expect("Invalid regex"));
43
44pub 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
96pub 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#[derive(Debug, Serialize)]
105pub struct ValidationErrorResponse {
106 pub error: String,
108 pub message: String,
110 pub details: Vec<FieldError>,
112}
113
114#[derive(Debug, Serialize)]
116pub struct FieldError {
117 pub field: String,
119 pub code: String,
121 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
131impl 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
158pub fn validate_name(name: &str) -> Result<(), ValidationError> {
160 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 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 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
192pub 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 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
228pub 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 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
252pub async fn body_size_limit_middleware(
254 request: Request,
255 next: axum::middleware::Next,
256) -> Response {
257 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 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 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 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()); assert!(validate_name("admin").is_err()); }
307
308 #[test]
309 fn test_validate_ref_name() {
310 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 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 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 assert!(validate_path("../etc/passwd").is_err());
333 assert!(validate_path("path/../secret").is_err());
334 }
335}