use std::collections::BTreeMap;
use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::{Duration, Instant};
use colored::Colorize;
use serde::Serialize;
use thiserror::Error;
use crate::utils::format_size;
#[derive(Error, Debug)]
pub enum HttpError {
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("DNS lookup failed for '{host}': {source}")]
DnsError { host: String, source: io::Error },
#[error("TCP connection failed: {0}")]
TcpError(io::Error),
#[error("TLS handshake failed: {0}")]
TlsError(#[from] native_tls::Error),
#[error("Invalid response from server")]
InvalidResponse,
#[error("Response body exceeds {0} byte limit")]
#[allow(dead_code)]
BodyTooLarge(usize),
#[error("Invalid header: contains CR/LF characters — possible injection attempt")]
InvalidHeader,
#[error("IO error: {0}")]
Io(io::Error),
}
#[derive(Debug, Serialize)]
pub struct TimingResult {
pub dns_ms: u64,
pub tcp_ms: u64,
pub tls_ms: Option<u64>,
pub server_ms: u64,
pub transfer_ms: u64,
pub total_ms: u64,
}
#[derive(Debug, Serialize)]
pub struct ResponseInfo {
pub status_line: String,
pub status_code: u16,
pub headers: BTreeMap<String, String>,
pub body_size: usize,
}
#[derive(Debug, Serialize)]
struct JsonOutput {
url: String,
status_code: u16,
status_line: String,
timing: TimingResult,
headers: BTreeMap<String, String>,
body_size: usize,
remote_addr: String,
}
#[derive(Debug)]
struct ParsedUrl {
scheme: String,
host: String,
port: u16,
path: String,
}
fn parse_url(url: &str) -> Result<ParsedUrl, HttpError> {
let (scheme, rest) = if let Some(stripped) = url.strip_prefix("https://") {
("https".to_string(), stripped)
} else if let Some(stripped) = url.strip_prefix("http://") {
("http".to_string(), stripped)
} else {
return Err(HttpError::InvalidUrl(format!(
"{url} — must start with http:// or https://"
)));
};
let default_port: u16 = if scheme == "https" { 443 } else { 80 };
let (host_port, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, "/"),
};
let (host, port) = if let Some(colon_idx) = host_port.rfind(':') {
if host_port.contains('[') {
(host_port.to_string(), default_port)
} else {
let port_str = &host_port[colon_idx + 1..];
let port = port_str
.parse::<u16>()
.map_err(|_| HttpError::InvalidUrl(format!("invalid port: {port_str}")))?;
(host_port[..colon_idx].to_string(), port)
}
} else {
(host_port.to_string(), default_port)
};
if host.is_empty() {
return Err(HttpError::InvalidUrl("empty host".to_string()));
}
Ok(ParsedUrl {
scheme,
host,
port,
path: path.to_string(),
})
}
const MAX_BODY_SIZE: usize = 10 * 1024 * 1024;
fn build_request(
method: &str,
parsed: &ParsedUrl,
headers: &[String],
data: &Option<String>,
) -> Result<String, HttpError> {
let mut req = format!("{method} {} HTTP/1.1\r\n", parsed.path);
req.push_str(&format!("Host: {}\r\n", parsed.host));
req.push_str(&format!("User-Agent: devpulse/{}\r\n", env!("CARGO_PKG_VERSION")));
req.push_str("Accept: */*\r\n");
req.push_str("Connection: close\r\n");
for h in headers {
if h.contains('\r') || h.contains('\n') {
return Err(HttpError::InvalidHeader);
}
req.push_str(h);
req.push_str("\r\n");
}
if let Some(body) = data {
req.push_str(&format!("Content-Length: {}\r\n", body.len()));
req.push_str("\r\n");
req.push_str(body);
} else {
req.push_str("\r\n");
}
Ok(req)
}
fn parse_status_line(line: &str) -> Result<(String, u16), HttpError> {
let parts: Vec<&str> = line.splitn(3, ' ').collect();
if parts.len() < 2 {
return Err(HttpError::InvalidResponse);
}
let code = parts[1]
.parse::<u16>()
.map_err(|_| HttpError::InvalidResponse)?;
Ok((line.to_string(), code))
}
fn read_headers(
reader: &mut BufReader<&mut dyn ReadWrite>,
) -> Result<BTreeMap<String, String>, HttpError> {
let mut headers = BTreeMap::new();
loop {
let mut line = String::new();
reader.read_line(&mut line).map_err(HttpError::Io)?;
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
break;
}
if let Some((key, val)) = trimmed.split_once(':') {
headers.insert(key.trim().to_lowercase(), val.trim().to_string());
}
}
Ok(headers)
}
trait ReadWrite: Read + Write {}
impl ReadWrite for TcpStream {}
impl<S: Read + Write> ReadWrite for native_tls::TlsStream<S> {}
#[derive(Debug, Serialize, Clone)]
pub struct SecurityAudit {
pub grade: char,
pub checks: Vec<SecurityCheck>,
pub present: usize,
pub missing: usize,
}
#[derive(Debug, Serialize, Clone)]
pub struct SecurityCheck {
pub header: String,
pub present: bool,
pub value: Option<String>,
pub severity: String,
}
const SECURITY_HEADERS: &[(&str, &str)] = &[
("strict-transport-security", "critical"),
("content-security-policy", "critical"),
("x-frame-options", "important"),
("x-content-type-options", "important"),
("referrer-policy", "important"),
("permissions-policy", "nice-to-have"),
("x-xss-protection", "nice-to-have"),
("cross-origin-opener-policy", "nice-to-have"),
];
pub fn audit_headers(headers: &BTreeMap<String, String>) -> SecurityAudit {
let mut checks = Vec::new();
let mut present = 0usize;
let mut missing = 0usize;
let mut score = 0i32;
let total_weight: i32 = SECURITY_HEADERS
.iter()
.map(|(_, sev)| match *sev {
"critical" => 30,
"important" => 20,
_ => 10,
})
.sum();
for &(header, severity) in SECURITY_HEADERS {
let value = headers.get(header).cloned();
let is_present = value.is_some();
if is_present {
present += 1;
let weight = match severity {
"critical" => 30,
"important" => 20,
_ => 10,
};
score += weight;
} else {
missing += 1;
}
checks.push(SecurityCheck {
header: header.to_string(),
present: is_present,
value,
severity: severity.to_string(),
});
}
let pct = if total_weight > 0 {
(score * 100) / total_weight
} else {
0
};
let grade = match pct {
90..=100 => 'A',
70..=89 => 'B',
50..=69 => 'C',
30..=49 => 'D',
_ => 'F',
};
SecurityAudit {
grade,
checks,
present,
missing,
}
}
#[derive(Debug, Serialize, Clone)]
pub struct CertInfo {
pub subject: String,
pub issuer: String,
pub not_before: String,
pub not_after: String,
pub days_until_expiry: i64,
pub key_algorithm: String,
pub key_bits: Option<u32>,
pub san: Vec<String>,
}
pub fn inspect_cert(host: &str, port: u16) -> Result<CertInfo, HttpError> {
let addr_str = format!("{host}:{port}");
let socket_addr = addr_str
.to_socket_addrs()
.map_err(|e| HttpError::DnsError {
host: host.to_string(),
source: e,
})?
.next()
.ok_or_else(|| HttpError::DnsError {
host: host.to_string(),
source: io::Error::new(io::ErrorKind::NotFound, "no addresses found"),
})?;
let tcp = TcpStream::connect_timeout(&socket_addr, Duration::from_secs(5))
.map_err(HttpError::TcpError)?;
let connector = native_tls::TlsConnector::new()?;
let tls_stream = connector
.connect(host, tcp)
.map_err(|e| match e {
native_tls::HandshakeError::Failure(err) => HttpError::TlsError(err),
native_tls::HandshakeError::WouldBlock(_) => HttpError::Io(io::Error::new(
io::ErrorKind::TimedOut,
"TLS handshake would block",
)),
})?;
let peer_cert = tls_stream
.peer_certificate()
.map_err(HttpError::TlsError)?
.ok_or(HttpError::InvalidResponse)?;
let der = peer_cert.to_der().map_err(HttpError::TlsError)?;
parse_certificate_der(&der)
}
fn parse_certificate_der(der: &[u8]) -> Result<CertInfo, HttpError> {
use x509_parser::prelude::*;
let (_, cert) = X509Certificate::from_der(der)
.map_err(|_| HttpError::InvalidResponse)?;
let subject = cert
.subject()
.iter_common_name()
.next()
.and_then(|cn| cn.as_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| cert.subject().to_string());
let issuer = cert
.issuer()
.iter_organization()
.next()
.and_then(|o| o.as_str().ok())
.map(|s| s.to_string())
.or_else(|| {
cert.issuer()
.iter_common_name()
.next()
.and_then(|cn| cn.as_str().ok())
.map(|s| s.to_string())
})
.unwrap_or_else(|| cert.issuer().to_string());
let not_before = cert.validity().not_before.to_rfc2822()
.unwrap_or_else(|_| "unknown".to_string());
let not_after = cert.validity().not_after.to_rfc2822()
.unwrap_or_else(|_| "unknown".to_string());
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::from_secs(0))
.as_secs() as i64;
let expiry_secs = cert.validity().not_after.timestamp();
let days_until_expiry = (expiry_secs - now_secs) / 86400;
let spki = cert.public_key();
let key_algorithm = match spki.algorithm.algorithm.to_string().as_str() {
"1.2.840.113549.1.1.1" => "RSA".to_string(),
"1.2.840.10045.2.1" => "ECDSA".to_string(),
"1.3.101.112" => "Ed25519".to_string(),
"1.3.101.113" => "Ed448".to_string(),
other => other.to_string(),
};
let key_bits = match key_algorithm.as_str() {
"RSA" => Some((spki.raw.len() as u32).saturating_mul(8).saturating_sub(160)),
"ECDSA" => {
let raw_len = spki.subject_public_key.data.len();
if raw_len >= 64 { Some(256) }
else if raw_len >= 96 { Some(384) }
else { Some(raw_len as u32 * 4) }
}
_ => None,
};
let san = cert
.extensions()
.iter()
.filter_map(|ext| {
if let ParsedExtension::SubjectAlternativeName(san) = ext.parsed_extension() {
Some(san.general_names.iter().filter_map(|name| {
match name {
GeneralName::DNSName(dns) => Some(dns.to_string()),
GeneralName::IPAddress(ip) => Some(format!("{ip:?}")),
_ => None,
}
}).collect::<Vec<_>>())
} else {
None
}
})
.flatten()
.collect();
Ok(CertInfo {
subject,
issuer,
not_before,
not_after,
days_until_expiry,
key_algorithm,
key_bits,
san,
})
}
#[derive(Debug, Serialize, Clone)]
pub struct RedirectHop {
pub url: String,
pub status_code: u16,
pub location: Option<String>,
pub time_ms: u64,
}
pub fn collect_timing_follow_redirects(
url: &str,
method: &str,
headers: &[String],
data: &Option<String>,
max_hops: usize,
) -> Result<(Vec<RedirectHop>, TimingResult, ResponseInfo, String), HttpError> {
let mut hops = Vec::new();
let mut current_url = url.to_string();
for _ in 0..max_hops {
let hop_start = Instant::now();
let (timing, response, remote_addr) =
collect_timing(¤t_url, method, headers, data)?;
let hop_time = hop_start.elapsed().as_millis() as u64;
if matches!(response.status_code, 301 | 302 | 303 | 307 | 308) {
let location = response.headers.get("location").cloned();
hops.push(RedirectHop {
url: current_url.clone(),
status_code: response.status_code,
location: location.clone(),
time_ms: hop_time,
});
if let Some(loc) = location {
if loc.starts_with("http://") || loc.starts_with("https://") {
current_url = loc;
} else if loc.starts_with('/') {
let parsed = parse_url(¤t_url)?;
let port_str = if (parsed.scheme == "https" && parsed.port == 443)
|| (parsed.scheme == "http" && parsed.port == 80)
{
String::new()
} else {
format!(":{}", parsed.port)
};
current_url = format!("{}://{}{}{}", parsed.scheme, parsed.host, port_str, loc);
} else {
let parsed = parse_url(¤t_url)?;
current_url = format!("{}://{}/{}", parsed.scheme, parsed.host, loc);
}
} else {
return Ok((hops, timing, response, remote_addr));
}
} else {
return Ok((hops, timing, response, remote_addr));
}
}
let (timing, response, remote_addr) = collect_timing(¤t_url, method, headers, data)?;
Ok((hops, timing, response, remote_addr))
}
pub fn collect_timing(
url: &str,
method: &str,
headers: &[String],
data: &Option<String>,
) -> Result<(TimingResult, ResponseInfo, String), HttpError> {
let parsed = parse_url(url)?;
let is_tls = parsed.scheme == "https";
let addr_str = format!("{}:{}", parsed.host, parsed.port);
let total_start = Instant::now();
let dns_start = Instant::now();
let socket_addr = addr_str
.to_socket_addrs()
.map_err(|e| HttpError::DnsError {
host: parsed.host.clone(),
source: e,
})?
.next()
.ok_or_else(|| HttpError::DnsError {
host: parsed.host.clone(),
source: io::Error::new(io::ErrorKind::NotFound, "no addresses found"),
})?;
let dns_dur = dns_start.elapsed();
let tcp_start = Instant::now();
let tcp_stream = TcpStream::connect_timeout(&socket_addr, Duration::from_secs(10))
.map_err(HttpError::TcpError)?;
tcp_stream
.set_read_timeout(Some(Duration::from_secs(30)))
.map_err(HttpError::Io)?;
tcp_stream
.set_write_timeout(Some(Duration::from_secs(10)))
.map_err(HttpError::Io)?;
let tcp_dur = tcp_start.elapsed();
let remote_addr = format!("{socket_addr}");
let (mut stream, tls_dur): (Box<dyn ReadWrite>, Option<Duration>) = if is_tls {
let tls_start = Instant::now();
let connector = native_tls::TlsConnector::new()?;
let tls_stream = connector
.connect(&parsed.host, tcp_stream)
.map_err(|e| match e {
native_tls::HandshakeError::Failure(err) => HttpError::TlsError(err),
native_tls::HandshakeError::WouldBlock(_) => HttpError::Io(io::Error::new(
io::ErrorKind::TimedOut,
"TLS handshake would block",
)),
})?;
let dur = tls_start.elapsed();
(Box::new(tls_stream), Some(dur))
} else {
(Box::new(tcp_stream), None)
};
let request = build_request(method, &parsed, headers, data)?;
stream
.write_all(request.as_bytes())
.map_err(HttpError::Io)?;
stream.flush().map_err(HttpError::Io)?;
let server_start = Instant::now();
let mut reader = BufReader::new(&mut *stream as &mut dyn ReadWrite);
let mut status_line_raw = String::new();
reader
.read_line(&mut status_line_raw)
.map_err(HttpError::Io)?;
let server_dur = server_start.elapsed();
let (status_line, status_code) =
parse_status_line(status_line_raw.trim_end_matches(['\r', '\n']))?;
let resp_headers = read_headers(&mut reader)?;
let transfer_start = Instant::now();
let mut body = Vec::new();
reader
.take(MAX_BODY_SIZE as u64)
.read_to_end(&mut body)
.map_err(HttpError::Io)?;
let transfer_dur = transfer_start.elapsed();
let total_dur = total_start.elapsed();
let timing = TimingResult {
dns_ms: dns_dur.as_millis() as u64,
tcp_ms: tcp_dur.as_millis() as u64,
tls_ms: tls_dur.map(|d| d.as_millis() as u64),
server_ms: server_dur.as_millis() as u64,
transfer_ms: transfer_dur.as_millis() as u64,
total_ms: total_dur.as_millis() as u64,
};
let response = ResponseInfo {
status_line,
status_code,
headers: resp_headers,
body_size: body.len(),
};
Ok((timing, response, remote_addr))
}
pub fn run(
url: &str,
method: &str,
headers: &[String],
data: &Option<String>,
json: bool,
) -> Result<(), HttpError> {
let parsed = parse_url(url)?;
let is_tls = parsed.scheme == "https";
let addr_str = format!("{}:{}", parsed.host, parsed.port);
let total_start = Instant::now();
let dns_start = Instant::now();
let socket_addr = addr_str
.to_socket_addrs()
.map_err(|e| HttpError::DnsError {
host: parsed.host.clone(),
source: e,
})?
.next()
.ok_or_else(|| HttpError::DnsError {
host: parsed.host.clone(),
source: io::Error::new(io::ErrorKind::NotFound, "no addresses found"),
})?;
let dns_dur = dns_start.elapsed();
let tcp_start = Instant::now();
let tcp_stream = TcpStream::connect_timeout(&socket_addr, Duration::from_secs(10))
.map_err(HttpError::TcpError)?;
tcp_stream
.set_read_timeout(Some(Duration::from_secs(30)))
.map_err(HttpError::Io)?;
tcp_stream
.set_write_timeout(Some(Duration::from_secs(10)))
.map_err(HttpError::Io)?;
let tcp_dur = tcp_start.elapsed();
let remote_addr = format!("{socket_addr}");
let (mut stream, tls_dur): (Box<dyn ReadWrite>, Option<Duration>) = if is_tls {
let tls_start = Instant::now();
let connector = native_tls::TlsConnector::new()?;
let tls_stream = connector
.connect(&parsed.host, tcp_stream)
.map_err(|e| match e {
native_tls::HandshakeError::Failure(err) => HttpError::TlsError(err),
native_tls::HandshakeError::WouldBlock(_) => HttpError::Io(io::Error::new(
io::ErrorKind::TimedOut,
"TLS handshake would block",
)),
})?;
let dur = tls_start.elapsed();
(Box::new(tls_stream), Some(dur))
} else {
(Box::new(tcp_stream), None)
};
let request = build_request(method, &parsed, headers, data)?;
stream
.write_all(request.as_bytes())
.map_err(HttpError::Io)?;
stream.flush().map_err(HttpError::Io)?;
let server_start = Instant::now();
let mut reader = BufReader::new(&mut *stream as &mut dyn ReadWrite);
let mut status_line_raw = String::new();
reader
.read_line(&mut status_line_raw)
.map_err(HttpError::Io)?;
let server_dur = server_start.elapsed();
let (status_line, status_code) =
parse_status_line(status_line_raw.trim_end_matches(['\r', '\n']))?;
let resp_headers = read_headers(&mut reader)?;
let transfer_start = Instant::now();
let mut body = Vec::new();
reader
.take(MAX_BODY_SIZE as u64)
.read_to_end(&mut body)
.map_err(HttpError::Io)?;
let transfer_dur = transfer_start.elapsed();
let total_dur = total_start.elapsed();
let timing = TimingResult {
dns_ms: dns_dur.as_millis() as u64,
tcp_ms: tcp_dur.as_millis() as u64,
tls_ms: tls_dur.map(|d| d.as_millis() as u64),
server_ms: server_dur.as_millis() as u64,
transfer_ms: transfer_dur.as_millis() as u64,
total_ms: total_dur.as_millis() as u64,
};
let response = ResponseInfo {
status_line,
status_code,
headers: resp_headers,
body_size: body.len(),
};
if json {
print_json(url, &timing, &response, &remote_addr)?;
} else {
print_colored(url, &timing, &response, &remote_addr);
}
Ok(())
}
fn print_json(
url: &str,
timing: &TimingResult,
response: &ResponseInfo,
remote_addr: &str,
) -> Result<(), HttpError> {
let output = JsonOutput {
url: url.to_string(),
status_code: response.status_code,
status_line: response.status_line.clone(),
timing: TimingResult {
dns_ms: timing.dns_ms,
tcp_ms: timing.tcp_ms,
tls_ms: timing.tls_ms,
server_ms: timing.server_ms,
transfer_ms: timing.transfer_ms,
total_ms: timing.total_ms,
},
headers: response.headers.clone(),
body_size: response.body_size,
remote_addr: remote_addr.to_string(),
};
let json_str =
serde_json::to_string_pretty(&output).map_err(|e| HttpError::Io(io::Error::other(e)))?;
println!("{json_str}");
Ok(())
}
fn print_colored(url: &str, timing: &TimingResult, response: &ResponseInfo, remote_addr: &str) {
println!();
println!(
" {} {} {} {} {}",
"devpulse".bold(),
"──".dimmed(),
"HTTP Timing".bold(),
"──".dimmed(),
url.dimmed()
);
println!();
let status_colored = match response.status_code {
200..=299 => response.status_line.green().bold().to_string(),
300..=399 => response.status_line.yellow().bold().to_string(),
_ => response.status_line.red().bold().to_string(),
};
println!(" {status_colored}");
println!();
let dns_str = format!(" {}ms ", timing.dns_ms);
let tcp_str = format!(" {}ms ", timing.tcp_ms);
let tls_str = timing
.tls_ms
.map(|ms| format!(" {}ms ", ms))
.unwrap_or_default();
let server_str = format!(" {}ms ", timing.server_ms);
let transfer_str = format!(" {}ms ", timing.transfer_ms);
print!(" {}", "DNS Lookup".cyan());
print!(" {}", "TCP Connect".green());
if timing.tls_ms.is_some() {
print!(" {}", "TLS Handshake".yellow());
}
print!(" {}", "Server Wait".magenta());
println!(" {}", "Transfer".blue());
print!(" {}{}", "|".dimmed(), dns_str.cyan());
print!("{}{}", "|".dimmed(), tcp_str.green());
if !tls_str.is_empty() {
print!("{}{}", "|".dimmed(), tls_str.yellow());
}
print!("{}{}", "|".dimmed(), server_str.magenta());
println!("{}{}{}", "|".dimmed(), transfer_str.blue(), "|".dimmed());
println!();
let connect = timing.dns_ms + timing.tcp_ms;
let pretransfer = connect + timing.tls_ms.unwrap_or(0);
let ttfb = pretransfer + timing.server_ms;
println!(" {:>14} {:>5}ms", "namelookup:".dimmed(), timing.dns_ms);
println!(" {:>14} {:>5}ms", "connect:".dimmed(), connect);
if timing.tls_ms.is_some() {
println!(" {:>14} {:>5}ms", "pretransfer:".dimmed(), pretransfer);
}
println!(" {:>14} {:>5}ms", "TTFB:".dimmed(), ttfb);
println!(
" {:>14} {:>5}ms",
"total:".white().bold(),
timing.total_ms.to_string().white().bold()
);
println!();
println!(" {}:", "Headers".bold());
for (key, val) in &response.headers {
println!(" {}: {}", key.dimmed(), val);
}
println!();
println!(
" {}: {}",
"Body".bold(),
format_size(response.body_size as u64)
);
println!(" {}: {}", "Connected to".dimmed(), remote_addr);
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_url_https() {
let url = parse_url("https://example.com/path").unwrap();
assert_eq!(url.scheme, "https");
assert_eq!(url.host, "example.com");
assert_eq!(url.port, 443);
assert_eq!(url.path, "/path");
}
#[test]
fn test_parse_url_http_with_port() {
let url = parse_url("http://localhost:8080/api/test").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.host, "localhost");
assert_eq!(url.port, 8080);
assert_eq!(url.path, "/api/test");
}
#[test]
fn test_parse_url_no_path() {
let url = parse_url("https://example.com").unwrap();
assert_eq!(url.path, "/");
}
#[test]
fn test_parse_url_invalid_no_scheme() {
assert!(parse_url("example.com").is_err());
}
#[test]
fn test_parse_url_empty_host() {
assert!(parse_url("http:///path").is_err());
}
#[test]
fn test_parse_status_line_200() {
let (line, code) = parse_status_line("HTTP/1.1 200 OK").unwrap();
assert_eq!(code, 200);
assert_eq!(line, "HTTP/1.1 200 OK");
}
#[test]
fn test_parse_status_line_404() {
let (_, code) = parse_status_line("HTTP/1.1 404 Not Found").unwrap();
assert_eq!(code, 404);
}
#[test]
fn test_parse_status_line_invalid() {
assert!(parse_status_line("INVALID").is_err());
}
#[test]
fn test_build_request_basic() {
let parsed = ParsedUrl {
scheme: "https".to_string(),
host: "example.com".to_string(),
port: 443,
path: "/test".to_string(),
};
let req = build_request("GET", &parsed, &[], &None).unwrap();
assert!(req.starts_with("GET /test HTTP/1.1\r\n"));
assert!(req.contains("Host: example.com\r\n"));
assert!(req.contains(&format!("User-Agent: devpulse/{}\r\n", env!("CARGO_PKG_VERSION"))));
}
#[test]
fn test_build_request_with_body() {
let parsed = ParsedUrl {
scheme: "https".to_string(),
host: "api.example.com".to_string(),
port: 443,
path: "/data".to_string(),
};
let body = Some("{\"key\":\"value\"}".to_string());
let req = build_request("POST", &parsed, &[], &body).unwrap();
assert!(req.contains("Content-Length: 15\r\n"));
assert!(req.ends_with("{\"key\":\"value\"}"));
}
#[test]
fn test_build_request_rejects_crlf_header() {
let parsed = ParsedUrl {
scheme: "https".to_string(),
host: "example.com".to_string(),
port: 443,
path: "/".to_string(),
};
let headers = vec!["X-Evil: injected\r\nX-Fake: yes".to_string()];
assert!(build_request("GET", &parsed, &headers, &None).is_err());
}
}