#![cfg(unix)]
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
#[derive(Clone)]
struct Reply {
status: u16,
content_type: &'static str,
body: Vec<u8>,
location: Option<String>,
}
impl Reply {
fn ok(content_type: &'static str, body: impl Into<Vec<u8>>) -> Self {
Self {
status: 200,
content_type,
body: body.into(),
location: None,
}
}
fn redirect(to: impl Into<String>) -> Self {
Self {
status: 302,
content_type: "text/plain",
body: Vec::new(),
location: Some(to.into()),
}
}
fn status(status: u16) -> Self {
Self {
status,
content_type: "text/plain",
body: b"no".to_vec(),
location: None,
}
}
}
struct Fixture {
port: u16,
hits: Arc<Mutex<HashMap<String, usize>>>,
}
impl Fixture {
fn serve(routes: HashMap<String, Reply>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("an ephemeral port");
let port = listener.local_addr().unwrap().port();
let hits: Arc<Mutex<HashMap<String, usize>>> = Arc::new(Mutex::new(HashMap::new()));
let counter = Arc::clone(&hits);
let routes = Arc::new(Mutex::new(routes));
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let routes = Arc::clone(&routes);
let counter = Arc::clone(&counter);
answer(stream, &routes.lock().unwrap(), &counter);
}
});
Self { port, hits }
}
fn origin(&self) -> String {
format!("http://127.0.0.1:{}", self.port)
}
fn url(&self, path: &str) -> String {
format!("{}{path}", self.origin())
}
fn requests(&self, path: &str) -> usize {
*self.hits.lock().unwrap().get(path).unwrap_or(&0)
}
}
fn answer(
mut stream: TcpStream,
routes: &HashMap<String, Reply>,
hits: &Arc<Mutex<HashMap<String, usize>>>,
) {
let Ok(peer) = stream.try_clone() else {
return;
};
let mut reader = BufReader::new(peer);
let mut line = String::new();
if reader.read_line(&mut line).is_err() {
return;
}
let path = line.split_whitespace().nth(1).unwrap_or("/").to_string();
*hits.lock().unwrap().entry(path.clone()).or_insert(0) += 1;
loop {
let mut header = String::new();
match reader.read_line(&mut header) {
Ok(0) => break,
Ok(_) if header == "\r\n" || header == "\n" => break,
Ok(_) => continue,
Err(_) => break,
}
}
let reply = routes
.get(&path)
.cloned()
.unwrap_or_else(|| Reply::status(404));
let mut head = format!(
"HTTP/1.1 {} X\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n",
reply.status,
reply.content_type,
reply.body.len()
);
if let Some(location) = &reply.location {
head.push_str(&format!("Location: {location}\r\n"));
}
head.push_str("\r\n");
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(&reply.body);
let _ = stream.flush();
}
fn routes(port_placeholder: &str) -> HashMap<String, Reply> {
let mut routes = HashMap::new();
routes.insert(
"/page.html".into(),
Reply::ok(
"text/html; charset=utf-8",
"<html><body><p>The pangolin ferry timetable was revised in March.</p></body></html>",
),
);
routes.insert(
"/paper".into(),
Reply::ok("application/pdf", minimal_pdf()),
);
routes.insert(
"/lib.rs".into(),
Reply::ok("text/plain", "fn main() { println!(\"hello\"); }"),
);
routes.insert("/hop1".into(), Reply::redirect("/hop2"));
routes.insert("/hop2".into(), Reply::redirect("/hop3"));
routes.insert("/hop3".into(), Reply::redirect("/page.html"));
for n in 1..=7 {
routes.insert(
format!("/loop{n}"),
Reply::redirect(format!("/loop{}", n + 1)),
);
}
routes.insert(
"/offsite".into(),
Reply::redirect(format!("http://{port_placeholder}/elsewhere")),
);
routes.insert(
"/huge".into(),
Reply::ok("text/html", vec![b'a'; (32 * 1024 * 1024) + 64]),
);
routes.insert(
"/image".into(),
Reply::ok("image/png", b"\x89PNG\r\n".to_vec()),
);
routes.insert("/untyped".into(), Reply::ok("", b"something".to_vec()));
routes.insert(
"/liar".into(),
Reply::ok("application/pdf", b"<html>not a pdf at all</html>".to_vec()),
);
routes.insert("/locked".into(), Reply::status(401));
routes.insert("/forbidden".into(), Reply::status(403));
routes.insert("/missing".into(), Reply::status(404));
routes.insert(
"/../../../../etc/passwd".into(),
Reply::ok("text/html", b"<p>root</p>".to_vec()),
);
routes
}
fn minimal_pdf() -> Vec<u8> {
let body = "%PDF-1.4\n\
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n\
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n\
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj\n\
4 0 obj<</Length 74>>stream\n\
BT /F1 12 Tf 72 720 Td (The okapi siding was relaid last autumn.) Tj ET\n\
endstream endobj\n\
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\n\
trailer<</Root 1 0 R>>\n";
body.as_bytes().to_vec()
}
fn fixture() -> &'static Fixture {
static FIXTURE: OnceLock<Fixture> = OnceLock::new();
FIXTURE.get_or_init(|| {
let fixture = Fixture::serve(routes("127.0.0.2:1"));
unsafe { std::env::set_var("SEMLITH_ADD_ORIGIN", fixture.origin()) };
fixture
})
}
struct Harness {
fixture: &'static Fixture,
store: tempfile::TempDir,
}
impl Harness {
fn new() -> Self {
Self {
fixture: fixture(),
store: tempfile::tempdir().unwrap(),
}
}
fn dir(&self) -> &Path {
self.store.path()
}
fn downloads(&self) -> PathBuf {
self.dir().join("downloads")
}
fn fetch(&self, path: &str) -> anyhow::Result<semlith::add::Fetched> {
semlith::add::fetch(&self.fixture.url(path), self.dir())
}
}
#[test]
fn a_page_a_pdf_and_a_text_file_each_land_under_their_host() {
let h = Harness::new();
let page = h.fetch("/page.html").expect("the page");
assert!(page.path.starts_with(h.downloads()), "{:?}", page.path);
assert_eq!(page.path.extension().unwrap(), "html");
let written = std::fs::read_to_string(&page.path).unwrap();
assert!(written.contains("pangolin ferry timetable"), "{written}");
let paper = h.fetch("/paper").expect("the paper");
assert_eq!(paper.path.extension().unwrap(), "pdf");
let source = h.fetch("/lib.rs").expect("the source file");
assert_eq!(source.path.extension().unwrap(), "rs");
assert!(
page.path
.parent()
.unwrap()
.to_string_lossy()
.contains("127.0.0.1"),
"{:?}",
page.path
);
}
#[test]
fn every_refusal_refuses_and_leaves_nothing_behind() {
let h = Harness::new();
let cases: &[(&str, &str)] = &[
("/loop1", "more than"),
("/offsite", "not https"),
("/huge", "larger than"),
("/image", "no reader for"),
("/untyped", "did not say what it is"),
("/liar", "does not start with %PDF-"),
("/locked", "credentials"),
("/forbidden", "credentials"),
("/missing", "404"),
];
for (path, expected) in cases {
let error = match h.fetch(path) {
Ok(fetched) => panic!("{path} was fetched to {:?}", fetched.path),
Err(e) => format!("{e:#}"),
};
assert!(
error.contains(expected),
"{path} was refused, but not for the stated reason: {error}"
);
}
let written = walk(&h.downloads());
assert!(
written.is_empty(),
"a refusal left files behind: {written:?}"
);
let error = semlith::add::fetch("http://example.com/x", h.dir())
.map(|f| format!("fetched {:?}", f.path))
.unwrap_err()
.to_string();
assert!(error.contains("not https"), "{error}");
}
#[test]
fn a_redirect_chain_is_followed_to_its_end_and_no_further() {
let h = Harness::new();
let fetched = h.fetch("/hop1").expect("three hops is inside the limit");
let written = std::fs::read_to_string(&fetched.path).unwrap();
assert!(written.contains("pangolin ferry timetable"), "{written}");
assert!(fetched.url.ends_with("/page.html"), "{}", fetched.url);
for hop in ["/hop1", "/hop2", "/hop3"] {
assert_eq!(
h.fixture.requests(hop),
1,
"{hop} was not asked for exactly once"
);
}
assert_eq!(
h.fixture.requests("/loop7"),
0,
"the redirect limit did not hold"
);
}
#[test]
fn a_url_cannot_write_outside_the_downloads_directory() {
let h = Harness::new();
if let Ok(fetched) = h.fetch("/../../../../etc/passwd") {
assert!(
fetched.path.starts_with(h.downloads()),
"a traversing URL escaped to {:?}",
fetched.path
);
assert!(
!fetched.path.to_string_lossy().contains(".."),
"a .. survived into {:?}",
fetched.path
);
}
assert!(
!Path::new("/etc/passwd.html").exists(),
"a file was written outside the store"
);
}
#[test]
fn a_second_fetch_of_the_same_url_does_not_replace_the_first() {
let h = Harness::new();
let first = h.fetch("/page.html").expect("the first fetch");
let second = h.fetch("/page.html").expect("the second fetch");
assert_ne!(first.path, second.path);
assert!(first.path.exists() && second.path.exists());
assert!(
second.path.to_string_lossy().contains("page-2"),
"the second copy is not suffixed: {:?}",
second.path
);
}
#[test]
fn airgap_refuses_before_a_single_request_is_made() {
let h = Harness::new();
let path = "/airgap.html";
let output = std::process::Command::new(env!("CARGO_BIN_EXE_semlith"))
.args(["add", "--airgap", &h.fixture.url(path)])
.env("SEMLITH_HOME", h.dir())
.env("SEMLITH_ADD_ORIGIN", h.fixture.origin())
.env("SEMLITH_STORE", h.dir())
.output()
.expect("running semlith add --airgap");
assert!(!output.status.success(), "--airgap exited zero");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--airgap"),
"the error does not name the flag that caused it: {stderr}"
);
assert_eq!(
h.fixture.requests(path),
0,
"--airgap refused, but only after opening a connection"
);
}
#[test]
#[ignore = "downloads an embedding model on first run"]
fn an_added_page_is_searchable_with_a_locator() {
let h = Harness::new();
let fetched = h.fetch("/page.html").expect("the page");
let mut store = semlith::Semlith::open(h.dir(), None).unwrap();
store.quiet = true;
let report = store
.index_paths(std::slice::from_ref(&fetched.path), |_, _| {})
.unwrap();
assert!(report.chunks > 0, "the page produced no chunks: {report:?}");
let hits = store.search("pangolin ferry timetable", 3).unwrap();
let top = hits.first().expect("the added page found nothing");
assert!(
top.path.ends_with("page.html"),
"the added page did not rank first: {top:?}"
);
assert!(top.start_line >= 1, "a hit with no locator: {top:?}");
}
fn walk(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(walk(&path));
} else {
out.push(path);
}
}
out
}