use flate2::read::GzDecoder;
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::net::Ipv4Addr;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::{Instant, timeout_at};
const MAX_BODY: usize = 64 * 1024;
pub struct Response {
pub headers: String,
pub body: String,
}
impl Response {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.lines().find_map(|l| {
let (k, v) = l.split_once(':')?;
k.trim().eq_ignore_ascii_case(name).then(|| v.trim())
})
}
}
pub async fn get(ip: Ipv4Addr, port: u16, path: &str, wait: Duration) -> Option<Response> {
let deadline = Instant::now() + wait;
let mut stream = timeout_at(deadline, TcpStream::connect((ip, port)))
.await
.ok()?
.ok()?;
let req = format!(
"GET {path} HTTP/1.0\r\nHost: {ip}:{port}\r\nUser-Agent: lsnet\r\nConnection: close\r\n\r\n"
);
timeout_at(deadline, stream.write_all(req.as_bytes()))
.await
.ok()?
.ok()?;
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
while buf.len() < MAX_BODY && !complete(&buf) {
match timeout_at(deadline, stream.read(&mut chunk)).await {
Ok(Ok(n)) if n > 0 => buf.extend_from_slice(&chunk[..n]),
_ => break,
}
}
let split = buf.windows(4).position(|w| w == b"\r\n\r\n")?;
let headers = String::from_utf8_lossy(&buf[..split]).to_string();
let mut body = buf[split + 4..].to_vec();
if headers
.to_ascii_lowercase()
.contains("transfer-encoding: chunked")
{
body = dechunk(&body);
}
if body.starts_with(&[0x1f, 0x8b]) {
let mut out = Vec::new();
let _ = GzDecoder::new(&body[..]).read_to_end(&mut out);
body = out;
}
Some(Response {
headers,
body: String::from_utf8_lossy(&body).to_string(),
})
}
fn complete(buf: &[u8]) -> bool {
let Some(split) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
return false;
};
let headers = String::from_utf8_lossy(&buf[..split]).to_ascii_lowercase();
let body = &buf[split + 4..];
if headers.contains("transfer-encoding: chunked") {
return body.ends_with(b"0\r\n\r\n");
}
headers
.lines()
.find_map(|l| {
l.strip_prefix("content-length:")?
.trim()
.parse::<usize>()
.ok()
})
.is_some_and(|len| body.len() >= len)
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Banner {
pub server: Option<String>,
pub title: Option<String>,
}
pub async fn banner(ip: Ipv4Addr, port: u16, wait: Duration) -> Option<Banner> {
let resp = get(ip, port, "/", wait).await?;
let b = Banner {
server: resp.header("server").map(String::from),
title: tag(&resp.body, "title"),
};
(b.server.is_some() || b.title.is_some()).then_some(b)
}
fn dechunk(mut data: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
while let Some(eol) = data.windows(2).position(|w| w == b"\r\n") {
let size_str = String::from_utf8_lossy(&data[..eol]);
let Ok(size) = usize::from_str_radix(size_str.split(';').next().unwrap_or("").trim(), 16)
else {
break;
};
let start = eol + 2;
if size == 0 || start >= data.len() {
break;
}
let end = (start + size).min(data.len());
out.extend_from_slice(&data[start..end]);
data = &data[(end + 2).min(data.len())..];
}
out
}
pub fn parse_url(url: &str) -> Option<(Ipv4Addr, u16, String)> {
let rest = url.strip_prefix("http://")?;
let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
let (host, port) = authority
.split_once(':')
.map_or((authority, "80"), |(h, p)| (h, p));
if path.chars().any(|c| c.is_control() || c.is_whitespace()) {
return None;
}
Some((host.parse().ok()?, port.parse().ok()?, format!("/{path}")))
}
pub fn tag(xml: &str, name: &str) -> Option<String> {
let lower = xml.to_ascii_lowercase();
let open = format!("<{}", name.to_ascii_lowercase());
let start = lower.find(&open)?;
let content_start = start + lower[start..].find('>')? + 1;
let end = content_start + lower[content_start..].find('<')?;
let text = xml[content_start..end]
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'");
(!text.is_empty()).then_some(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_urls() {
assert_eq!(
parse_url("http://192.168.1.1:41389/rootDesc.xml"),
Some((
"192.168.1.1".parse().unwrap(),
41389,
"/rootDesc.xml".into()
))
);
assert_eq!(
parse_url("http://10.0.0.2"),
Some(("10.0.0.2".parse().unwrap(), 80, "/".into()))
);
assert_eq!(parse_url("http://10.0.0.2/a\rX-Evil: 1"), None);
}
#[test]
fn dechunks() {
assert_eq!(
dechunk(b"5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"),
b"hello world"
);
}
#[test]
fn extracts_tags() {
let xml =
"<root><device><friendlyName>Den TV & more</friendlyName><Title x='1'>Hi</Title>";
assert_eq!(tag(xml, "friendlyName").as_deref(), Some("Den TV & more"));
assert_eq!(tag(xml, "title").as_deref(), Some("Hi"));
assert_eq!(tag(xml, "missing"), None);
}
}