Files
mail/src/remote.rs
T

1020 lines
38 KiB
Rust

//! Bounded network provider for images embedded in email HTML.
//!
//! Embedded `data:` images are always available because they are already part
//! of the message. HTTP(S) loading is a separate, feature- and consent-gated
//! capability with strict byte/pixel budgets and public-address pinning.
use blitz_traits::net::{Bytes, NetHandler, NetProvider, NetWaker, Request};
use image::{ImageReader, Limits};
#[cfg(feature = "remote-content")]
use reqwest::redirect::Policy;
use std::{
collections::HashMap,
io::Cursor,
sync::{Arc, Mutex},
};
#[cfg(feature = "remote-content")]
use std::{
net::{IpAddr, SocketAddr},
time::Duration,
};
mod scheduler;
pub type ResourcePriorities = Arc<Mutex<std::collections::HashSet<(usize, u64)>>>;
pub fn reprioritize_images() {
scheduler::shared().reprioritize();
}
#[cfg(feature = "remote-content")]
const MAX_REMOTE_RESOURCE_BYTES: usize = 5 * 1024 * 1024;
// CID images are already bounded to 8 MiB by the mail core. Keep the renderer
// in agreement so a valid embedded attachment is not rejected after it has
// been resolved to a data URI.
const MAX_EMBEDDED_RESOURCE_BYTES: usize = 8 * 1024 * 1024;
const MAX_IMAGE_DIMENSION: u32 = 4_096;
const MAX_IMAGE_PIXELS: u64 = 8 * 1024 * 1024;
// Modern phone JPEGs commonly exceed the generic raster limits. JPEG IDCT
// scaling reduces these before allocating pixels, so larger sources remain
// safe while PNG/GIF/WebP keep the stricter limits above.
const MAX_JPEG_SOURCE_DIMENSION: u32 = 16_384;
const MAX_JPEG_SOURCE_PIXELS: u64 = 64 * 1024 * 1024;
// Decoded images keep at most this many pixels, the memory of a 2048 x 2048
// image, so tall and wide images keep their detail. Their longer side stays
// within the GPU renderer's image atlas.
const MAX_DECODED_DIMENSION: u32 = 4096;
const MAX_DECODED_PIXELS: u64 = 2048 * 2048;
const MAX_DOCUMENT_BYTES: u64 = 20 * 1024 * 1024;
const MAX_DOCUMENT_PIXELS: u64 = 16 * 1024 * 1024;
const MAX_DOCUMENT_REQUESTS: u8 = 64;
const MAX_TRACKED_DOCUMENTS: usize = 128;
pub(crate) fn image_decode_limits() -> blitz_dom::net::ImageDecodeLimits {
blitz_dom::net::ImageDecodeLimits {
max_source_dimension: MAX_IMAGE_DIMENSION,
max_source_pixels: MAX_IMAGE_PIXELS,
max_jpeg_source_dimension: MAX_JPEG_SOURCE_DIMENSION,
max_jpeg_source_pixels: MAX_JPEG_SOURCE_PIXELS,
max_alloc: MAX_IMAGE_PIXELS * 4,
target_dimension: MAX_DECODED_DIMENSION,
target_pixels: MAX_DECODED_PIXELS,
}
}
#[derive(Default)]
struct DocumentBudget {
requests: u8,
embedded_requests: u8,
bytes: u64,
pixels: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResourceState {
Loading,
Ready,
Blocked,
Failed,
Limited,
}
pub type ResourceLedger = Arc<Mutex<HashMap<(usize, u64), ResourceState>>>;
pub fn resource_key(id: usize, url: &str) -> (usize, u64) {
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hash);
(id, hash.finish())
}
fn record(ledger: &ResourceLedger, id: usize, url: &str, state: ResourceState) {
let mut map = ledger
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if map.len() >= 4096 {
map.clear();
}
map.insert(resource_key(id, url), state);
}
fn forget_resource(ledger: &ResourceLedger, id: usize, url: &str) {
ledger
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&resource_key(id, url));
}
struct LimitedImageProvider {
ledger: ResourceLedger,
#[cfg(feature = "remote-content")]
allow_remote: bool,
waker: Arc<dyn NetWaker>,
scheduler: Arc<scheduler::Scheduler>,
priorities: ResourcePriorities,
budgets: Arc<Mutex<HashMap<usize, DocumentBudget>>>,
}
#[cfg(test)]
pub fn email_image_provider_tracked(
waker: Arc<dyn NetWaker>,
allow_remote: bool,
ledger: ResourceLedger,
) -> Result<Arc<dyn NetProvider>, String> {
email_image_provider_prioritized(waker, allow_remote, ledger, ResourcePriorities::default())
}
pub fn email_image_provider_prioritized(
waker: Arc<dyn NetWaker>,
allow_remote: bool,
ledger: ResourceLedger,
priorities: ResourcePriorities,
) -> Result<Arc<dyn NetProvider>, String> {
#[cfg(not(feature = "remote-content"))]
let _ = allow_remote;
Ok(Arc::new(LimitedImageProvider {
ledger,
#[cfg(feature = "remote-content")]
allow_remote: allow_remote && cfg!(feature = "remote-content"),
waker,
scheduler: scheduler::shared(),
priorities,
budgets: Arc::new(Mutex::new(HashMap::new())),
}))
}
impl NetProvider for LimitedImageProvider {
fn fetch(&self, document_id: usize, request: Request, handler: Box<dyn NetHandler>) {
let embedded = request.url.scheme() == "data";
let permitted = (embedded
&& request
.url
.as_str()
.get(..11)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:image/")))
|| {
#[cfg(feature = "remote-content")]
{
self.allow_remote && safe_remote_url_shape(&request.url)
}
#[cfg(not(feature = "remote-content"))]
{
false
}
};
// Rejected trackers must not spend the embedded-image allowance.
if request.method != blitz_traits::net::http::Method::GET
|| !permitted
|| request.signal.as_ref().is_some_and(|s| s.aborted())
|| !reserve_request(&self.budgets, document_id, embedded)
{
record(
&self.ledger,
document_id,
request.url.as_str(),
if permitted {
ResourceState::Limited
} else {
ResourceState::Blocked
},
);
self.waker.wake(document_id);
return;
}
record(
&self.ledger,
document_id,
request.url.as_str(),
ResourceState::Loading,
);
let ledger = self.ledger.clone();
let waker = Arc::clone(&self.waker);
let scheduler = self.scheduler.clone();
let priorities = self.priorities.clone();
let budgets = Arc::clone(&self.budgets);
tokio::spawn(async move {
let started = crate::renderer::render_timings_enabled().then(std::time::Instant::now);
let work = async {
let permit = scheduler
.clone()
.acquire(document_id, request.url.as_str(), embedded, priorities)
.await;
let queued = started.map(|start| start.elapsed());
// Queueing behind other images must not consume the download
// deadline. A newsletter can use all 64 request slots.
let bytes = tokio::time::timeout(std::time::Duration::from_secs(16), async {
match request.url.scheme() {
"data" => load_data_image(request.url.as_str()),
#[cfg(feature = "remote-content")]
"http" | "https" => load_http_image(&request).await,
_ => None,
}
})
.await
.ok()??;
if let (Some(start), Some(queued)) = (started, queued) {
eprintln!(
"email image: queue={:.2}ms transfer={:.2}ms bytes={}",
queued.as_secs_f64() * 1000.0,
start.elapsed().saturating_sub(queued).as_secs_f64() * 1000.0,
bytes.len()
);
}
let Some(image_work) = validated_image_work(&bytes) else {
record(
&ledger,
document_id,
request.url.as_str(),
ResourceState::Limited,
);
waker.wake(document_id);
return None;
};
// The byte permit is acquired before the image decoder can
// allocate pixels. A cancelled waiter releases its slot; once
// spawned, the blocking worker owns both permits until done.
let Some(decode_permit) = scheduler
.acquire_decode_bytes(image_work.estimated_bytes)
.await
else {
record(
&ledger,
document_id,
request.url.as_str(),
ResourceState::Limited,
);
waker.wake(document_id);
return None;
};
let signal = request.signal.clone();
let url = request.url.to_string();
let ledger = ledger.clone();
let decode_waker = waker.clone();
// Decoding may block. Keep it off Tokio's async executor and
// hold the work permit.
tokio::task::spawn_blocking(move || {
let _permit = permit;
let _decode_permit = decode_permit;
let decode_start = started.map(|_| std::time::Instant::now());
if signal.as_ref().is_some_and(|s| s.aborted()) {
return;
}
if reserve_image(&budgets, document_id, bytes.len() as u64, image_work.pixels) {
let decoded =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
handler.bytes(url.clone(), Bytes::from(bytes));
}));
if signal.as_ref().is_some_and(|s| s.aborted()) {
// Navigation may abort while Blitz is decoding.
// Its old document receiver then drops the image;
// do not restore this URL to the shared ledger.
forget_resource(&ledger, document_id, &url);
} else {
record(
&ledger,
document_id,
&url,
if decoded.is_ok() {
ResourceState::Ready
} else {
ResourceState::Failed
},
);
}
} else {
if signal.as_ref().is_some_and(|s| s.aborted()) {
forget_resource(&ledger, document_id, &url);
} else {
record(&ledger, document_id, &url, ResourceState::Limited);
}
}
// Cover a navigation racing with the state write above:
// clear() aborts before it clears the ledger, so either
// this removal or clear() wins after the last old write.
if signal.as_ref().is_some_and(|s| s.aborted()) {
forget_resource(&ledger, document_id, &url);
}
// Blocking work is not aborted when its JoinHandle is
// dropped. Always wake after delivery, even if the async
// caller was cancelled while a decode was finishing.
decode_waker.wake(document_id);
if let Some(start) = decode_start {
eprintln!(
"email image decode + delivery: {:.2}ms",
start.elapsed().as_secs_f64() * 1000.0
);
}
})
.await
.ok()
};
let cancelled = async {
loop {
if request.signal.as_ref().is_some_and(|s| s.aborted()) {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
};
tokio::select! {
_ = cancelled => {},
_ = work => {},
}
let mut states = ledger
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let key = resource_key(document_id, request.url.as_str());
if request.signal.as_ref().is_some_and(|s| s.aborted()) {
states.remove(&key);
} else if states.get(&key) == Some(&ResourceState::Loading) {
states.insert(key, ResourceState::Failed);
}
drop(states);
waker.wake(document_id);
});
}
}
#[cfg(feature = "remote-content")]
async fn load_http_image(request: &Request) -> Option<Vec<u8>> {
if request
.signal
.as_ref()
.is_some_and(|signal| signal.aborted())
{
return None;
}
let mut url = request.url.clone();
let mut redirects = 0_u8;
let response = loop {
let client = pinned_public_client(&url).await?;
let response = client.get(url.clone()).send().await.ok()?;
if !response.status().is_redirection() {
break response;
}
if redirects >= 3 {
return None;
}
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())?;
url = url.join(location).ok()?;
redirects += 1;
};
consume_http_image(response, request.signal.as_ref()).await
}
#[cfg(feature = "remote-content")]
async fn consume_http_image(
mut response: reqwest::Response,
signal: Option<&blitz_traits::net::AbortSignal>,
) -> Option<Vec<u8>> {
if !response.status().is_success()
|| response
.content_length()
.is_some_and(|length| length > MAX_REMOTE_RESOURCE_BYTES as u64)
{
return None;
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
let media_type = content_type.split(';').next().unwrap_or("").trim();
// Some image CDNs return generic or absent MIME types. Accept those only
// after the raster decoder recognizes and validates the payload below.
if !(media_type.starts_with("image/")
|| media_type.is_empty()
|| media_type == "application/octet-stream")
|| media_type.starts_with("image/svg")
{
return None;
}
let mut bytes = Vec::with_capacity(
response
.content_length()
.unwrap_or(0)
.min(MAX_REMOTE_RESOURCE_BYTES as u64) as usize,
);
while let Some(chunk) = response.chunk().await.ok()? {
if signal.is_some_and(|signal| signal.aborted())
|| bytes.len().saturating_add(chunk.len()) > MAX_REMOTE_RESOURCE_BYTES
{
return None;
}
bytes.extend_from_slice(&chunk);
}
validated_pixel_count(&bytes).map(|_| bytes)
}
/// Resolve and pin public addresses before opening the connection. This
/// prevents a hostname or redirect from reaching loopback, LAN, link-local, or
/// metadata services through DNS rebinding.
#[cfg(feature = "remote-content")]
async fn pinned_public_client(url: &reqwest::Url) -> Option<reqwest::Client> {
if !safe_remote_url_shape(url) {
return None;
}
let port = url.port_or_known_default()?;
let (mut addresses, resolve_host): (Vec<SocketAddr>, Option<String>) = match url.host()? {
url::Host::Domain(host) => (
tokio::net::lookup_host((host, port)).await.ok()?.collect(),
Some(host.to_owned()),
),
url::Host::Ipv4(address) => (vec![SocketAddr::new(address.into(), port)], None),
url::Host::Ipv6(address) => (vec![SocketAddr::new(address.into(), port)], None),
};
if addresses.is_empty()
|| addresses
.iter()
.any(|address| !safe_remote_ip(address.ip()))
{
return None;
}
// Keep both address families so the HTTP connector can race/fall back
// when IPv6 or one CDN endpoint is unreachable. Every address is vetted.
addresses.sort_unstable();
addresses.dedup();
addresses.truncate(32);
static CLIENTS: std::sync::OnceLock<Mutex<HashMap<String, reqwest::Client>>> =
std::sync::OnceLock::new();
let cache = CLIENTS.get_or_init(Default::default);
let key = format!("{}|{}|{:?}", url.scheme(), url.host_str()?, addresses);
if let Some(client) = cache.lock().ok()?.get(&key) {
return Some(client.clone());
}
let mut builder = reqwest::Client::builder()
.no_proxy()
.user_agent("Flectar Mail bounded remote image loader/0.1")
.connect_timeout(Duration::from_secs(4))
.timeout(Duration::from_secs(12))
.pool_max_idle_per_host(2)
.pool_idle_timeout(Duration::from_secs(30))
.redirect(Policy::none());
if let Some(host) = resolve_host {
builder = builder.resolve_to_addrs(&host, &addresses);
}
let client = builder.build().ok()?;
let mut clients = cache.lock().ok()?;
if clients.len() >= 32 {
clients.clear();
}
clients.insert(key, client.clone());
Some(client)
}
fn load_data_image(url: &str) -> Option<Vec<u8>> {
// Base64 overhead is 4/3; this pre-check bounds allocation even before
// the streaming decoder sees the data.
if url.len() > MAX_EMBEDDED_RESOURCE_BYTES.saturating_mul(2) {
return None;
}
let data = data_url::DataUrl::process(url).ok()?;
if data.mime_type().type_ != "image" || data.mime_type().subtype == "svg+xml" {
return None;
}
let (bytes, _) = data.decode_to_vec().ok()?;
(bytes.len() <= MAX_EMBEDDED_RESOURCE_BYTES).then_some(bytes)
}
#[derive(Clone, Copy)]
struct ImageWorkEstimate {
pixels: u64,
estimated_bytes: u64,
}
fn validated_image_work(bytes: &[u8]) -> Option<ImageWorkEstimate> {
let mut reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let format = reader.format();
let (max_dimension, max_pixels) = if format == Some(image::ImageFormat::Jpeg) {
(MAX_JPEG_SOURCE_DIMENSION, MAX_JPEG_SOURCE_PIXELS)
} else {
(MAX_IMAGE_DIMENSION, MAX_IMAGE_PIXELS)
};
let mut limits = Limits::default();
limits.max_image_width = Some(max_dimension);
limits.max_image_height = Some(max_dimension);
limits.max_alloc = Some(MAX_IMAGE_PIXELS * 4);
reader.limits(limits);
let (width, height) = reader.into_dimensions().ok()?;
let pixels = u64::from(width).checked_mul(u64::from(height))?;
(width > 0
&& height > 0
&& width <= max_dimension
&& height <= max_dimension
&& pixels <= max_pixels)
.then(|| {
// Charge the retained decoded pixels, rather than the source
// pixels discarded by downsampling.
let (width, height) = image_decode_limits().target_size(width, height);
u64::from(width) * u64::from(height)
})
.and_then(|retained_pixels| {
// The decoder may hold its source buffer, a resize intermediate,
// and RGBA output at once. Charge two RGBA buffers at both source
// and target sizes, plus the compressed input. For large JPEGs,
// native scaling bounds the decoded source allocation; the
// generic raster cap also covers the decoder's fallback limit.
let source_pixels = if format == Some(image::ImageFormat::Jpeg) {
pixels.min(MAX_IMAGE_PIXELS)
} else {
pixels
};
let estimated_bytes = source_pixels
.checked_add(retained_pixels)?
.checked_mul(8)?
.checked_add(u64::try_from(bytes.len()).ok()?)?;
Some(ImageWorkEstimate {
pixels: retained_pixels,
estimated_bytes,
})
})
}
#[cfg(any(feature = "remote-content", test))]
fn validated_pixel_count(bytes: &[u8]) -> Option<u64> {
validated_image_work(bytes).map(|work| work.pixels)
}
fn reserve_request(
budgets: &Mutex<HashMap<usize, DocumentBudget>>,
document_id: usize,
embedded: bool,
) -> bool {
// A panic in unrelated remote-image work must not turn every later image
// request into a second panic. The counters remain conservative if a
// poisoned lock is recovered.
let mut budgets = budgets
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if budgets.len() >= MAX_TRACKED_DOCUMENTS && !budgets.contains_key(&document_id) {
budgets.clear();
}
let budget = budgets.entry(document_id).or_default();
let requests = if embedded {
&mut budget.embedded_requests
} else {
&mut budget.requests
};
if *requests >= MAX_DOCUMENT_REQUESTS {
return false;
}
*requests += 1;
true
}
fn reserve_image(
budgets: &Mutex<HashMap<usize, DocumentBudget>>,
document_id: usize,
bytes: u64,
pixels: u64,
) -> bool {
let mut budgets = budgets
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let budget = budgets.entry(document_id).or_default();
let Some(total_bytes) = budget.bytes.checked_add(bytes) else {
return false;
};
let Some(total_pixels) = budget.pixels.checked_add(pixels) else {
return false;
};
if total_bytes > MAX_DOCUMENT_BYTES || total_pixels > MAX_DOCUMENT_PIXELS {
return false;
}
budget.bytes = total_bytes;
budget.pixels = total_pixels;
true
}
#[cfg(feature = "remote-content")]
fn safe_remote_url_shape(url: &reqwest::Url) -> bool {
if !matches!(url.scheme(), "http" | "https")
|| !url.username().is_empty()
|| url.password().is_some()
|| url.host_str().is_none()
{
return false;
}
{
let Some(port) = url.port_or_known_default() else {
return false;
};
if !matches!((url.scheme(), port), ("http", 80) | ("https", 443)) {
return false;
}
}
match url.host() {
Some(url::Host::Ipv4(address)) => safe_remote_ip(address.into()),
Some(url::Host::Ipv6(address)) => safe_remote_ip(address.into()),
Some(url::Host::Domain(_)) => true,
None => false,
}
}
#[cfg(feature = "remote-content")]
fn safe_remote_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => {
let octets = ip.octets();
!(ip.is_private()
|| ip.is_loopback()
|| ip.is_link_local()
|| ip.is_multicast()
|| ip.is_unspecified()
|| ip.is_broadcast()
|| ip.is_documentation()
|| octets[0] == 0
|| (octets[0] == 100 && (64..=127).contains(&octets[1]))
|| (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
|| (octets[0] == 198 && matches!(octets[1], 18 | 19))
|| octets[0] >= 240)
}
IpAddr::V6(ip) => {
!(ip.is_loopback()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
|| ip.is_multicast()
|| ip.is_unspecified()
|| ip.segments()[..2] == [0x2001, 0x0db8])
&& ip
.to_ipv4_mapped()
.is_none_or(|mapped| safe_remote_ip(mapped.into()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn document_budget_is_bounded() {
let budgets = Mutex::new(HashMap::new());
assert!(reserve_image(
&budgets,
7,
MAX_DOCUMENT_BYTES,
MAX_DOCUMENT_PIXELS
));
assert!(!reserve_image(&budgets, 7, 1, 1));
for _ in 0..MAX_DOCUMENT_REQUESTS {
assert!(reserve_request(&budgets, 8, false));
}
assert!(!reserve_request(&budgets, 8, false));
assert!(reserve_request(&budgets, 8, true));
}
#[test]
#[cfg(feature = "remote-content")]
fn remote_url_shape_rejects_credentials() {
let url = reqwest::Url::parse("https://user:secret@example.com/pixel.png").unwrap();
assert!(!safe_remote_url_shape(&url));
}
const PIXEL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
struct Counter(Arc<std::sync::atomic::AtomicUsize>);
impl NetHandler for Counter {
fn bytes(self: Box<Self>, _: String, _: Bytes) {
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
struct HeldDelivery {
started: Arc<std::sync::atomic::AtomicBool>,
release: Arc<std::sync::atomic::AtomicBool>,
}
impl NetHandler for HeldDelivery {
fn bytes(self: Box<Self>, _: String, _: Bytes) {
use std::sync::atomic::Ordering;
self.started.store(true, Ordering::SeqCst);
while !self.release.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
}
#[test]
fn abort_during_delivery_cannot_restore_old_document_state() {
use std::sync::atomic::{AtomicBool, Ordering};
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let scheduler = scheduler::Scheduler::new(1, 1);
let ledger = ResourceLedger::default();
let provider = LimitedImageProvider {
ledger: ledger.clone(),
#[cfg(feature = "remote-content")]
allow_remote: false,
waker: Arc::new(|_| {}),
scheduler: scheduler.clone(),
priorities: ResourcePriorities::default(),
budgets: Arc::default(),
};
let started = Arc::new(AtomicBool::new(false));
let release = Arc::new(AtomicBool::new(false));
let abort = blitz_traits::net::AbortController::default();
let signal = abort.signal.clone();
provider.fetch(
9,
Request::get(url::Url::parse(PIXEL).unwrap()).signal(signal),
Box::new(HeldDelivery {
started: started.clone(),
release: release.clone(),
}),
);
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while !started.load(Ordering::SeqCst) {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
})
.await
.unwrap();
record(&ledger, 10, "new-document", ResourceState::Ready);
abort.abort();
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while ledger.lock().unwrap().contains_key(&resource_key(9, PIXEL)) {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
})
.await
.unwrap();
release.store(true, Ordering::SeqCst);
let next = tokio::time::timeout(
std::time::Duration::from_secs(2),
scheduler
.clone()
.acquire(10, "next", true, ResourcePriorities::default()),
)
.await
.unwrap();
drop(next);
let states = ledger.lock().unwrap();
assert!(!states.contains_key(&resource_key(9, PIXEL)));
assert_eq!(
states.get(&resource_key(10, "new-document")),
Some(&ResourceState::Ready)
);
});
}
#[test]
fn queued_images_keep_their_download_deadline() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let scheduler = scheduler::Scheduler::new(1, 1);
let held = scheduler
.clone()
.acquire(9, "held", true, ResourcePriorities::default())
.await;
let ledger = ResourceLedger::default();
let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (done, mut completions) = tokio::sync::mpsc::unbounded_channel();
let provider = LimitedImageProvider {
ledger: ledger.clone(),
#[cfg(feature = "remote-content")]
allow_remote: false,
waker: Arc::new(move |_| {
let _ = done.send(());
}),
scheduler,
priorities: ResourcePriorities::default(),
budgets: Arc::default(),
};
provider.fetch(
9,
Request::get(url::Url::parse(PIXEL).unwrap()),
Box::new(Counter(count.clone())),
);
// Deliberately longer than the production transfer deadline.
tokio::time::sleep(std::time::Duration::from_secs(17)).await;
assert_eq!(
ledger.lock().unwrap().get(&resource_key(9, PIXEL)),
Some(&ResourceState::Loading)
);
drop(held);
tokio::time::timeout(std::time::Duration::from_secs(3), completions.recv())
.await
.unwrap()
.unwrap();
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(
ledger.lock().unwrap().get(&resource_key(9, PIXEL)),
Some(&ResourceState::Ready)
);
});
}
#[test]
fn pixel_budget_covers_retained_downsampled_images() {
for (width, height) in [(3000, 301), (301, 3000), (2049, 3), (17, 29)] {
for format in [image::ImageFormat::Png, image::ImageFormat::Jpeg] {
let mut bytes = Cursor::new(Vec::new());
image::RgbImage::new(width, height)
.write_to(&mut bytes, format)
.unwrap();
let budget = validated_pixel_count(bytes.get_ref()).unwrap();
let work = validated_image_work(bytes.get_ref()).unwrap();
let source_bytes = u64::from(width) * u64::from(height) * 4;
let retained_bytes = work.pixels * 4;
assert!(
work.estimated_bytes
>= source_bytes + retained_bytes + bytes.get_ref().len() as u64
);
let decoded = image_decode_limits().decode(bytes.get_ref()).unwrap();
let retained = u64::from(decoded.pixel_width) * u64::from(decoded.pixel_height);
assert!(
budget >= retained,
"{width}x{height} {format:?}: {budget} < {retained}"
);
assert!(budget <= u64::from(width) * u64::from(height));
}
}
}
#[test]
fn full_resolution_phone_jpeg_is_admitted_and_downsampled() {
let mut bytes = Cursor::new(Vec::new());
image::RgbImage::new(4032, 3024)
.write_to(&mut bytes, image::ImageFormat::Jpeg)
.unwrap();
let budget = validated_pixel_count(bytes.get_ref())
.expect("a common 12 MP phone JPEG should fit the bounded JPEG path");
let work = validated_image_work(bytes.get_ref()).unwrap();
assert!(work.estimated_bytes <= 128 * 1024 * 1024);
let decoded = image_decode_limits().decode(bytes.get_ref()).unwrap();
assert!(decoded.pixel_width <= MAX_DECODED_DIMENSION);
assert!(decoded.pixel_height <= MAX_DECODED_DIMENSION);
let retained = u64::from(decoded.pixel_width) * u64::from(decoded.pixel_height);
assert!(retained <= MAX_DECODED_PIXELS);
assert!(budget >= retained);
}
#[test]
fn raster_limits_reject_oversized_dimensions_and_non_images() {
let mut bytes = Cursor::new(Vec::new());
image::RgbaImage::new(1, MAX_IMAGE_DIMENSION + 1)
.write_to(&mut bytes, image::ImageFormat::Png)
.unwrap();
assert!(validated_pixel_count(bytes.get_ref()).is_none());
assert!(validated_pixel_count(b"<html>error</html>").is_none());
assert_eq!(
validated_pixel_count(&load_data_image(PIXEL).unwrap()),
Some(1)
);
}
#[test]
fn blocked_trackers_leave_embedded_budget_available_and_cancelled_requests_do_not_decode() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let ledger = ResourceLedger::default();
let provider =
email_image_provider_tracked(Arc::new(|_| {}), false, ledger.clone()).unwrap();
for i in 0..64 {
provider.fetch(
1,
Request::get(url::Url::parse(&format!("https://example.com/{i}.png")).unwrap()),
Box::new(Counter(count.clone())),
);
}
provider.fetch(
1,
Request::get(url::Url::parse(PIXEL).unwrap()),
Box::new(Counter(count.clone())),
);
let abort = blitz_traits::net::AbortController::default();
let signal = abort.signal.clone();
abort.abort();
provider.fetch(
2,
Request::get(url::Url::parse(PIXEL).unwrap()).signal(signal),
Box::new(Counter(count.clone())),
);
for _ in 0..100 {
if count.load(std::sync::atomic::Ordering::SeqCst) > 0 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(
ledger.lock().unwrap().get(&resource_key(1, PIXEL)),
Some(&ResourceState::Ready)
);
});
}
#[test]
#[cfg(feature = "remote-content")]
fn production_url_and_ip_policy_cannot_be_disabled_in_tests() {
for value in [
"http://127.0.0.1/x",
"http://10.0.0.1/x",
"http://169.254.169.254/",
"http://[::1]/",
"http://[::ffff:127.0.0.1]/",
"https://example.com:8443/a",
"file:///tmp/pixel.png",
] {
assert!(
!safe_remote_url_shape(&url::Url::parse(value).unwrap()),
"{value}"
);
}
for value in [
"127.0.0.1",
"10.0.0.1",
"169.254.169.254",
"100.64.0.1",
"192.0.2.1",
"198.18.0.1",
"::1",
"fc00::1",
"fe80::1",
"::ffff:127.0.0.1",
] {
assert!(!safe_remote_ip(value.parse().unwrap()), "{value}");
}
assert!(safe_remote_url_shape(
&url::Url::parse("https://example.com/pixel.png").unwrap()
));
}
#[test]
#[cfg(feature = "remote-content")]
fn http_body_checks_content_type_status_bytes_and_abort() {
use std::io::{Read, Write};
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
// Only this transport fixture uses localhost; the provider and its
// production resolver are tested separately with their policy intact.
for (header, body, accepted) in [
(
"200 OK\r\nContent-Type: image/png",
load_data_image(PIXEL).unwrap(),
true,
),
(
"200 OK\r\nContent-Type: application/octet-stream",
load_data_image(PIXEL).unwrap(),
true,
),
("200 OK", load_data_image(PIXEL).unwrap(), true),
("200 OK\r\nContent-Type: image/png", vec![1, 2, 3], false),
(
"200 OK\r\nContent-Type: application/octet-stream",
b"<svg/>".to_vec(),
false,
),
("404 Not Found\r\nContent-Type: image/png", vec![1], false),
("200 OK\r\nContent-Type: text/html", vec![1], false),
("200 OK\r\nContent-Type: image/svg+xml", vec![1], false),
] {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
let mut request = [0; 1024];
let _ = stream.read(&mut request);
write!(
stream,
"HTTP/1.1 {header}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.unwrap();
stream.write_all(&body).unwrap();
});
let actual = rt.block_on(async {
let response = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(2))
.build()
.unwrap()
.get(format!("http://{addr}/pixel"))
.send()
.await
.unwrap();
consume_http_image(response, None).await.is_some()
});
server.join().unwrap();
assert_eq!(actual, accepted);
}
}
}