pollux/src/request.rs
Jeena f05b9373f1 Implement BACKLOG.md items: config-only, request limits, URL validation
- Remove CLI options except --config and --test-processing-delay
- Enforce 1026 byte request limit per Gemini spec (1024 + 2 for CRLF)
- Add comprehensive URL parsing with host and port validation
- Reject malformed URIs and wrong ports with 59 Bad Request
- Update tests for new URL parsing signature
- Fix clippy warning in port parsing
2026-01-16 11:48:06 +00:00

165 lines
No EOL
5.3 KiB
Rust

use path_security::validate_path;
use std::path::{Path, PathBuf};
#[derive(Debug, PartialEq)]
pub enum PathResolutionError {
NotFound,
}
pub fn parse_gemini_url(request: &str, expected_host: &str, expected_port: u16) -> Result<String, ()> {
if let Some(url) = request.strip_prefix("gemini://") {
let host_port_end = url.find('/').unwrap_or(url.len());
let host_port = &url[..host_port_end];
// Parse host and port
let (host, port_str) = if let Some(colon_pos) = host_port.find(':') {
let host = &host_port[..colon_pos];
let port_str = &host_port[colon_pos + 1..];
(host, Some(port_str))
} else {
(host_port, None)
};
// Validate host
if host != expected_host {
return Err(()); // Hostname mismatch
}
// Validate port
let port = port_str
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(1965);
if port != expected_port {
return Err(()); // Port mismatch
}
let path = if host_port_end < url.len() { &url[host_port_end..] } else { "/" };
Ok(path.trim().to_string())
} else {
Err(())
}
}
pub fn resolve_file_path(path: &str, dir: &str) -> Result<PathBuf, PathResolutionError> {
let file_path_str = if path == "/" {
"index.gmi".to_string()
} else if path.ends_with('/') {
format!("{}index.gmi", &path[1..])
} else {
path[1..].to_string()
};
match validate_path(Path::new(&file_path_str), Path::new(dir)) {
Ok(safe_path) => {
// Path is secure, now check if file exists
if safe_path.exists() {
Ok(safe_path)
} else {
Err(PathResolutionError::NotFound)
}
},
Err(_) => {
// Path validation failed - treat as not found
Err(PathResolutionError::NotFound)
},
}
}
pub fn get_mime_type(file_path: &Path) -> &str {
if let Some(ext) = file_path.extension() {
match ext.to_str() {
Some("gmi") => "text/gemini",
Some("txt") => "text/plain",
Some("html") => "text/html",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
_ => "application/octet-stream",
}
} else {
"application/octet-stream"
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_parse_gemini_url_valid() {
assert_eq!(parse_gemini_url("gemini://gemini.jeena.net/", "gemini.jeena.net", 1965), Ok("/".to_string()));
assert_eq!(parse_gemini_url("gemini://gemini.jeena.net/posts/test", "gemini.jeena.net", 1965), Ok("/posts/test".to_string()));
}
#[test]
fn test_parse_gemini_url_invalid_host() {
assert!(parse_gemini_url("gemini://foo.com/", "gemini.jeena.net", 1965).is_err());
}
#[test]
fn test_parse_gemini_url_no_prefix() {
assert!(parse_gemini_url("http://gemini.jeena.net/", "gemini.jeena.net", 1965).is_err());
}
#[test]
fn test_resolve_file_path_root() {
let temp_dir = TempDir::new().unwrap();
// Create index.gmi file since we now check for existence
std::fs::write(temp_dir.path().join("index.gmi"), "# Test").unwrap();
assert!(resolve_file_path("/", temp_dir.path().to_str().unwrap()).is_ok());
}
#[test]
fn test_resolve_file_path_directory() {
let temp_dir = TempDir::new().unwrap();
std::fs::create_dir(temp_dir.path().join("test")).unwrap();
std::fs::write(temp_dir.path().join("test/index.gmi"), "# Test").unwrap();
assert!(resolve_file_path("/test/", temp_dir.path().to_str().unwrap()).is_ok());
}
#[test]
fn test_resolve_file_path_file() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join("test.gmi"), "# Test").unwrap();
assert!(resolve_file_path("/test.gmi", temp_dir.path().to_str().unwrap()).is_ok());
}
#[test]
fn test_resolve_file_path_traversal() {
let temp_dir = TempDir::new().unwrap();
assert_eq!(resolve_file_path("/../etc/passwd", temp_dir.path().to_str().unwrap()), Err(PathResolutionError::NotFound));
}
#[test]
fn test_resolve_file_path_not_found() {
let temp_dir = TempDir::new().unwrap();
// Don't create the file, should return NotFound error
assert_eq!(resolve_file_path("/nonexistent.gmi", temp_dir.path().to_str().unwrap()), Err(PathResolutionError::NotFound));
}
#[test]
fn test_get_mime_type_gmi() {
let path = Path::new("test.gmi");
assert_eq!(get_mime_type(path), "text/gemini");
}
#[test]
fn test_get_mime_type_png() {
let path = Path::new("test.png");
assert_eq!(get_mime_type(path), "image/png");
}
#[test]
fn test_get_mime_type_unknown() {
let path = Path::new("test.xyz");
assert_eq!(get_mime_type(path), "application/octet-stream");
}
#[test]
fn test_get_mime_type_no_extension() {
let path = Path::new("test");
assert_eq!(get_mime_type(path), "application/octet-stream");
}
}