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;
static RUNTIME_AIRGAP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn set_runtime_airgap(on: bool) {
RUNTIME_AIRGAP.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn runtime_airgap() -> bool {
RUNTIME_AIRGAP.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn airgap_state() -> serde_json::Value {
let from_env = matches!(
std::env::var(embed::AIRGAP_ENV).ok().as_deref(),
Some("1" | "true" | "yes")
);
let reason = if from_env {
Some(if std::env::args().any(|a| a == "--airgap") {
"flag"
} else {
"env"
})
} else if runtime_airgap() {
Some("runtime")
} else {
None
};
serde_json::json!({ "on": reason.is_some(), "reason": reason })
}
struct Outbound {
count: u64,
since: u64,
recent: std::collections::VecDeque<(u64, String, String)>,
}
const OUTBOUND_RECENT: usize = 20;
static OUTBOUND: std::sync::Mutex<Option<Outbound>> = std::sync::Mutex::new(None);
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn start_counting() {
let mut out = OUTBOUND.lock().unwrap_or_else(|e| e.into_inner());
out.get_or_insert_with(|| Outbound {
count: 0,
since: unix_now(),
recent: Default::default(),
});
}
pub fn note_outbound(what: &str, url: &str) {
let host = url
.split_once("://")
.map_or(url, |(_, rest)| rest)
.split(['/', '?', '#'])
.next()
.unwrap_or("");
let host = host.rsplit_once('@').map_or(host, |(_, h)| h).to_string();
start_counting();
let mut out = OUTBOUND.lock().unwrap_or_else(|e| e.into_inner());
let Some(out) = out.as_mut() else { return };
out.count += 1;
out.recent.push_back((unix_now(), what.to_string(), host));
while out.recent.len() > OUTBOUND_RECENT {
out.recent.pop_front();
}
}
pub fn outbound() -> serde_json::Value {
start_counting();
let out = OUTBOUND.lock().unwrap_or_else(|e| e.into_inner());
let Some(out) = out.as_ref() else {
return serde_json::json!({ "count": 0, "since": null, "recent": [] });
};
serde_json::json!({
"count": out.count,
"since": out.since,
"recent": out.recent.iter().rev().map(|(at, what, host)| serde_json::json!({
"at": at, "what": what, "host": host,
})).collect::<Vec<_>>(),
})
}
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(),
}
}
#[cfg(any(debug_assertions, feature = "dev-overrides"))]
const HTTP_ORIGIN_ENV: &str = "SEMLITH_ADD_ORIGIN";
fn require_https(url: &str) -> Result<()> {
let authority = url.split_once("://").map_or("", |(_, rest)| {
rest.split(['/', '?', '#']).next().unwrap_or("")
});
if authority.contains('@') {
bail!(
"{} carries credentials; `semlith add` never sends any — download the \
file yourself and run `semlith index` on it",
url.split_once("://")
.map_or(url, |(scheme, _)| scheme)
.to_string()
+ "://…"
);
}
if url.starts_with("https://") {
return Ok(());
}
#[cfg(any(debug_assertions, feature = "dev-overrides"))]
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.strip_prefix('[') {
Some(rest) => match rest.split_once(']') {
Some((h, tail)) => (h, tail.strip_prefix(':').unwrap_or("443")),
None => bail!("{url} has an unclosed IPv6 address"),
},
None => match authority.rsplit_once(':') {
Some((h, p)) if p.chars().all(|c| c.is_ascii_digit()) => (h, p),
_ => (authority, "443"),
},
};
let port: u16 = port.parse().unwrap_or(443);
use std::net::ToSocketAddrs;
let resolved: Vec<std::net::SocketAddr> = (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 == 198 && (b & 0xfe) == 18)
|| v4.octets()[..3] == [192, 0, 0]
|| v4.octets()[..3] == [192, 88, 99]
|| 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] & 0x80) == 0x80)
|| embedded_v4(v6).is_some_and(|v4| !is_public(&IpAddr::V4(v4)))
|| v6.segments()[..2] == [0x2001, 0])
}
}
}
fn embedded_v4(v6: &std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
let o = v6.octets();
let s = v6.segments();
let last = std::net::Ipv4Addr::new(o[12], o[13], o[14], o[15]);
if let Some(v4) = v6.to_ipv4_mapped() {
return Some(v4);
}
if s[..6] == [0; 6] && !v6.is_loopback() && !v6.is_unspecified() {
return Some(last);
}
if s[..6] == [0x64, 0xff9b, 0, 0, 0, 0] || s[..3] == [0x64, 0xff9b, 1] {
return Some(last);
}
if s[0] == 0x2002 {
return Some(std::net::Ipv4Addr::new(o[2], o[3], o[4], o[5]));
}
None
}
#[derive(Debug)]
struct PublicOnly<R>(R);
impl<R: ureq::unversioned::resolver::Resolver> ureq::unversioned::resolver::Resolver
for PublicOnly<R>
{
fn resolve(
&self,
uri: &ureq::http::Uri,
config: &ureq::config::Config,
timeout: ureq::unversioned::transport::NextTimeout,
) -> std::result::Result<ureq::unversioned::resolver::ResolvedSocketAddrs, ureq::Error> {
let addrs = self.0.resolve(uri, config, timeout)?;
if !private_allowed()
&& let Some(bad) = addrs.iter().find(|a| !is_public(&a.ip()))
{
return Err(ureq::Error::Io(std::io::Error::other(format!(
"{} resolves to {}, which is not on the public internet",
uri.host().unwrap_or("the host"),
bad.ip()
))));
}
Ok(addrs)
}
}
fn agent(resolver: impl ureq::unversioned::resolver::Resolver) -> ureq::Agent {
let config = ureq::Agent::config_builder()
.proxy(None)
.timeout_connect(Some(std::time::Duration::from_secs(10)))
.timeout_global(Some(std::time::Duration::from_secs(60)))
.max_redirects(0)
.max_redirects_will_error(false)
.http_status_as_error(false)
.build();
ureq::Agent::with_parts(
config,
ureq::unversioned::transport::DefaultConnector::default(),
PublicOnly(resolver),
)
}
fn proxy_ignored() -> String {
let set: Vec<&str> = [
("ALL_PROXY", "all_proxy"),
("HTTPS_PROXY", "https_proxy"),
("HTTP_PROXY", "http_proxy"),
]
.into_iter()
.filter_map(|(upper, lower)| {
[upper, lower]
.into_iter()
.find(|v| std::env::var_os(v).is_some())
})
.collect();
if set.is_empty() {
return String::new();
}
format!(
" ({} is set; `semlith add` connects directly and never through a proxy)",
set.join(", ")
)
}
fn get(url: &str) -> Result<(String, String, Vec<u8>)> {
let agent = agent(ureq::unversioned::resolver::DefaultResolver::default());
let mut current = url.to_string();
require_public(¤t)?;
for _ in 0..=MAX_REDIRECTS {
note_outbound("add", ¤t);
let mut response = agent
.get(¤t)
.call()
.with_context(|| format!("fetching {current}{}", proxy_ignored()))?;
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::*;
#[derive(Debug)]
struct Rebinding {
asked: std::sync::atomic::AtomicUsize,
port: u16,
}
impl ureq::unversioned::resolver::Resolver for Rebinding {
fn resolve(
&self,
_uri: &ureq::http::Uri,
_config: &ureq::config::Config,
_timeout: ureq::unversioned::transport::NextTimeout,
) -> std::result::Result<ureq::unversioned::resolver::ResolvedSocketAddrs, ureq::Error>
{
let first = self.asked.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
let ip: std::net::IpAddr = if first {
[93, 184, 215, 14]
} else {
[127, 0, 0, 1]
}
.into();
let mut addrs = self.empty();
addrs.push(std::net::SocketAddr::new(ip, self.port));
Ok(addrs)
}
}
#[test]
fn sk30_a_rebinding_name_never_reaches_loopback() {
use ureq::unversioned::resolver::Resolver;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
let rebinding = || Rebinding {
asked: Default::default(),
port,
};
let uri: ureq::http::Uri = format!("https://rebind.test:{port}/doc.md")
.parse()
.unwrap();
let config = ureq::Agent::config_builder().build();
let timeout = ureq::unversioned::transport::NextTimeout {
after: ureq::unversioned::transport::time::Duration::NotHappening,
reason: ureq::Timeout::Resolve,
};
let connected = |listener: &std::net::TcpListener| {
std::thread::sleep(std::time::Duration::from_millis(200));
listener.accept().is_ok()
};
let plain = rebinding();
let checked = plain.resolve(&uri, &config, timeout).unwrap();
assert!(is_public(&checked[0].ip()));
let unguarded = ureq::Agent::with_parts(
ureq::Agent::config_builder()
.proxy(None)
.timeout_global(Some(std::time::Duration::from_secs(2)))
.build(),
ureq::unversioned::transport::DefaultConnector::default(),
plain,
);
let _ = unguarded.get(&uri.to_string()).call();
assert!(connected(&listener), "the control never reached loopback");
let guarded = rebinding();
let checked = guarded.resolve(&uri, &config, timeout).unwrap();
assert!(is_public(&checked[0].ip()));
let err = agent(guarded).get(&uri.to_string()).call().unwrap_err();
assert!(
err.to_string().contains("not on the public internet"),
"{err}"
);
assert!(!connected(&listener), "a rebinding name reached loopback");
}
#[test]
fn every_ipv6_spelling_of_a_private_address_is_refused() {
for private in [
"::ffff:127.0.0.1",
"::127.0.0.1",
"64:ff9b::a9fe:a9fe",
"64:ff9b:1::a00:1",
"2002:c0a8:0101::1",
"2001:0:4136:e378:8000:63bf:3fff:fdd2",
"fd00::1",
"fe80::1",
"::1",
] {
let ip: std::net::IpAddr = private.parse().unwrap();
assert!(!is_public(&ip), "{private} passed as public");
}
for public in [
"2606:4700:4700::1111",
"64:ff9b::808:808",
"2002:0808:0808::1",
] {
let ip: std::net::IpAddr = public.parse().unwrap();
assert!(is_public(&ip), "{public} refused as private");
}
assert!(require_https("https://user:pw@example.com/x").is_err());
assert!(require_https("https://example.com/a@b").is_ok());
}
#[test]
fn every_reserved_ipv4_block_is_refused_and_its_neighbours_are_not() {
for private in [
"127.0.0.1",
"10.0.0.1",
"172.16.0.1",
"192.168.1.1",
"169.254.169.254",
"100.64.0.1",
"0.0.0.0",
"240.0.0.1",
"255.255.255.255",
"224.0.0.1",
"192.0.2.1",
"198.18.0.0",
"198.19.255.255",
"192.0.0.1",
"192.0.0.255",
"192.88.99.1",
] {
let ip: std::net::IpAddr = private.parse().unwrap();
assert!(!is_public(&ip), "{private} passed as public");
}
for public in [
"8.8.8.8",
"198.17.255.255",
"198.20.0.0",
"192.0.1.0",
"192.88.98.255",
"192.88.100.0",
"100.128.0.1",
] {
let ip: std::net::IpAddr = public.parse().unwrap();
assert!(is_public(&ip), "{public} refused as private");
}
let site_local: std::net::IpAddr = "fec0::1".parse().unwrap();
assert!(!is_public(&site_local), "fec0::1 passed as public");
let below: std::net::IpAddr = "fe7f:ffff::1".parse().unwrap();
assert!(is_public(&below), "fe7f:ffff::1 refused as private");
}
#[test]
fn an_ipv6_literal_is_resolved_not_misparsed() {
if private_allowed() {
return;
}
let err = require_public("https://[::1]:8443/x")
.unwrap_err()
.to_string();
assert!(err.contains("not on the public internet"), "{err}");
let err = require_public("https://[::1]/x").unwrap_err().to_string();
assert!(err.contains("not on the public internet"), "{err}");
}
#[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());
}
}