use crate::cli::Args;
use crate::mqtt::ProtocolExitCode;
use anyhow::{anyhow, bail, Context, Result};
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::net::UnixStream;
use std::time::{Duration, Instant};
pub struct UdsResponse {
#[allow(dead_code)]
pub url: String,
pub final_url: String,
pub status: u16,
pub body: Vec<u8>,
pub headers: Vec<(String, String)>,
pub http_version: String,
pub duration_ms: u64,
}
pub fn execute(args: &Args) -> Result<UdsResponse> {
let socket_path = args
.unix_socket
.as_ref()
.ok_or_else(|| anyhow!("unix-socket: --unix-socket path is required"))?;
if !socket_path.exists() {
bail!(
"unix-socket: '{}' does not exist",
socket_path.display()
);
}
let url = args.target_url();
let (host, path) = parse_url(url)?;
let method = resolve_method(args);
let body = resolve_body(args)?;
let timeout = Duration::from_secs(args.timeout.max(1));
let t0 = Instant::now();
let mut stream = UnixStream::connect(socket_path).map_err(|e| {
anyhow!(
"unix-socket: connect to {}: {e}",
socket_path.display()
)
.context(ProtocolExitCode::CouldntConnect)
})?;
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
let mut header_lines: Vec<(String, String)> = vec![
("Host".to_string(), host.clone()),
(
"User-Agent".to_string(),
args.user_agent
.clone()
.unwrap_or_else(|| concat!("recon/", env!("CARGO_PKG_VERSION")).to_string()),
),
("Accept".to_string(), "*/*".to_string()),
("Connection".to_string(), "close".to_string()),
];
for h in &args.header {
if let Some((k, v)) = h.split_once(':') {
let name = k.trim().to_string();
let val = v.trim().to_string();
header_lines.retain(|(n, _)| !n.eq_ignore_ascii_case(&name));
header_lines.push((name, val));
}
}
if !body.is_empty() {
let has_cl = header_lines
.iter()
.any(|(n, _)| n.eq_ignore_ascii_case("Content-Length"));
if !has_cl {
header_lines.push(("Content-Length".to_string(), body.len().to_string()));
}
}
let mut request = String::new();
request.push_str(&format!("{method} {path} HTTP/1.1\r\n"));
for (k, v) in &header_lines {
request.push_str(&format!("{k}: {v}\r\n"));
}
request.push_str("\r\n");
stream.write_all(request.as_bytes()).context("unix-socket: write request")?;
if !body.is_empty() {
stream.write_all(&body).context("unix-socket: write body")?;
}
stream.flush().ok();
let mut reader = BufReader::new(stream);
let mut status_line = String::new();
let n = reader
.read_line(&mut status_line)
.context("unix-socket: read status")?;
if n == 0 {
bail!("unix-socket: server closed connection without reply");
}
let (http_version, status) = parse_status_line(&status_line)?;
let mut headers: Vec<(String, String)> = Vec::new();
loop {
let mut line = String::new();
let n = reader.read_line(&mut line).context("unix-socket: read header")?;
if n == 0 {
break;
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
break;
}
if let Some((k, v)) = trimmed.split_once(':') {
headers.push((k.trim().to_string(), v.trim().to_string()));
}
}
let content_length: Option<usize> = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("Content-Length"))
.and_then(|(_, v)| v.parse().ok());
let body = if let Some(len) = content_length {
let mut buf = vec![0u8; len];
reader.read_exact(&mut buf).context("unix-socket: read body")?;
buf
} else {
let mut buf = Vec::new();
reader.read_to_end(&mut buf).context("unix-socket: read body to EOF")?;
buf
};
Ok(UdsResponse {
url: url.to_string(),
final_url: url.to_string(),
status,
body,
headers,
http_version,
duration_ms: t0.elapsed().as_millis() as u64,
})
}
pub fn run(args: &Args) -> Result<()> {
let r = execute(args)?;
if args.verbose >= 1 || args.include_headers || args.head_only || args.full {
let put_to_stdout = args.include_headers || args.head_only || args.full;
let out: &mut dyn Write = if put_to_stdout {
&mut std::io::stdout().lock()
} else {
&mut std::io::stderr().lock()
};
writeln!(out, "< HTTP/{} {}", r.http_version, r.status)?;
for (k, v) in &r.headers {
writeln!(out, "< {k}: {v}")?;
}
writeln!(out, "<")?;
}
if args.head_only && !args.full {
return Ok(());
}
if let Some(path) = &args.output {
std::fs::write(path, &r.body)
.with_context(|| format!("unix-socket: write {}", path.display()))?;
if !args.silent {
eprintln!("Saved to {}", path.display());
}
} else {
std::io::stdout().write_all(&r.body)?;
}
Ok(())
}
fn resolve_method(args: &Args) -> String {
if let Some(m) = &args.method {
return m.to_uppercase();
}
if args.head_only {
return "HEAD".to_string();
}
if args.data.is_some()
|| args.data_raw.is_some()
|| args.data_binary.is_some()
|| !args.data_urlencode.is_empty()
|| args.json.is_some()
{
return "POST".to_string();
}
if args.upload_file.is_some() {
return "PUT".to_string();
}
"GET".to_string()
}
fn resolve_body(args: &Args) -> Result<Vec<u8>> {
if let Some(path) = &args.upload_file {
return std::fs::read(path).with_context(|| format!("upload file '{}'", path.display()));
}
if let Some(json) = &args.json {
return Ok(load_body_string(json)?);
}
if let Some(raw) = &args.data_raw {
return Ok(raw.as_bytes().to_vec());
}
if let Some(bin) = &args.data_binary {
if let Some(p) = bin.strip_prefix('@') {
return std::fs::read(p).with_context(|| format!("data-binary file '{p}'"));
}
return Ok(bin.as_bytes().to_vec());
}
if !args.data_urlencode.is_empty() {
bail!("unix-socket: --data-urlencode not supported over UDS; use -d / --json");
}
if let Some(data) = &args.data {
return Ok(load_body_string(data)?);
}
Ok(Vec::new())
}
fn load_body_string(s: &str) -> Result<Vec<u8>> {
if s == "@-" {
let mut buf = Vec::new();
std::io::stdin().read_to_end(&mut buf).context("body from stdin")?;
return Ok(buf);
}
if let Some(p) = s.strip_prefix('@') {
let raw = std::fs::read(p).with_context(|| format!("body file '{p}'"))?;
return Ok(raw.into_iter().filter(|&b| b != b'\r' && b != b'\n').collect());
}
Ok(s.as_bytes().to_vec())
}
fn parse_url(url: &str) -> Result<(String, String)> {
let (host, path) = if let Some(rest) = url.strip_prefix("http://") {
let (h, p) = rest.split_once('/').unwrap_or((rest, ""));
(h.to_string(), format!("/{p}"))
} else if let Some(rest) = url.strip_prefix("https://") {
let (h, p) = rest.split_once('/').unwrap_or((rest, ""));
(h.to_string(), format!("/{p}"))
} else if url.starts_with('/') {
("localhost".to_string(), url.to_string())
} else {
bail!("unix-socket: URL must start with http://, https://, or /");
};
Ok((host, path))
}
fn parse_status_line(line: &str) -> Result<(String, u16)> {
let trimmed = line.trim_end_matches(['\r', '\n']);
let parts: Vec<&str> = trimmed.splitn(3, ' ').collect();
if parts.len() < 2 {
bail!("unix-socket: malformed status line: {line:?}");
}
let version = parts[0].trim_start_matches("HTTP/").to_string();
let status: u16 = parts[1]
.parse()
.map_err(|e| anyhow!("unix-socket: bad status code '{}': {e}", parts[1]))?;
Ok((version, status))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_url_http_scheme() {
let (h, p) = parse_url("http://localhost/v1.40/version").unwrap();
assert_eq!(h, "localhost");
assert_eq!(p, "/v1.40/version");
}
#[test]
fn parse_url_path_only() {
let (h, p) = parse_url("/_ping").unwrap();
assert_eq!(h, "localhost");
assert_eq!(p, "/_ping");
}
#[test]
fn parse_url_https_scheme() {
let (h, p) = parse_url("https://api/v1/info").unwrap();
assert_eq!(h, "api");
assert_eq!(p, "/v1/info");
}
#[test]
fn parse_url_rejects_unknown_scheme() {
assert!(parse_url("ftp://host/").is_err());
}
#[test]
fn parse_status_ok() {
let (v, s) = parse_status_line("HTTP/1.1 200 OK\r\n").unwrap();
assert_eq!(v, "1.1");
assert_eq!(s, 200);
}
#[test]
fn parse_status_no_reason() {
let (v, s) = parse_status_line("HTTP/1.0 404\r\n").unwrap();
assert_eq!(v, "1.0");
assert_eq!(s, 404);
}
#[test]
fn parse_status_rejects_malformed() {
assert!(parse_status_line("garbage").is_err());
}
}