mockforge_http/
file_server.rs

1//! File serving for MockForge generated files
2//!
3//! This module provides HTTP endpoints to serve generated mock files
4//! from the mock-files directory.
5
6use axum::http::{header, StatusCode};
7use axum::response::{IntoResponse, Response};
8use std::path::PathBuf;
9use tracing::{error, warn};
10
11/// Serve a generated file from the mock-files directory
12pub async fn serve_mock_file(
13    axum::extract::Path(file_path): axum::extract::Path<String>,
14) -> Result<Response, StatusCode> {
15    // Security: Prevent path traversal
16    if file_path.contains("..") || file_path.contains("//") {
17        warn!("Path traversal attempt detected: {}", file_path);
18        return Err(StatusCode::BAD_REQUEST);
19    }
20
21    // Parse the file path: {route_id}/{filename} or {route_id}/{subdir}/{filename}
22    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    // Get base directory from environment or use default
29    let base_dir =
30        std::env::var("MOCKFORGE_MOCK_FILES_DIR").unwrap_or_else(|_| "mock-files".to_string());
31
32    // Reconstruct the full path preserving subdirectories
33    let full_file_path = PathBuf::from(&base_dir).join(&file_path);
34
35    // Check if file exists
36    if !full_file_path.exists() {
37        warn!("File not found: {:?}", full_file_path);
38        return Err(StatusCode::NOT_FOUND);
39    }
40
41    // Read file content
42    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    // Get filename from path for Content-Disposition header
51    let filename = full_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
52
53    // Determine content type from file extension
54    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    // Build response with appropriate headers
68    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
76/// Create router for file serving endpoints
77pub 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]
91    async fn test_serve_mock_file_path_traversal() {
92        // Test path traversal protection
93        use axum::extract::Path;
94        let result = serve_mock_file(Path("../etc/passwd".to_string())).await;
95        assert!(result.is_err());
96        assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
97    }
98
99    #[tokio::test]
100    async fn test_serve_mock_file_invalid_format() {
101        // Test invalid path format (empty string results in empty parts)
102        use axum::extract::Path;
103        let result = serve_mock_file(Path("".to_string())).await;
104        assert!(result.is_err());
105        assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
106    }
107}