mod common;
use std::io::Read;
use std::process::{Child, Command};
struct Server {
child: Child,
port: u16,
}
impl Server {
fn start(port: u16, envs: &[(&str, String)]) -> Server {
let fixtures = format!("{}/tests/fixtures", env!("CARGO_MANIFEST_DIR"));
let mut cmd = Command::new(env!("CARGO_BIN_EXE_oximg"));
cmd.env("PORT", port.to_string())
.env("IMAGES_DIR", fixtures)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
for (k, v) in envs {
cmd.env(k, v);
}
let child = cmd.spawn().expect("spawn oximg");
let mut server = Server { child, port };
for _ in 0..400 {
if server.get("/health").is_ok() {
return server;
}
if let Ok(Some(status)) = server.child.try_wait() {
panic!("server exited before becoming healthy: {status}");
}
std::thread::sleep(std::time::Duration::from_millis(30));
}
panic!("server did not become healthy");
}
fn get(&self, path: &str) -> Result<(u16, String, Vec<u8>), ureq::Error> {
let mut resp = ureq::get(format!("http://127.0.0.1:{}{}", self.port, path)).call()?;
let status = resp.status().as_u16();
let ct = resp
.headers()
.get("content-type")
.map(|v| v.to_str().unwrap_or("").to_string())
.unwrap_or_default();
let mut body = Vec::new();
resp.body_mut()
.as_reader()
.read_to_end(&mut body)
.unwrap_or(0);
Ok((status, ct, body))
}
fn status_of(&self, path: &str) -> u16 {
match self.get(path) {
Ok((s, _, _)) => s,
Err(ureq::Error::StatusCode(s)) => s,
Err(e) => panic!("transport error: {e}"),
}
}
fn get_accept(
&self,
path: &str,
accept: Option<&str>,
) -> Result<(u16, String, Option<String>, Vec<u8>), ureq::Error> {
let mut req = ureq::get(format!("http://127.0.0.1:{}{}", self.port, path));
if let Some(a) = accept {
req = req.header("Accept", a);
}
let mut resp = req.call()?;
let status = resp.status().as_u16();
let hdr = |name: &str| {
resp.headers()
.get(name)
.map(|v| v.to_str().unwrap_or("").to_string())
};
let ct = hdr("content-type").unwrap_or_default();
let vary = hdr("vary");
let mut body = Vec::new();
resp.body_mut()
.as_reader()
.read_to_end(&mut body)
.unwrap_or(0);
Ok((status, ct, vary, body))
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn serves_each_format_with_matching_content_type() {
let s = Server::start(47101, &[]);
for (file, ct) in [
("photo.jpg", "image/jpeg"),
("rgb.png", "image/png"),
("photo.webp", "image/webp"),
] {
let (status, got_ct, body) = s.get(&format!("/resize/100/100/{file}")).unwrap();
assert_eq!(status, 200, "{file}");
assert_eq!(got_ct, ct, "{file}");
assert!(!body.is_empty());
let (_, w, h) = oximg::pipeline::probe(&body).unwrap();
assert_eq!((w, h), (100, 75), "{file}");
}
}
#[cfg(feature = "avif")]
#[test]
fn serves_avif_with_matching_content_type() {
let s = Server::start(47106, &[]);
let (status, ct, body) = s.get("/resize/100/100/photo.avif").unwrap();
assert_eq!(status, 200);
assert_eq!(ct, "image/avif");
let (_, w, h) = oximg::pipeline::probe(&body).unwrap();
assert_eq!((w, h), (100, 75));
}
#[test]
fn error_mapping() {
let s = Server::start(47102, &[]);
assert_eq!(s.status_of("/resize/0/100/photo.jpg"), 400);
assert_eq!(s.status_of("/resize/9000/9000/photo.jpg"), 400);
assert_eq!(s.status_of("/resize/100/100/missing.jpg"), 404);
assert_eq!(s.status_of("/resize/100/100/..%2Fsecret"), 400);
}
#[test]
fn concurrent_identical_requests_coalesce_to_identical_bytes() {
let s = Server::start(47103, &[]);
let results: Vec<Vec<u8>> = std::thread::scope(|sc| {
(0..12)
.map(|_| sc.spawn(|| s.get("/resize/120/120/photo.jpg").unwrap().2))
.collect::<Vec<_>>()
.into_iter()
.map(|h| h.join().unwrap())
.collect()
});
for r in &results[1..] {
assert_eq!(r, &results[0], "coalesced responses must be identical");
}
}
#[test]
fn signing_gate() {
let key = "deadbeef".repeat(8);
let salt = "cafebabe".repeat(8);
let s = Server::start(47104, &[("OXIMG_KEY", key), ("OXIMG_SALT", salt)]);
assert_eq!(s.status_of("/resize/100/100/photo.jpg"), 403);
assert_eq!(s.status_of("/AAAA/resize/100/100/photo.jpg"), 403);
let sig = "t-jKRoyvzhs4dEBnGGBUS_t6Uh_HE6WysfGYvs8UaTo";
let (status, ct, _) = s.get(&format!("/{sig}/resize/100/100/photo.jpg")).unwrap();
assert_eq!(status, 200);
assert_eq!(ct, "image/jpeg");
assert_eq!(
s.status_of(&format!("/{sig}/resize/101/100/photo.jpg")),
403
);
}
#[test]
fn explicit_format_token_transcodes() {
let s = Server::start(47107, &[]);
let (status, ct, body) = s.get("/resize/100/100/photo.jpg@webp").unwrap();
assert_eq!(status, 200);
assert_eq!(ct, "image/webp");
let (fmt, w, h) = oximg::pipeline::probe(&body).unwrap();
assert_eq!(fmt, oximg::pipeline::ImageFormat::Webp);
assert_eq!((w, h), (100, 75));
let plain = s.get("/resize/100/100/photo.jpg").unwrap().2;
let explicit = s.get("/resize/100/100/photo.jpg@jpeg").unwrap().2;
assert_eq!(plain, explicit, "@jpeg must match the bare URL's bytes");
}
#[test]
fn format_token_error_mapping() {
let s = Server::start(47108, &[]);
assert_eq!(s.status_of("/resize/100/100/photo.jpg@bogus"), 404);
assert_eq!(s.status_of("/resize/100/100/photo.jpg@jxl"), 400);
#[cfg(not(feature = "avif"))]
assert_eq!(s.status_of("/resize/100/100/photo.jpg@avif"), 400);
}
#[test]
fn signed_urls_cover_the_format_token() {
let key = "deadbeef".repeat(8);
let salt = "cafebabe".repeat(8);
let s = Server::start(47109, &[("OXIMG_KEY", key), ("OXIMG_SALT", salt)]);
let sig = "XQ8C3eYRVAkFAnUczGBsuXMOu-J6vMoYi3W8_4-sT6Q";
let (status, ct, _) = s
.get(&format!("/{sig}/resize/100/100/photo.jpg@webp"))
.unwrap();
assert_eq!(status, 200);
assert_eq!(ct, "image/webp");
let plain_sig = "t-jKRoyvzhs4dEBnGGBUS_t6Uh_HE6WysfGYvs8UaTo";
assert_eq!(
s.status_of(&format!("/{plain_sig}/resize/100/100/photo.jpg@webp")),
403
);
}
#[test]
fn accept_negotiation_and_vary() {
let s = Server::start(47110, &[]);
let (_, ct, vary, _) = s
.get_accept("/resize/100/100/photo.jpg", Some("image/webp,*/*"))
.unwrap();
assert_eq!(ct, "image/jpeg", "negotiation must be opt-in");
assert_eq!(vary, None, "no Vary when negotiation is off");
let s = Server::start(47111, &[("OXIMG_AUTO_FORMAT", "webp".into())]);
let (_, ct, vary, body) = s
.get_accept("/resize/100/100/photo.jpg", Some("image/webp,*/*"))
.unwrap();
assert_eq!(ct, "image/webp");
assert_eq!(vary.as_deref(), Some("Accept"));
let (fmt, _, _) = oximg::pipeline::probe(&body).unwrap();
assert_eq!(fmt, oximg::pipeline::ImageFormat::Webp);
let (_, ct, vary, _) = s.get_accept("/resize/100/100/photo.jpg", None).unwrap();
assert_eq!(ct, "image/jpeg", "no Accept -> source format");
assert_eq!(
vary.as_deref(),
Some("Accept"),
"Vary must be config-static"
);
let (_, ct, _, _) = s
.get_accept("/resize/100/100/photo.jpg@png", Some("image/webp,*/*"))
.unwrap();
assert_eq!(ct, "image/png");
}
#[test]
fn mixed_format_requests_do_not_cross_coalesce() {
let s = Server::start(47112, &[]);
let (jpegs, webps): (Vec<_>, Vec<_>) = std::thread::scope(|sc| {
let j: Vec<_> = (0..6)
.map(|_| sc.spawn(|| s.get("/resize/120/120/photo.jpg").unwrap()))
.collect();
let w: Vec<_> = (0..6)
.map(|_| sc.spawn(|| s.get("/resize/120/120/photo.jpg@webp").unwrap()))
.collect();
(
j.into_iter().map(|h| h.join().unwrap()).collect(),
w.into_iter().map(|h| h.join().unwrap()).collect(),
)
});
for (_, ct, body) in &jpegs {
assert_eq!(ct, "image/jpeg");
assert_eq!(body, &jpegs[0].2);
}
for (_, ct, body) in &webps {
assert_eq!(ct, "image/webp");
assert_eq!(body, &webps[0].2);
assert!(body.starts_with(b"RIFF"), "must be WebP bytes");
}
}
#[test]
fn forced_overlap_cross_format_matches_serial() {
let fused = Server::start(47113, &[("OXIMG_OVERLAP", "1".into())]);
let serial = Server::start(47114, &[("OXIMG_OVERLAP", "0".into())]);
let mut urls = vec![
"/resize/100/100/photo.jpg@webp",
"/resize/100/100/photo.jpg@png",
];
if cfg!(feature = "avif") {
urls.push("/resize/100/100/photo.jpg@avif");
}
for url in urls {
let (status, ct, body) = fused.get(url).unwrap();
assert_eq!(status, 200, "{url}");
let (s2, ct2, body2) = serial.get(url).unwrap();
assert_eq!(s2, 200, "{url}");
assert_eq!(ct, ct2, "{url}");
assert_eq!(body, body2, "{url}: fused and serial bytes must match");
}
let (_, ct, body) = fused.get("/resize/100/100/photo.jpg@webp").unwrap();
assert_eq!(ct, "image/webp");
assert!(body.starts_with(b"RIFF"), "fused gate leaked jpegli bytes");
assert_eq!(&body[8..12], b"WEBP");
}
fn oriented_images_dir(tag: &str) -> String {
let dir = std::env::temp_dir().join(format!("oximg-orient-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let display = common::corner_base(240, 180, 60);
let (stored, sw, sh) = common::store_for_orientation(&display, 240, 180, 6);
let jpeg = common::jpeg_with_orientation(&stored, sw, sh, Some(6));
std::fs::write(dir.join("rotated.jpg"), jpeg).unwrap();
std::fs::write(
dir.join("rotated.png"),
common::png_with_orientation(&stored, sw, sh, 6),
)
.unwrap();
dir.to_str().unwrap().to_string()
}
#[test]
fn auto_rotate_default_and_kill_switch() {
let dir = oriented_images_dir("kill");
let on = Server::start(47121, &[("IMAGES_DIR", dir.clone())]);
let (status, _, body) = on.get("/resize/120/120/rotated.jpg").unwrap();
assert_eq!(status, 200);
let (_, w, h) = oximg::pipeline::probe(&body).unwrap();
assert_eq!((w, h), (120, 90), "default: display-oriented fit");
let (_, _, body) = on.get("/resize/120/120/rotated.png").unwrap();
let (_, w, h) = oximg::pipeline::probe(&body).unwrap();
assert_eq!((w, h), (120, 90), "png default: display-oriented fit");
#[cfg(feature = "avif")]
{
let fx = Server::start(47131, &[]);
let (_, _, body) = fx.get("/resize/120/120/orient_irot1.avif@jpg").unwrap();
let (_, w, h) = oximg::pipeline::probe(&body).unwrap();
assert_eq!((w, h), (90, 120), "avif default: irot applied");
}
drop(on);
let off = Server::start(
47122,
&[
("IMAGES_DIR", dir),
("OXIMG_AUTO_ROTATE", "0".into()),
("OXIMG_ICC", "0".into()),
],
);
for name in ["rotated.jpg", "rotated.png"] {
let (_, _, body) = off.get(&format!("/resize/120/120/{name}")).unwrap();
let (_, w, h) = oximg::pipeline::probe(&body).unwrap();
assert_eq!((w, h), (90, 120), "{name} kill switch: stored orientation");
}
drop(off);
#[cfg(feature = "avif")]
{
let off = Server::start(47132, &[("OXIMG_AUTO_ROTATE", "0".into())]);
let (_, _, body) = off.get("/resize/120/120/orient_irot1.avif@jpg").unwrap();
let (_, w, h) = oximg::pipeline::probe(&body).unwrap();
assert_eq!((w, h), (120, 90), "avif kill switch: stored orientation");
}
}
#[test]
fn oriented_bytes_do_not_depend_on_overlap_gate() {
let dir = oriented_images_dir("gate");
let fused = Server::start(
47123,
&[("IMAGES_DIR", dir.clone()), ("OXIMG_OVERLAP", "1".into())],
);
let serial = Server::start(47124, &[("IMAGES_DIR", dir), ("OXIMG_OVERLAP", "0".into())]);
let a = fused.get("/resize/120/120/rotated.jpg").unwrap().2;
let b = serial.get("/resize/120/120/rotated.jpg").unwrap().2;
assert_eq!(a, b, "oriented fused and serial bytes must match");
#[cfg(feature = "avif")]
{
let a = fused.get("/resize/120/120/rotated.jpg@avif").unwrap().2;
let b = serial.get("/resize/120/120/rotated.jpg@avif").unwrap().2;
assert_eq!(a, b, "preheated-session and serial AVIF bytes must match");
}
}
#[test]
fn icc_default_kill_switch_and_gate_independence() {
let dir = std::env::temp_dir().join(format!("oximg-icc-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let icc = common::fake_icc(700);
let px = common::corner_base(240, 180, 60);
let app2 = common::app2_icc_payloads(&icc, 60_000).remove(0);
let jpeg = common::jpeg_with_markers(&px, 240, 180, &[(2, &app2)]);
std::fs::write(dir.join("profiled.jpg"), jpeg).unwrap();
let dir = dir.to_str().unwrap().to_string();
let on = Server::start(
47125,
&[("IMAGES_DIR", dir.clone()), ("OXIMG_OVERLAP", "1".into())],
);
let (status, _, body) = on.get("/resize/120/120/profiled.jpg").unwrap();
assert_eq!(status, 200);
assert_eq!(
common::jpeg_icc(&body).as_deref(),
Some(&icc[..]),
"default: profile passes through"
);
let fused_bytes = body;
drop(on);
let serial = Server::start(
47127,
&[("IMAGES_DIR", dir.clone()), ("OXIMG_OVERLAP", "0".into())],
);
let (_, _, body) = serial.get("/resize/120/120/profiled.jpg").unwrap();
assert_eq!(body, fused_bytes, "profiled bytes are gate-independent");
drop(serial);
let off = Server::start(
47126,
&[("IMAGES_DIR", dir.clone()), ("OXIMG_ICC", "0".into())],
);
let (status, _, body) = off.get("/resize/120/120/profiled.jpg").unwrap();
assert_eq!(status, 200);
assert_eq!(common::jpeg_icc(&body), None, "kill switch: no profile");
drop(off);
#[cfg(feature = "avif")]
{
let fx = common::fake_icc(900); let on = Server::start(47129, &[]);
let (_, _, body) = on.get("/resize/100/100/icc.avif@jpg").unwrap();
assert_eq!(
common::jpeg_icc(&body).as_deref(),
Some(&fx[..]),
"avif source: profile passes through by default"
);
drop(on);
let off = Server::start(47130, &[("OXIMG_ICC", "0".into())]);
let (_, _, body) = off.get("/resize/100/100/icc.avif@jpg").unwrap();
assert_eq!(
common::jpeg_icc(&body),
None,
"avif source: kill switch strips it"
);
drop(off);
}
let display = common::corner_base(240, 180, 60);
let (stored, sw, sh) = common::store_for_orientation(&display, 240, 180, 6);
let app1 = common::app1_orientation(6);
let app2 = common::app2_icc_payloads(&icc, 60_000).remove(0);
let both = common::jpeg_with_markers(&stored, sw, sh, &[(1, &app1), (2, &app2)]);
std::fs::write(std::path::Path::new(&dir).join("both.jpg"), both).unwrap();
let no_rot = Server::start(
47128,
&[("IMAGES_DIR", dir), ("OXIMG_AUTO_ROTATE", "0".into())],
);
let (_, _, body) = no_rot.get("/resize/120/120/both.jpg").unwrap();
let (_, w, h) = oximg::pipeline::probe(&body).unwrap();
assert_eq!((w, h), (90, 120), "rotation off: stored orientation");
assert_eq!(
common::jpeg_icc(&body).as_deref(),
Some(&icc[..]),
"rotation off: profile still passes through"
);
}
#[test]
fn preset_bytes_do_not_depend_on_overlap_gate() {
for (port_a, port_b, preset) in [(47117, 47118, "fast"), (47119, 47120, "small")] {
let fused = Server::start(
port_a,
&[("OXIMG_OVERLAP", "1".into()), ("PRESET", preset.into())],
);
let serial = Server::start(
port_b,
&[("OXIMG_OVERLAP", "0".into()), ("PRESET", preset.into())],
);
let a = fused.get("/resize/100/100/photo.jpg").unwrap().2;
let b = serial.get("/resize/100/100/photo.jpg").unwrap().2;
assert_eq!(a, b, "PRESET={preset}: fused and serial bytes must match");
assert!(a.starts_with(&[0xFF, 0xD8]), "PRESET={preset}: not a JPEG");
}
}
#[test]
fn fir_backend_disables_fusing_for_stable_bytes() {
let fir = ("OXIMG_RESIZE_BACKEND", "fir".to_string());
let fused = Server::start(47115, &[("OXIMG_OVERLAP", "1".into()), fir.clone()]);
let serial = Server::start(47116, &[("OXIMG_OVERLAP", "0".into()), fir]);
for url in ["/resize/100/100/photo.jpg@png", "/resize/100/100/photo.jpg"] {
let a = fused.get(url).unwrap().2;
let b = serial.get(url).unwrap().2;
assert_eq!(a, b, "{url}: bytes must not depend on the overlap gate");
}
}
#[test]
fn remote_source_mode_streams_from_http_origin() {
let fixtures = format!("{}/tests/fixtures", env!("CARGO_MANIFEST_DIR"));
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let origin_port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let fixtures = fixtures.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 2048];
let n = std::io::Read::read(&mut stream, &mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
let path = req
.split_whitespace()
.nth(1)
.unwrap_or("/")
.trim_start_matches('/');
use std::io::Write;
match std::fs::read(format!("{fixtures}/{path}")) {
Ok(data) => {
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
data.len()
);
let _ = stream.write_all(&data);
}
Err(_) => {
let _ = write!(
stream,
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
}
}
});
}
});
let s = Server::start(
47105,
&[(
"OXIMG_SOURCE_BASE_URL",
format!("http://127.0.0.1:{origin_port}"),
)],
);
let (status, ct, body) = s.get("/resize/100/100/photo.webp").unwrap();
assert_eq!(status, 200);
assert_eq!(ct, "image/webp");
let (_, w, h) = oximg::pipeline::probe(&body).unwrap();
assert_eq!((w, h), (100, 75));
let (status, ct, body) = s.get("/resize/100/100/photo.webp@jpeg").unwrap();
assert_eq!(status, 200);
assert_eq!(ct, "image/jpeg");
assert!(body.starts_with(&[0xFF, 0xD8]));
assert_eq!(s.status_of("/resize/100/100/nope.jpg"), 404);
}