use anyhow::{Context, Result, bail};
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::{embed, home};
pub const MAX_BYTES: u64 = 32 * 1024 * 1024;
const MAX_REDIRECTS: usize = 5;
pub const DOWNLOADS: &str = "downloads";
#[derive(Debug, Clone)]
pub struct Fetched {
pub url: String,
pub path: PathBuf,
pub bytes: usize,
}
pub fn fetch(url: &str, store_dir: &Path) -> Result<Fetched> {
if embed::airgap() {
bail!(
"`semlith add` fetches {url} over the network, which --airgap forbids; \
download the file yourself and run `semlith index` on it"
);
}
let url = rewrite(url);
require_https(&url)?;
let (final_url, content_type, body) = get(&url)?;
let extension = extension_for(&content_type, &final_url, &body)?;
let path = destination(store_dir, &final_url, &extension)?;
let parent = path.parent().unwrap_or(store_dir);
crate::home::secure_dir(parent)?;
std::fs::write(&path, &body).with_context(|| format!("writing {}", path.display()))?;
register_root(store_dir)?;
Ok(Fetched {
url: final_url,
path,
bytes: body.len(),
})
}
pub fn downloads_dir(store_dir: &Path) -> PathBuf {
store_dir.join(DOWNLOADS)
}
fn rewrite(url: &str) -> String {
let Some(rest) = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))
else {
return url.to_string();
};
let parts: Vec<&str> = rest.splitn(4, '/').collect();
match parts.as_slice() {
[owner, repo, "blob", tail] => {
format!("https://raw.githubusercontent.com/{owner}/{repo}/{tail}")
}
_ => url.to_string(),
}
}
const HTTP_ORIGIN_ENV: &str = "SEMLITH_ADD_ORIGIN";
fn require_https(url: &str) -> Result<()> {
if url.starts_with("https://") {
return Ok(());
}
if let Some(origin) = std::env::var(HTTP_ORIGIN_ENV)
.ok()
.filter(|o| !o.is_empty())
&& url.starts_with(&origin)
{
return Ok(());
}
bail!("{url} is not https; `semlith add` fetches over https only")
}
pub const ALLOW_PRIVATE_ENV: &str = "SEMLITH_ADD_ALLOW_PRIVATE";
fn private_allowed() -> bool {
std::env::var_os(ALLOW_PRIVATE_ENV).is_some_and(|v| v == "1" || v == "true")
}
fn require_public(url: &str) -> Result<()> {
if private_allowed() {
return Ok(());
}
let Some(authority) = url
.split_once("://")
.map(|(_, rest)| rest.split(['/', '?', '#']).next().unwrap_or(""))
else {
bail!("{url} is not a URL semlith can resolve");
};
let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) if !h.ends_with(']') && p.chars().all(|c| c.is_ascii_digit()) => (h, p),
_ => (authority, "443"),
};
let host = host.trim_start_matches('[').trim_end_matches(']');
use std::net::ToSocketAddrs;
let resolved: Vec<std::net::SocketAddr> = format!("{host}:{port}")
.to_socket_addrs()
.with_context(|| format!("resolving {host}"))?
.collect();
if resolved.is_empty() {
bail!("{host} resolves to nothing");
}
for address in resolved {
if !is_public(&address.ip()) {
bail!(
"{host} resolves to {}, which is not on the public internet. \
`semlith add` fetches from the internet; set {ALLOW_PRIVATE_ENV}=1 \
if you meant to reach an address only this machine or this network \
can see.",
address.ip()
);
}
}
Ok(())
}
fn is_public(ip: &std::net::IpAddr) -> bool {
use std::net::IpAddr;
match ip {
IpAddr::V4(v4) => {
let [a, b, ..] = v4.octets();
!(v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_unspecified()
|| v4.is_multicast()
|| (a == 100 && (64..128).contains(&b))
|| a == 0
|| a >= 240)
}
IpAddr::V6(v6) => {
!(v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| (v6.octets()[0] & 0xfe) == 0xfc
|| (v6.octets()[0] == 0xfe && (v6.octets()[1] & 0xc0) == 0x80)
|| v6
.to_ipv4_mapped()
.is_some_and(|v4| !is_public(&IpAddr::V4(v4))))
}
}
}
fn get(url: &str) -> Result<(String, String, Vec<u8>)> {
let agent = ureq::Agent::config_builder()
.max_redirects(0)
.max_redirects_will_error(false)
.http_status_as_error(false)
.build()
.new_agent();
let mut current = url.to_string();
require_public(¤t)?;
for _ in 0..=MAX_REDIRECTS {
let mut response = agent
.get(¤t)
.call()
.with_context(|| format!("fetching {current}"))?;
let status = response.status().as_u16();
if (300..400).contains(&status) {
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(|v| v.to_string())
.with_context(|| format!("{current} redirected without saying where"))?;
current = absolute(¤t, &location);
require_https(¤t)?;
require_public(¤t)?;
continue;
}
if status == 401 || status == 403 {
bail!(
"{current} needs credentials ({status}); `semlith add` never sends any — \
download the file yourself and run `semlith index` on it"
);
}
if status >= 400 {
bail!("{current} answered {status}");
}
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
let mut body = Vec::new();
response
.body_mut()
.as_reader()
.take(MAX_BYTES + 1)
.read_to_end(&mut body)
.with_context(|| format!("reading {current}"))?;
if body.len() as u64 > MAX_BYTES {
bail!(
"{current} is larger than the {} MiB `semlith add` will fetch",
MAX_BYTES / 1024 / 1024
);
}
if body.is_empty() {
bail!("{current} returned nothing");
}
return Ok((current, content_type, body));
}
bail!("{url} redirected more than {MAX_REDIRECTS} times")
}
fn absolute(from: &str, location: &str) -> String {
if location.contains("://") {
return location.to_string();
}
let after_scheme = from.split_once("://").map(|(_, rest)| rest).unwrap_or(from);
let host_end = after_scheme.find('/').unwrap_or(after_scheme.len());
let origin = &from[..from.len() - after_scheme.len() + host_end];
if let Some(rooted) = location.strip_prefix('/') {
return format!("{origin}/{rooted}");
}
let directory = from
.rsplit_once('/')
.map(|(head, _)| head)
.unwrap_or(origin);
format!("{directory}/{location}")
}
fn extension_for(content_type: &str, url: &str, body: &[u8]) -> Result<String> {
let mime = content_type
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if body.starts_with(b"%PDF-") {
return Ok("pdf".to_string());
}
if mime == "application/pdf" {
bail!("{url} is labelled a PDF but does not start with %PDF-");
}
match mime.as_str() {
"text/html" | "application/xhtml+xml" => Ok("html".to_string()),
_ if mime.starts_with("text/") || mime == "application/json" => {
Ok(url_extension(url).unwrap_or_else(|| "txt".to_string()))
}
"" => bail!("{url} did not say what it is"),
other => bail!(
"{url} is {other}, which semlith has no reader for; \
`semlith add` fetches pages, PDFs and text files"
),
}
}
fn url_extension(url: &str) -> Option<String> {
let path = url.split(['?', '#']).next().unwrap_or(url);
let last = path.rsplit('/').next()?;
let ext = last.rsplit_once('.')?.1;
looks_like_extension(ext).then(|| ext.to_ascii_lowercase())
}
fn looks_like_extension(tail: &str) -> bool {
!tail.is_empty()
&& tail.len() <= 8
&& tail.chars().all(|c| c.is_ascii_alphanumeric())
&& tail.chars().any(|c| c.is_ascii_alphabetic())
}
fn destination(store_dir: &Path, url: &str, extension: &str) -> Result<PathBuf> {
let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
let (host, path) = match after_scheme.split_once('/') {
Some((host, path)) => (host, path),
None => (after_scheme, ""),
};
let path = path.split(['?', '#']).next().unwrap_or("");
let path = crate::formats::unpercent(path);
let root = downloads_dir(store_dir);
let mut target = root.join(safe(host));
let mut segments: Vec<String> = path
.split('/')
.filter(|s| !s.is_empty() && *s != "." && *s != "..")
.map(safe)
.collect();
let last = if path.ends_with('/') {
None
} else {
segments.pop()
};
for segment in segments {
target.push(segment);
}
let stem = match last {
None => "index".to_string(),
Some(name) => match name.rsplit_once('.') {
Some((stem, tail)) if !stem.is_empty() && looks_like_extension(tail) => {
stem.to_string()
}
_ => name,
},
};
let candidate = target.join(format!("{stem}.{extension}"));
if !candidate.starts_with(&root) {
bail!("{url} resolves outside the store's downloads directory");
}
Ok(unused(&target, &stem, extension))
}
fn safe(segment: &str) -> String {
let cleaned: String = segment
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '-',
c if c.is_control() => '-',
c => c,
})
.collect();
let cleaned = cleaned.trim_matches('.').trim();
if cleaned.is_empty() {
return "file".to_string();
}
cleaned.chars().take(80).collect()
}
fn unused(dir: &Path, stem: &str, extension: &str) -> PathBuf {
let first = dir.join(format!("{stem}.{extension}"));
if !first.exists() {
return first;
}
for n in 2..1000 {
let candidate = dir.join(format!("{stem}-{n}.{extension}"));
if !candidate.exists() {
return candidate;
}
}
first
}
fn register_root(store_dir: &Path) -> Result<()> {
let mut registry = home::Registry::load()?;
let Some(name) = registry.name_of(store_dir).map(str::to_string) else {
return Ok(());
};
let root = downloads_dir(store_dir);
let Some(entry) = registry.stores.get_mut(&name) else {
return Ok(());
};
if entry.roots.contains(&root) {
return Ok(());
}
entry.roots.push(root);
registry.save()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_github_blob_link_is_rewritten_to_the_file_it_shows() {
assert_eq!(
rewrite("https://github.com/semlith/semlith/blob/main/src/lib.rs"),
"https://raw.githubusercontent.com/semlith/semlith/main/src/lib.rs"
);
assert_eq!(
rewrite("https://github.com/o/r/blob/v1.2.3/docs/a b.md"),
"https://raw.githubusercontent.com/o/r/v1.2.3/docs/a b.md"
);
for untouched in [
"https://github.com/semlith/semlith/issues/41",
"https://github.com/semlith/semlith",
"https://example.com/blob/main/x.rs",
] {
assert_eq!(rewrite(untouched), untouched);
}
}
#[test]
fn a_location_header_resolves_in_all_three_forms() {
let from = "https://a.example/docs/guide.html";
assert_eq!(absolute(from, "https://b.example/x"), "https://b.example/x");
assert_eq!(absolute(from, "/x/y"), "https://a.example/x/y");
assert_eq!(
absolute(from, "other.html"),
"https://a.example/docs/other.html"
);
}
#[test]
fn the_bytes_decide_the_reader_and_an_unknown_type_is_refused() {
assert_eq!(
extension_for("text/html", "https://x.example/p", b"%PDF-1.7 ...").unwrap(),
"pdf"
);
assert!(extension_for("application/pdf", "https://x.example/p", b"<html>").is_err());
assert_eq!(
extension_for("text/html; charset=utf-8", "https://x.example/p", b"<p>").unwrap(),
"html"
);
assert_eq!(
extension_for("text/plain", "https://x.example/a/lib.rs", b"fn main() {}").unwrap(),
"rs"
);
assert_eq!(
extension_for("text/plain", "https://x.example/a/notes", b"hello").unwrap(),
"txt"
);
for refused in ["image/png", "application/zip", "video/mp4", ""] {
assert!(
extension_for(refused, "https://x.example/p", b"\x89PNG").is_err(),
"{refused:?} was accepted"
);
}
}
#[test]
fn a_url_cannot_write_outside_the_downloads_directory() {
let store = std::path::Path::new("/tmp/store");
let root = downloads_dir(store);
for hostile in [
"https://x.example/../../../../etc/passwd",
"https://x.example/..%2f..%2fetc/passwd",
"https://../../etc/passwd",
"https://x.example//etc/passwd",
"https://x.example/a/../../../b",
"https://x.example/%2e%2e/%2e%2e/etc/passwd",
] {
let path = destination(store, hostile, "html").unwrap();
assert!(
path.starts_with(&root),
"{hostile} escaped to {}",
path.display()
);
assert!(
!path.to_string_lossy().contains(".."),
"{hostile} kept a .. in {}",
path.display()
);
}
}
#[test]
fn a_document_is_laid_out_by_host_and_keeps_its_name() {
let store = std::path::Path::new("/tmp/store");
let path = destination(store, "https://arxiv.org/pdf/2501.00001", "pdf").unwrap();
assert!(
path.ends_with("downloads/arxiv.org/pdf/2501.00001.pdf"),
"{path:?}"
);
let path = destination(store, "https://example.com/docs/", "html").unwrap();
assert!(
path.ends_with("downloads/example.com/docs/index.html"),
"{path:?}"
);
let path = destination(store, "https://example.com/p?a=1&b=2", "html").unwrap();
assert!(path.ends_with("downloads/example.com/p.html"), "{path:?}");
}
#[test]
fn plain_http_is_refused_rather_than_upgraded() {
assert!(require_https("http://example.com/x").is_err());
assert!(require_https("ftp://example.com/x").is_err());
assert!(require_https("file:///etc/passwd").is_err());
assert!(require_https("https://example.com/x").is_ok());
}
}