Render: bounded PNG/JPEG texture input admission (#909)

Bounded in-memory PNG/JPEG texture decoding with explicit base-colour/data-map roles; RGBA8 pixels are published only after a complete decode. Input only: no material assignment, GPU shading or persistence. Refs #900.

Contributed by @0xlillith.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
This commit is contained in:
selune authored and GitHub committed 2026-10-14 16:35:52 -04:00
1 parent cccbe1255d
commit 241dac1542
6 files changed
+298 -1

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ mod curve_display;
pub mod lighting;
mod raster;
pub mod surface;
pub mod texture_input;
pub use backend::{
Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits,
+152
View File
@@ -0,0 +1,152 @@
//! Bounded texture admission, independent of documents, files, schedulers and GPU handles.
//! This utility does not advertise texture/PBR support or define a persistent pack schema.
use image::{ImageDecoder, ImageFormat, ImageReader, Limits};
use serde::{Deserialize, Serialize};
use std::{io::Cursor, sync::Arc};
pub const MAX_TEXTURE_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_TEXTURE_EDGE: u32 = 2048;
const MAX_CODEC_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MapRole {
BaseColor,
NormalOpenGl,
Roughness,
Metallic,
AmbientOcclusion,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Interpretation {
Srgb,
Data,
}
impl MapRole {
pub fn interpretation(self) -> Interpretation {
if self == Self::BaseColor { Interpretation::Srgb } else { Interpretation::Data }
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TextureError(pub String);
impl std::fmt::Display for TextureError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for TextureError {}
type Result<T> = std::result::Result<T, TextureError>;
fn invalid(message: impl Into<String>) -> TextureError {
TextureError(message.into())
}
#[derive(Clone, Debug, Serialize)]
pub struct TextureInfo {
pub width: u32,
pub height: u32,
pub encoded_bytes: usize,
pub rgba_bytes: u64,
pub role: MapRole,
pub interpretation: Interpretation,
}
/// Header-admitted source. Full decoding must succeed before publishing pixels.
#[derive(Clone)]
pub struct TextureSource {
bytes: Arc<Vec<u8>>,
format: ImageFormat,
info: TextureInfo,
}
fn reader(bytes: &[u8], format: ImageFormat) -> ImageReader<Cursor<&[u8]>> {
let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
let mut limits = Limits::default();
limits.max_image_width = Some(MAX_TEXTURE_EDGE);
limits.max_image_height = Some(MAX_TEXTURE_EDGE);
limits.max_alloc = Some(MAX_CODEC_BYTES);
reader.limits(limits);
reader
}
fn guarded<T>(work: impl FnOnce() -> Result<T>) -> Result<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).map_err(|_| invalid("texture decoder failed"))?
}
impl TextureSource {
pub fn new(bytes: Vec<u8>, role: MapRole) -> Result<Self> {
if bytes.is_empty() || bytes.len() > MAX_TEXTURE_BYTES || bytes.capacity() > MAX_TEXTURE_BYTES {
return Err(invalid("texture input must contain at most 16 MiB"));
}
let format = image::guess_format(&bytes).map_err(|_| invalid("texture must be PNG or JPEG"))?;
if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) {
return Err(invalid("texture must be PNG or JPEG; HDR uses the Radiance input utility"));
}
// Whole-image codecs may return pixels before noticing a missing trailer. This
// marker check rejects that truncation; it is not a general container validator.
let complete = match format {
ImageFormat::Png => bytes.ends_with(b"\x00\x00\x00\x00IEND\xae\x42\x60\x82"),
ImageFormat::Jpeg => bytes.ends_with(b"\xff\xd9"),
_ => false,
};
if !complete {
return Err(invalid("texture is missing its final terminator"));
}
let (width, height) = guarded(|| {
let mut decoder = reader(&bytes, format).into_decoder().map_err(|e| invalid(e.to_string()))?;
if !matches!(decoder.color_type(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::Rgb8 | image::ColorType::Rgba8) {
return Err(invalid("only 8-bit texture channels are supported"));
}
if decoder.icc_profile().map_err(|e| invalid(e.to_string()))?.is_some() {
return Err(invalid("embedded ICC profiles need explicit color conversion before admission"));
}
if decoder.orientation().map_err(|e| invalid(e.to_string()))? != image::metadata::Orientation::NoTransforms {
return Err(invalid("texture orientation must be normalized before admission"));
}
Ok(decoder.dimensions())
})?;
if width == 0 || height == 0 || width > MAX_TEXTURE_EDGE || height > MAX_TEXTURE_EDGE {
return Err(invalid("texture dimensions exceed 2048x2048"));
}
let rgba_bytes = u64::from(width) * u64::from(height) * 4;
let info = TextureInfo { width, height, encoded_bytes: bytes.len(), rgba_bytes, role, interpretation: role.interpretation() };
Ok(Self { bytes: Arc::new(bytes), format, info })
}
pub fn info(&self) -> &TextureInfo {
&self.info
}
/// Bounded whole-image codec work, intended for a caller-owned worker. Cancellation is
/// checked at the codec boundary, not during PNG/JPEG decoding. No partial image escapes.
pub fn decode(&self, cancelled: impl Fn() -> bool) -> Result<DecodedTexture> {
if cancelled() {
return Err(invalid("texture decoding cancelled"));
}
let image = guarded(|| reader(&self.bytes, self.format).decode().map_err(|e| invalid(e.to_string())))?;
if cancelled() {
return Err(invalid("texture decoding cancelled"));
}
let pixels = guarded(|| Ok(image.into_rgba8().into_raw()))?;
if cancelled() {
return Err(invalid("texture decoding cancelled"));
}
if pixels.len() as u64 != self.info.rgba_bytes {
return Err(invalid("decoded texture dimensions changed"));
}
Ok(DecodedTexture { info: self.info.clone(), pixels })
}
}
/// Completed pixels retain encoded channel values. sRGB conversion, normal basis handling
/// and sampling belong to the eventual renderer adapter, not this input utility.
pub struct DecodedTexture {
info: TextureInfo,
pixels: Vec<u8>,
}
impl DecodedTexture {
pub fn info(&self) -> &TextureInfo {
&self.info
}
pub fn rgba(&self) -> &[u8] {
&self.pixels
}
}
#[cfg(test)]
mod tests;
+65
View File
@@ -0,0 +1,65 @@
use super::*;
fn png(width: u32, height: u32) -> Vec<u8> {
let image = image::RgbaImage::from_pixel(width, height, image::Rgba([64, 128, 192, 255]));
let mut out = Cursor::new(Vec::new());
image.write_to(&mut out, ImageFormat::Png).unwrap();
out.into_inner()
}
#[test]
fn texture_input_preserves_channels_and_explicit_map_interpretation() {
for role in [MapRole::BaseColor, MapRole::NormalOpenGl, MapRole::Roughness, MapRole::Metallic, MapRole::AmbientOcclusion] {
let source = TextureSource::new(png(3, 2), role).unwrap();
let decoded = source.decode(|| false).unwrap();
assert_eq!((decoded.info().width, decoded.info().height, decoded.info().rgba_bytes), (3, 2, 24));
assert_eq!(decoded.info().interpretation, if role == MapRole::BaseColor { Interpretation::Srgb } else { Interpretation::Data });
assert!(decoded.rgba().as_chunks::<4>().0.iter().all(|p| *p == [64, 128, 192, 255]));
}
}
#[test]
fn texture_input_rejects_dimensions_formats_and_corrupt_pixels() {
assert!(TextureSource::new(png(MAX_TEXTURE_EDGE + 1, 1), MapRole::BaseColor).is_err());
assert!(TextureSource::new(Vec::new(), MapRole::BaseColor).is_err());
assert!(TextureSource::new(b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 2\nabcdefgh".to_vec(), MapRole::BaseColor).is_err());
let bytes = png(3, 2);
for n in 0..bytes.len() {
if let Ok(source) = TextureSource::new(bytes.get(..n).unwrap().to_vec(), MapRole::BaseColor) {
assert!(source.decode(|| false).is_err(), "truncated PNG accepted at {n}");
}
}
}
#[test]
fn texture_input_cancellation_never_publishes_partial_pixels() {
let source = TextureSource::new(png(3, 2), MapRole::NormalOpenGl).unwrap();
assert!(source.decode(|| true).is_err());
let calls = std::cell::Cell::new(0);
assert!(
source
.decode(|| {
calls.set(calls.get() + 1);
calls.get() == 2
})
.is_err()
);
assert!(source.clone().decode(|| false).is_ok());
}
#[test]
fn texture_input_jpeg_is_supported_without_hdr_feature_confusion() {
let image = image::RgbImage::from_pixel(2, 2, image::Rgb([64, 128, 192]));
let mut out = Cursor::new(Vec::new());
image.write_to(&mut out, ImageFormat::Jpeg).unwrap();
let source = TextureSource::new(out.into_inner(), MapRole::BaseColor).unwrap();
assert_eq!(source.decode(|| false).unwrap().rgba().len(), 16);
}
#[test]
fn texture_input_caps_encoded_storage_and_refuses_precision_loss() {
let mut bytes = Vec::with_capacity(MAX_TEXTURE_BYTES + 1);
bytes.extend(png(1, 1));
assert!(TextureSource::new(bytes, MapRole::Roughness).is_err());
assert!(TextureSource::new(vec![0; MAX_TEXTURE_BYTES + 1], MapRole::BaseColor).is_err());
let image = image::DynamicImage::ImageLuma16(image::ImageBuffer::from_pixel(1, 1, image::Luma([32768u16])));
let mut out = Cursor::new(Vec::new());
image.write_to(&mut out, ImageFormat::Png).unwrap();
assert!(TextureSource::new(out.into_inner(), MapRole::NormalOpenGl).is_err());
}