use std::time::Duration;
use hypertor::{Body, Config, Error, IsolationLevel, IsolationToken, RedirectPolicy, TorClient};
#[test]
fn config_rejects_nonsense() {
assert!(Config::builder().timeout(Duration::ZERO).build().is_err());
assert!(Config::builder().max_response_size(0).build().is_err());
assert!(Config::builder().user_agent("").build().is_err());
}
#[test]
fn config_rejects_a_user_agent_that_would_inject_headers() {
let result = Config::builder()
.user_agent("Mozilla/5.0\r\nX-Injected: yes")
.build();
assert!(result.is_err(), "CRLF in the User-Agent must be rejected");
}
#[test]
fn default_config_is_safe() {
let config = Config::default();
assert!(
config.tls.verify_certificates,
"certificate verification must be on by default"
);
assert_eq!(
config.isolation,
IsolationLevel::PerHost,
"the default must not share circuits across every destination"
);
assert!(
config.connect_timeout <= config.timeout,
"a connect budget larger than the request budget can never elapse"
);
assert!(
!config
.redirect
.evaluate(
&"http://a.onion/".parse().unwrap(),
&"https://tracker.example/".parse().unwrap(),
)
.eq(&hypertor::RedirectAction::Follow),
"onion-to-clearnet redirects must not be followed silently"
);
}
#[test]
fn isolation_tokens_are_distinct() {
let a = IsolationToken::new();
let b = IsolationToken::new();
assert_ne!(a, b);
}
#[test]
fn redirect_policy_strips_credentials_across_origins() {
let policy = RedirectPolicy::default();
assert_eq!(
policy.evaluate(
&"http://a.onion/one".parse().unwrap(),
&"http://a.onion/two".parse().unwrap()
),
hypertor::RedirectAction::Follow
);
assert_eq!(
policy.evaluate(
&"http://a.onion/".parse().unwrap(),
&"http://b.onion/".parse().unwrap()
),
hypertor::RedirectAction::FollowStripped
);
}
#[test]
fn errors_do_not_leak_hostnames() {
let err = Error::Connect {
host: safelog::Sensitive::new("hidden-service.onion".to_string()),
port: 80,
source: Box::new(std::io::Error::other("refused")),
};
let rendered = format!("{err}");
assert!(
!rendered.contains("hidden-service"),
"error text leaked a hostname: {rendered}"
);
assert_eq!(err.host(), Some("hidden-service.onion"));
}
#[test]
fn io_errors_say_what_went_wrong() {
let err = Error::from(std::io::Error::other("connection reset by peer"));
assert!(
err.to_string().contains("connection reset by peer"),
"unhelpful error: {err}"
);
}
#[test]
fn status_errors_carry_the_code() {
use hypertor::Response;
let response = Response::new(
http::StatusCode::TOO_MANY_REQUESTS,
http::Version::HTTP_11,
http::HeaderMap::new(),
bytes::Bytes::new(),
);
let err = response.error_for_status().expect_err("429 is an error");
assert_eq!(err.status(), Some(http::StatusCode::TOO_MANY_REQUESTS));
assert!(!err.is_retryable());
}
#[test]
fn in_memory_bodies_can_be_retried_and_streams_cannot() {
assert!(Body::bytes("payload").is_replayable());
assert!(
!Body::from_stream(futures::stream::once(async {
Ok::<_, std::io::Error>(bytes::Bytes::from_static(b"x"))
}))
.is_replayable()
);
}
#[tokio::test]
async fn decompression_bombs_are_bounded_by_the_response_limit() {
use std::io::Write;
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
encoder.write_all(&vec![0u8; 32 * 1024 * 1024]).unwrap();
let bomb = encoder.finish().unwrap();
assert!(
bomb.len() < 64 * 1024,
"fixture should be small on the wire"
);
let response = http::Response::builder()
.header("content-encoding", "gzip")
.body(http_body_util::Full::new(bytes::Bytes::from(bomb)))
.unwrap();
let err = hypertor::Streaming::from_response(response, 64 * 1024)
.expect("splits")
.buffered()
.await
.expect_err("must refuse");
assert!(
matches!(err, Error::BodyTooLarge { .. }),
"expected a size error, got: {err}"
);
}
#[test]
fn stacked_content_encodings_are_refused_rather_than_half_decoded() {
use hypertor::Encoding;
let mut headers = http::HeaderMap::new();
headers.insert("content-encoding", "gzip, br".parse().unwrap());
assert!(
Encoding::from_headers(&headers).is_err(),
"half-decoding would hand the caller bytes that are still compressed"
);
}
#[tokio::test]
async fn building_a_client_does_not_panic_on_an_ambiguous_crypto_provider() {
let dir = std::env::temp_dir().join("hypertor-crypto-provider-test");
tokio::fs::remove_dir_all(&dir).await.ok();
let client = TorClient::builder()
.lazy_bootstrap(true)
.state_dir(dir.join("state"))
.cache_dir(dir.join("cache"))
.build()
.await
.expect("building a client must not panic or fail before any network use");
assert!(client.config().tls.verify_certificates);
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[tokio::test]
#[ignore = "requires a live Tor connection"]
async fn reaches_an_onion_service() {
let client = TorClient::new().await.expect("bootstraps");
let response = client
.get("http://duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion/")
.expect("valid url")
.send()
.await
.expect("request succeeds");
assert!(response.status().is_success() || response.status().is_redirection());
}
#[tokio::test]
#[ignore = "requires a live Tor connection"]
async fn pooling_makes_the_second_request_faster() {
let client = TorClient::new().await.expect("bootstraps");
let url = "https://check.torproject.org/api/ip";
let cold = std::time::Instant::now();
client
.get(url)
.unwrap()
.send()
.await
.expect("first request");
let cold = cold.elapsed();
let warm = std::time::Instant::now();
client
.get(url)
.unwrap()
.send()
.await
.expect("second request");
let warm = warm.elapsed();
assert!(
warm < cold,
"a pooled connection should be faster: cold {cold:?}, warm {warm:?}"
);
}
#[tokio::test]
#[ignore = "requires a live Tor connection"]
async fn isolation_yields_different_exits() {
let client = TorClient::builder()
.isolation(IsolationLevel::None)
.build()
.await
.expect("bootstraps");
let url = "https://check.torproject.org/api/ip";
let mut seen = std::collections::HashSet::new();
for _ in 0..3 {
let value: serde_json::Value = client
.get(url)
.unwrap()
.isolation(IsolationToken::new())
.send()
.await
.expect("request")
.json()
.expect("json");
if let Some(ip) = value["IP"].as_str() {
seen.insert(ip.to_string());
}
}
assert!(
seen.len() > 1,
"three isolated requests all used one exit relay: {seen:?}"
);
}
#[tokio::test]
#[ignore = "requires a live Tor connection"]
async fn streams_a_body_without_buffering_it() {
let client = TorClient::builder()
.max_response_size(64 * 1024 * 1024)
.build()
.await
.expect("bootstraps");
let mut response = client
.get("https://check.torproject.org/api/ip")
.expect("valid url")
.send_streaming()
.await
.expect("headers arrive");
let mut total = 0usize;
while let Some(chunk) = response.chunk().await.expect("chunk") {
total += chunk.len();
}
assert!(total > 0);
}