1use axum::http::{header, StatusCode};
7use axum::response::{IntoResponse, Response};
8use std::path::PathBuf;
9use tracing::{error, warn};
10
11pub async fn serve_mock_file(
13 axum::extract::Path(file_path): axum::extract::Path<String>,
14) -> Result<Response, StatusCode> {
15 if file_path.contains("..") || file_path.contains("//") {
17 warn!("Path traversal attempt detected: {}", file_path);
18 return Err(StatusCode::BAD_REQUEST);
19 }
20
21 let parts: Vec<&str> = file_path.split('/').filter(|s| !s.is_empty()).collect();
23 if parts.is_empty() {
24 warn!("Invalid file path format: {}", file_path);
25 return Err(StatusCode::BAD_REQUEST);
26 }
27
28 let base_dir =
30 std::env::var("MOCKFORGE_MOCK_FILES_DIR").unwrap_or_else(|_| "mock-files".to_string());
31
32 let full_file_path = PathBuf::from(&base_dir).join(&file_path);
34
35 if !full_file_path.exists() {
37 warn!("File not found: {:?}", full_file_path);
38 return Err(StatusCode::NOT_FOUND);
39 }
40
41 let content = match tokio::fs::read(&full_file_path).await {
43 Ok(content) => content,
44 Err(e) => {
45 error!("Failed to read file {:?}: {}", full_file_path, e);
46 return Err(StatusCode::INTERNAL_SERVER_ERROR);
47 }
48 };
49
50 let filename = full_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
52
53 let content_type = full_file_path
55 .extension()
56 .and_then(|ext| ext.to_str())
57 .map(|ext| match ext.to_lowercase().as_str() {
58 "pdf" => "application/pdf",
59 "csv" => "text/csv",
60 "json" => "application/json",
61 "xml" => "application/xml",
62 "txt" => "text/plain",
63 _ => "application/octet-stream",
64 })
65 .unwrap_or("application/octet-stream");
66
67 let headers = [
69 (header::CONTENT_TYPE, content_type),
70 (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", filename)),
71 ];
72
73 Ok((StatusCode::OK, headers, content).into_response())
74}
75
76pub fn file_serving_router() -> axum::Router {
78 use axum::routing::get;
79
80 axum::Router::new().route("/mock-files/{*path}", get(serve_mock_file))
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use axum::body::Body;
87 use axum::http::Request;
88 use tower::ServiceExt;
89
90 #[tokio::test]
93 async fn test_serve_mock_file_path_traversal() {
94 use axum::extract::Path;
96 let result = serve_mock_file(Path("../etc/passwd".to_string())).await;
97 assert!(result.is_err());
98 assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
99 }
100
101 #[tokio::test]
102 async fn test_serve_mock_file_path_traversal_double_slash() {
103 use axum::extract::Path;
104 let result = serve_mock_file(Path("route//file.json".to_string())).await;
105 assert!(result.is_err());
106 assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
107 }
108
109 #[tokio::test]
110 async fn test_serve_mock_file_path_traversal_nested() {
111 use axum::extract::Path;
112 let result = serve_mock_file(Path("route/../../../etc/passwd".to_string())).await;
113 assert!(result.is_err());
114 assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
115 }
116
117 #[tokio::test]
118 async fn test_serve_mock_file_path_traversal_middle() {
119 use axum::extract::Path;
120 let result = serve_mock_file(Path("route/sub/../../../file.txt".to_string())).await;
121 assert!(result.is_err());
122 assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
123 }
124
125 #[tokio::test]
128 async fn test_serve_mock_file_invalid_format() {
129 use axum::extract::Path;
131 let result = serve_mock_file(Path("".to_string())).await;
132 assert!(result.is_err());
133 assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
134 }
135
136 #[tokio::test]
137 async fn test_serve_mock_file_only_slashes() {
138 use axum::extract::Path;
139 let result = serve_mock_file(Path("/".to_string())).await;
140 assert!(result.is_err());
142 assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
143 }
144
145 #[tokio::test]
148 async fn test_serve_mock_file_not_found() {
149 use axum::extract::Path;
150 let result = serve_mock_file(Path("nonexistent/file.json".to_string())).await;
151 assert!(result.is_err());
152 assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
153 }
154
155 #[tokio::test]
156 async fn test_serve_mock_file_deep_path_not_found() {
157 use axum::extract::Path;
158 let result = serve_mock_file(Path("route/subdir/deep/file.json".to_string())).await;
159 assert!(result.is_err());
160 assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
161 }
162
163 #[test]
166 fn test_file_serving_router_creation() {
167 let router = file_serving_router();
168 assert!(std::mem::size_of_val(&router) > 0);
170 }
171
172 #[tokio::test]
173 async fn test_router_path_traversal_blocked() {
174 let router = file_serving_router();
175
176 let request =
177 Request::builder().uri("/mock-files/../etc/passwd").body(Body::empty()).unwrap();
178
179 let response = router.oneshot(request).await.unwrap();
180 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
182 }
183
184 #[tokio::test]
185 async fn test_router_nonexistent_file() {
186 let router = file_serving_router();
187
188 let request = Request::builder()
189 .uri("/mock-files/route123/file.json")
190 .body(Body::empty())
191 .unwrap();
192
193 let response = router.oneshot(request).await.unwrap();
194 assert_eq!(response.status(), StatusCode::NOT_FOUND);
196 }
197
198 #[test]
202 fn test_content_type_detection_logic() {
203 let extensions = vec![
205 ("pdf", "application/pdf"),
206 ("csv", "text/csv"),
207 ("json", "application/json"),
208 ("xml", "application/xml"),
209 ("txt", "text/plain"),
210 ("unknown", "application/octet-stream"),
211 ("bin", "application/octet-stream"),
212 ];
213
214 for (ext, expected) in extensions {
215 let content_type = match ext.to_lowercase().as_str() {
216 "pdf" => "application/pdf",
217 "csv" => "text/csv",
218 "json" => "application/json",
219 "xml" => "application/xml",
220 "txt" => "text/plain",
221 _ => "application/octet-stream",
222 };
223 assert_eq!(content_type, expected, "Extension: {}", ext);
224 }
225 }
226
227 #[test]
228 fn test_content_type_case_insensitive() {
229 let extensions = vec!["PDF", "Pdf", "pDf", "JSON", "Json", "XML", "Xml"];
231
232 for ext in extensions {
233 let content_type = match ext.to_lowercase().as_str() {
234 "pdf" => "application/pdf",
235 "json" => "application/json",
236 "xml" => "application/xml",
237 _ => "application/octet-stream",
238 };
239 assert_ne!(
240 content_type, "application/octet-stream",
241 "Extension {} should be recognized",
242 ext
243 );
244 }
245 }
246
247 #[test]
250 fn test_path_construction() {
251 let base_dir = "mock-files";
252 let file_path = "route123/data.json";
253 let full_path = PathBuf::from(base_dir).join(file_path);
254
255 assert!(full_path.to_string_lossy().contains("mock-files"));
256 assert!(full_path.to_string_lossy().contains("route123"));
257 assert!(full_path.to_string_lossy().contains("data.json"));
258 }
259
260 #[test]
261 fn test_path_with_subdirectory() {
262 let base_dir = "mock-files";
263 let file_path = "route123/subdir/nested/file.csv";
264 let full_path = PathBuf::from(base_dir).join(file_path);
265
266 assert!(full_path.to_string_lossy().contains("subdir"));
267 assert!(full_path.to_string_lossy().contains("nested"));
268 }
269
270 #[test]
271 fn test_filename_extraction() {
272 let full_path = PathBuf::from("mock-files/route123/data.json");
273 let filename = full_path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
274 assert_eq!(filename, "data.json");
275 }
276
277 #[test]
278 fn test_filename_extraction_nested() {
279 let full_path = PathBuf::from("mock-files/route/sub/deep/report.pdf");
280 let filename = full_path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
281 assert_eq!(filename, "report.pdf");
282 }
283
284 #[test]
285 fn test_extension_extraction() {
286 let paths = vec![
287 ("mock-files/file.pdf", Some("pdf")),
288 ("mock-files/file.JSON", Some("JSON")),
289 ("mock-files/file.tar.gz", Some("gz")),
290 ("mock-files/file", None),
291 ];
292
293 for (path, expected_ext) in paths {
294 let full_path = PathBuf::from(path);
295 let ext = full_path.extension().and_then(|e| e.to_str());
296 assert_eq!(ext, expected_ext, "Path: {}", path);
297 }
298 }
299}