use std::{
fs::{self, File},
io::{self, BufReader, Cursor, Read, Seek, SeekFrom},
os::unix::fs::FileExt,
path::Path,
sync::{Condvar, Mutex},
};
use anyhow::{Result, ensure};
use exif::{In, Tag};
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, metadata::Orientation};
use rustix::fs::{Mode, OFlags};
use crate::guard;
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"];
const UNSUPPORTED_EXTS: &[&str] = &[
"heic", "heif", "avif", "jxl", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2",
];
pub fn is_image(path: &Path) -> bool {
has_ext(path, IMAGE_EXTS)
}
pub fn is_unsupported(path: &Path) -> bool {
has_ext(path, UNSUPPORTED_EXTS)
}
fn has_ext(path: &Path, exts: &[&str]) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
const MAX_DECODED: u64 = 256 << 20;
const MAX_FILE: u64 = MAX_DECODED;
const PREFIX: u64 = 128 << 10;
static BUDGET: Budget = Budget::new(2 << 30);
pub fn check(md: &fs::Metadata) -> Result<u64> {
ensure!(md.is_file(), "not a regular file");
ensure!(md.len() > 0, "empty file");
ensure!(md.len() <= MAX_FILE, "too large: {} MiB", md.len() >> 20);
Ok(md.len())
}
pub struct Header {
width: u32,
height: u32,
format: ImageFormat,
decoded: u64,
pub exif: Option<exif::Exif>,
}
impl Header {
pub fn size(&self) -> (u32, u32) {
match self.orientation() {
Orientation::Rotate90
| Orientation::Rotate270
| Orientation::Rotate90FlipH
| Orientation::Rotate270FlipH => (self.height, self.width),
_ => (self.width, self.height),
}
}
fn orientation(&self) -> Orientation {
self.exif
.as_ref()
.and_then(|e| {
e.get_field(Tag::Orientation, In::PRIMARY)?
.value
.get_uint(0)
})
.and_then(|o| Orientation::from_exif(o.try_into().ok()?))
.unwrap_or(Orientation::NoTransforms)
}
fn peak(&self, file: u64) -> u64 {
match self.format {
ImageFormat::Jpeg => file + 3 * self.decoded,
_ => 2 * self.decoded,
}
}
}
pub fn header(path: &Path) -> Result<Header> {
guard(|| read_header(&mut open(path)?, path))
}
pub fn load(path: &Path, side: u32) -> Result<DynamicImage> {
guard(|| {
let mut file = open(path)?;
let header = read_header(&mut file, path)?;
ensure!(
header.decoded <= MAX_DECODED,
"too large: {} MiB decoded",
header.decoded >> 20
);
let (format, orientation) = (header.format, header.orientation());
let charge = BUDGET.charge(header.peak(file.len));
drop(header);
file.rewind()?;
let decoder = ImageReader::with_format(BufReader::new(file), format).into_decoder()?;
let mut image = shrink(DynamicImage::from_decoder(decoder)?, side);
drop(charge);
image.apply_orientation(orientation);
Ok(image)
})
}
fn shrink(image: DynamicImage, side: u32) -> DynamicImage {
if image.width().max(image.height()) > side {
image.thumbnail(side, side)
} else {
image
}
}
fn read_header(file: &mut Bounded, path: &Path) -> Result<Header> {
let mut bytes = Vec::new();
file.by_ref().take(PREFIX).read_to_end(&mut bytes)?;
if bytes.len() as u64 == file.len {
return parse(&bytes, path, true);
}
parse(&bytes, path, false).or_else(|_| {
let _charge = BUDGET.charge(file.len);
file.read_to_end(&mut bytes)?;
parse(&bytes, path, true)
})
}
fn parse(bytes: &[u8], path: &Path, whole: bool) -> Result<Header> {
let format = image::guess_format(bytes).or_else(|_| ImageFormat::from_path(path))?;
let mut decoder = ImageReader::with_format(Cursor::new(bytes), format).into_decoder()?;
let (width, height) = decoder.dimensions();
let exif = match read_exif(&mut decoder, format, bytes) {
Ok(exif) => exif,
Err(_) if whole => None,
Err(e) => return Err(e),
};
Ok(Header {
width,
height,
format,
decoded: decoder.total_bytes(),
exif,
})
}
fn read_exif(
decoder: &mut impl ImageDecoder,
format: ImageFormat,
bytes: &[u8],
) -> Result<Option<exif::Exif>> {
let raw = match format {
ImageFormat::Tiff => Some(bytes.to_vec()),
_ => decoder.exif_metadata()?,
};
Ok(raw
.map(|raw| exif::Reader::new().read_raw(raw))
.transpose()?)
}
fn open(path: &Path) -> Result<Bounded> {
let flags = OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOCTTY | OFlags::CLOEXEC;
let file = File::from(rustix::fs::open(path, flags, Mode::empty())?);
let len = check(&file.metadata()?)?;
Ok(Bounded { file, pos: 0, len })
}
struct Bounded {
file: File,
pos: u64,
len: u64,
}
impl Read for Bounded {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let left = usize::try_from(self.len.saturating_sub(self.pos)).unwrap_or(usize::MAX);
let n = buf.len().min(left);
let n = self.file.read_at(&mut buf[..n], self.pos)?;
self.pos += n as u64;
Ok(n)
}
}
impl Seek for Bounded {
fn seek(&mut self, to: SeekFrom) -> io::Result<u64> {
let pos = match to {
SeekFrom::Start(pos) => Some(pos),
SeekFrom::End(delta) => self.len.checked_add_signed(delta),
SeekFrom::Current(delta) => self.pos.checked_add_signed(delta),
};
self.pos = pos.ok_or(io::ErrorKind::InvalidInput)?;
Ok(self.pos)
}
}
struct Budget {
used: Mutex<u64>,
freed: Condvar,
limit: u64,
}
impl Budget {
const fn new(limit: u64) -> Self {
Self {
used: Mutex::new(0),
freed: Condvar::new(),
limit,
}
}
fn charge(&self, bytes: u64) -> Charge<'_> {
let mut used = self.used.lock().unwrap();
while *used > 0 && *used + bytes > self.limit {
used = self.freed.wait(used).unwrap();
}
*used += bytes;
Charge {
budget: self,
bytes,
}
}
}
struct Charge<'a> {
budget: &'a Budget,
bytes: u64,
}
impl Drop for Charge<'_> {
fn drop(&mut self) {
*self.budget.used.lock().unwrap() -= self.bytes;
self.budget.freed.notify_all();
}
}
#[cfg(test)]
mod tests {
use std::{os::unix::fs::symlink, sync::mpsc, thread, time::Duration};
use exif::{Field, Value};
use super::*;
use crate::testing::{Temp, encode, jpeg_with_exif};
fn turned_jpeg(width: u32, height: u32) -> Vec<u8> {
let orientation = Field {
tag: Tag::Orientation,
ifd_num: In::PRIMARY,
value: Value::Short(vec![6]),
};
jpeg_with_exif(width, height, &[orientation])
}
#[test]
fn load_turns_the_image() {
let file = Temp::file("turned.jpg", &turned_jpeg(3, 2));
let image = load(&file, 1024).unwrap();
assert_eq!((image.width(), image.height()), (2, 3));
assert_eq!(header(&file).unwrap().size(), (2, 3));
}
#[test]
fn load_shrinks_then_turns() {
let file = Temp::file("shrunk.jpg", &turned_jpeg(300, 200));
let image = load(&file, 30).unwrap();
assert_eq!((image.width(), image.height()), (20, 30));
}
#[test]
fn load_refuses_huge_images() {
let header = [
b"BM".as_slice(),
&54u32.to_le_bytes(),
&[0; 4],
&54u32.to_le_bytes(),
&40u32.to_le_bytes(),
&20_000i32.to_le_bytes(),
&20_000i32.to_le_bytes(),
&1u16.to_le_bytes(),
&24u16.to_le_bytes(),
&[0; 24],
]
.concat();
let file = Temp::file("huge.bmp", &header);
let err = load(&file, 1024).unwrap_err();
assert!(err.to_string().starts_with("too large"), "{err}");
}
#[test]
fn header_at_the_end_is_found() {
let tiff = encode(&DynamicImage::new_rgb8(300, 300), ImageFormat::Tiff);
assert!(tiff.len() as u64 > PREFIX);
let file = Temp::file("late.tif", &tiff);
assert_eq!(header(&file).unwrap().size(), (300, 300));
}
#[test]
fn only_regular_files_open() {
let empty = Temp::file("empty.jpg", b"");
assert_eq!(header(&empty).err().unwrap().to_string(), "empty file");
let zero = Temp::path("zero.jpg");
symlink("/dev/zero", &zero).unwrap();
assert_eq!(
load(&zero, 64).unwrap_err().to_string(),
"not a regular file"
);
let fifo = Temp::path("fifo.png");
rustix::fs::mknodat(
rustix::fs::CWD,
&*fifo,
rustix::fs::FileType::Fifo,
Mode::RUSR | Mode::WUSR,
0,
)
.unwrap();
assert_eq!(
header(&fifo).err().unwrap().to_string(),
"not a regular file"
);
}
#[test]
fn bounded_file_ends_at_its_size() {
let file = Temp::file("bounded.bin", b"0123456789");
let mut bounded = Bounded {
file: File::open(&file).unwrap(),
pos: 0,
len: 4,
};
let mut all = Vec::new();
bounded.read_to_end(&mut all).unwrap();
assert_eq!(all, b"0123");
assert_eq!(bounded.seek(SeekFrom::End(-1)).unwrap(), 3);
let mut last = Vec::new();
bounded.read_to_end(&mut last).unwrap();
assert_eq!(last, b"3");
assert!(bounded.seek(SeekFrom::Current(-9)).is_err());
}
#[test]
fn budget_waits_for_room() {
let budget = Budget::new(10);
let big = budget.charge(25);
let (tx, rx) = mpsc::channel();
thread::scope(|s| {
s.spawn(|| {
let _small = budget.charge(1);
tx.send(()).unwrap();
});
assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
drop(big);
rx.recv_timeout(Duration::from_secs(5)).unwrap();
});
}
}