mod common;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use common::{fixture, pdfboss, stdout_str};
struct MockServer {
addr: SocketAddr,
done: Arc<AtomicBool>,
accept_thread: Option<JoinHandle<()>>,
}
impl MockServer {
fn start(data: Vec<u8>) -> MockServer {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let addr = listener.local_addr().expect("local addr");
let done = Arc::new(AtomicBool::new(false));
let done_for_accept = Arc::clone(&done);
let accept_thread = thread::spawn(move || {
for incoming in listener.incoming() {
if done_for_accept.load(Ordering::Acquire) {
break;
}
let Ok(socket) = incoming else { continue };
let data = data.clone();
thread::spawn(move || handle_connection(socket, &data));
}
});
MockServer {
addr,
done,
accept_thread: Some(accept_thread),
}
}
fn url(&self, name: &str) -> String {
format!("http://{}/{name}", self.addr)
}
}
impl Drop for MockServer {
fn drop(&mut self) {
self.done.store(true, Ordering::Release);
let _ = TcpStream::connect(self.addr);
if let Some(handle) = self.accept_thread.take() {
let _ = handle.join();
}
}
}
fn handle_connection(mut socket: TcpStream, data: &[u8]) {
let total = data.len();
loop {
let Some(head) = read_request_head(&mut socket) else {
return; };
if head.starts_with("HEAD ") {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nAccept-Ranges: bytes\r\n\r\n"
);
if socket.write_all(response.as_bytes()).is_err() {
return;
}
continue;
}
match parse_range_header(&head) {
Some((start, end)) if start < total && start <= end => {
let end = end.min(total - 1);
let body = &data[start..=end];
let response = format!(
"HTTP/1.1 206 Partial Content\r\n\
Content-Range: bytes {start}-{end}/{total}\r\n\
Content-Length: {}\r\n\r\n",
body.len()
);
if socket.write_all(response.as_bytes()).is_err() || socket.write_all(body).is_err()
{
return;
}
}
_ => {
let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {total}\r\n\r\n");
if socket.write_all(response.as_bytes()).is_err() || socket.write_all(data).is_err()
{
return;
}
}
}
}
}
fn read_request_head(socket: &mut TcpStream) -> Option<String> {
let mut head = Vec::new();
let mut byte = [0u8; 1];
loop {
match socket.read(&mut byte) {
Ok(0) => {
return if head.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&head).into_owned())
};
}
Ok(_) => {
head.push(byte[0]);
if head.ends_with(b"\r\n\r\n") {
return Some(String::from_utf8_lossy(&head).into_owned());
}
}
Err(_) => return None,
}
}
}
fn parse_range_header(head: &str) -> Option<(usize, usize)> {
let line = head
.lines()
.find(|line| line.to_ascii_lowercase().starts_with("range:"))?;
let spec = line.split('=').nth(1)?.trim();
let (start, end) = spec.split_once('-')?;
Some((start.trim().parse().ok()?, end.trim().parse().ok()?))
}
fn hello_bytes() -> Vec<u8> {
std::fs::read(fixture("hello.pdf")).expect("fixture reads")
}
#[test]
fn json_over_url_matches_local() {
let server = MockServer::start(hello_bytes());
let url = server.url("hello.pdf");
let remote = pdfboss(&["json", &url]);
assert!(remote.status.success(), "json over url failed: {remote:?}");
let file = fixture("hello.pdf");
let local = pdfboss(&["json", file.to_str().unwrap()]);
assert!(
local.status.success(),
"json over local file failed: {local:?}"
);
assert_eq!(
remote.stdout, local.stdout,
"json output over url must match local byte-for-byte"
);
}
#[test]
fn hex_over_url_range_guard_fires() {
let data = hello_bytes();
let len = data.len() as u64;
assert!(
len < 999_999,
"fixture grew past the selector's hard-coded out-of-range bound"
);
let server = MockServer::start(data);
let url = server.url("hello.pdf");
let output = pdfboss(&["hex", &url, "range:0-999999"]);
assert_eq!(
output.status.code(),
Some(1),
"expected the bounds guard to fail: {output:?}"
);
let err = String::from_utf8_lossy(&output.stderr);
assert!(
err.contains(&len.to_string()),
"error does not mention the file length {len}: {err}"
);
}
#[test]
fn q_over_url_basic() {
let server = MockServer::start(hello_bytes());
let url = server.url("hello.pdf");
let output = pdfboss(&["q", &url, ".header.version"]);
assert!(output.status.success(), "q over url failed: {output:?}");
assert_eq!(stdout_str(&output), "\"1.7\"\n");
}
#[test]
fn tui_over_unreachable_url_fails_with_http_error_not_a_feature_gate() {
let output = pdfboss(&["tui", "http://127.0.0.1:1/nope.pdf"]);
assert_eq!(output.status.code(), Some(1), "expected exit 1: {output:?}");
let err = String::from_utf8_lossy(&output.stderr);
assert!(
!err.contains("--features http"),
"tui must not require an opt-in http feature: {err}"
);
assert!(
err.contains("http:"),
"expected the aio HTTP backend's own transport error (\"http: ...\"), got: {err}"
);
}