use std::collections::BTreeMap;
use std::io::Cursor;
use crate::uri::validate_absolute_uri_identity;
use crate::{
PersonalOriginalIdentity, ReadablePersonalOriginalIdentity, Result, SharedOriginalMetadata,
SharedRelativeOriginalUri, SharedRepositoryContext, ThumbnailError, ThumbnailSize,
UnixMtimeSeconds,
};
const MAX_RENDERED_PIXELS: u64 = 16_777_216;
const MAX_RENDERED_DECODE_BYTES: usize = 256 * 1024 * 1024;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum RawThumbnailPixelFormat {
Rgb8,
Rgba8,
}
impl RawThumbnailPixelFormat {
const fn channels(self) -> usize {
match self {
Self::Rgb8 => 3,
Self::Rgba8 => 4,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RawThumbnailImage<'a> {
width: u32,
height: u32,
stride: usize,
format: RawThumbnailPixelFormat,
pixels: &'a [u8],
}
impl<'a> RawThumbnailImage<'a> {
pub fn new(
width: u32,
height: u32,
stride: usize,
format: RawThumbnailPixelFormat,
pixels: &'a [u8],
) -> Result<Self> {
validate_raw_thumbnail_image(width, height, stride, format, pixels)?;
Ok(Self {
width,
height,
stride,
format,
pixels,
})
}
#[must_use]
pub const fn width(&self) -> u32 {
self.width
}
#[must_use]
pub const fn height(&self) -> u32 {
self.height
}
#[must_use]
pub const fn stride(&self) -> usize {
self.stride
}
#[must_use]
pub const fn format(&self) -> RawThumbnailPixelFormat {
self.format
}
#[must_use]
pub const fn pixels(&self) -> &'a [u8] {
self.pixels
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct OwnedRawThumbnailImage {
width: u32,
height: u32,
stride: usize,
format: RawThumbnailPixelFormat,
pixels: Vec<u8>,
}
impl OwnedRawThumbnailImage {
pub fn new(
width: u32,
height: u32,
stride: usize,
format: RawThumbnailPixelFormat,
pixels: Vec<u8>,
) -> Result<Self> {
validate_raw_thumbnail_image(width, height, stride, format, &pixels)?;
Ok(Self {
width,
height,
stride,
format,
pixels,
})
}
#[must_use]
pub fn as_borrowed(&self) -> RawThumbnailImage<'_> {
RawThumbnailImage {
width: self.width,
height: self.height,
stride: self.stride,
format: self.format,
pixels: &self.pixels,
}
}
#[must_use]
pub const fn width(&self) -> u32 {
self.width
}
#[must_use]
pub const fn height(&self) -> u32 {
self.height
}
#[must_use]
pub const fn stride(&self) -> usize {
self.stride
}
#[must_use]
pub const fn format(&self) -> RawThumbnailPixelFormat {
self.format
}
#[must_use]
pub fn pixels(&self) -> &[u8] {
&self.pixels
}
#[must_use]
pub fn into_parts(self) -> OwnedRawThumbnailImageParts {
OwnedRawThumbnailImageParts {
width: self.width,
height: self.height,
stride: self.stride,
format: self.format,
pixels: self.pixels,
}
}
}
#[derive(Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct OwnedRawThumbnailImageParts {
pub width: u32,
pub height: u32,
pub stride: usize,
pub format: RawThumbnailPixelFormat,
pub pixels: Vec<u8>,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum CacheEntryProblem {
Metadata(ThumbnailMetadataProblem),
UnreadableEntry,
UnverifiableOriginal,
InvalidPngStructure,
NonconformingPngFormat,
DimensionsExceedNamespace,
ResourceLimitExceeded,
NonstandardFilename,
UriFilenameMismatch,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum ThumbnailMetadataKey {
Uri,
Mtime,
Size,
MimeType,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum ThumbnailMetadataProblemKind {
MissingRequired,
InvalidSyntax,
ValueMismatch,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ThumbnailMetadataProblem {
key: ThumbnailMetadataKey,
kind: ThumbnailMetadataProblemKind,
}
impl ThumbnailMetadataProblem {
#[must_use]
pub const fn new(key: ThumbnailMetadataKey, kind: ThumbnailMetadataProblemKind) -> Self {
Self { key, kind }
}
#[must_use]
pub const fn key(self) -> ThumbnailMetadataKey {
self.key
}
#[must_use]
pub const fn kind(self) -> ThumbnailMetadataProblemKind {
self.kind
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum PersonalValidationOutcome {
FullyVerified,
Invalid(Vec<CacheEntryProblem>),
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum SharedValidationOutcome {
FullyVerified,
MetadataIncomplete,
Invalid(Vec<CacheEntryProblem>),
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ThumbnailMetadata {
values: BTreeMap<String, String>,
}
impl ThumbnailMetadata {
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.values.get(key).map(String::as_str)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> + '_ {
self.values
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
}
#[must_use]
pub fn thumb_uri(&self) -> Option<&str> {
self.get("Thumb::URI")
}
#[must_use]
pub fn thumb_mtime_lossy(&self) -> Option<UnixMtimeSeconds> {
self.try_thumb_mtime().ok().flatten()
}
pub fn try_thumb_mtime(&self) -> Result<Option<UnixMtimeSeconds>> {
self.get("Thumb::MTime").map(parse_thumb_mtime).transpose()
}
pub(crate) fn thumb_mtime_result(&self) -> Result<Option<UnixMtimeSeconds>> {
self.try_thumb_mtime()
}
#[must_use]
pub fn thumb_size_lossy(&self) -> Option<u64> {
self.try_thumb_size().ok().flatten()
}
pub fn try_thumb_size(&self) -> Result<Option<u64>> {
self.get("Thumb::Size").map(parse_thumb_size).transpose()
}
pub(crate) fn thumb_size_result(&self) -> Result<Option<u64>> {
self.try_thumb_size()
}
#[must_use]
pub fn thumb_mime_type(&self) -> Option<&str> {
self.get("Thumb::Mimetype")
}
}
fn parse_thumb_mtime(value: &str) -> Result<UnixMtimeSeconds> {
value
.parse::<u64>()
.map(UnixMtimeSeconds::new)
.map_err(|_| ThumbnailError::invalid_metadata("invalid Thumb::MTime"))
}
fn parse_thumb_size(value: &str) -> Result<u64> {
value
.parse::<u64>()
.map_err(|_| ThumbnailError::invalid_metadata("invalid Thumb::Size"))
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum ThumbnailPngBitDepth {
One,
Two,
Four,
Eight,
Sixteen,
}
impl ThumbnailPngBitDepth {
fn from_png(value: png::BitDepth) -> Self {
match value {
png::BitDepth::One => Self::One,
png::BitDepth::Two => Self::Two,
png::BitDepth::Four => Self::Four,
png::BitDepth::Eight => Self::Eight,
png::BitDepth::Sixteen => Self::Sixteen,
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum ThumbnailPngColorType {
Grayscale,
Rgb,
Indexed,
GrayscaleAlpha,
Rgba,
}
impl ThumbnailPngColorType {
fn from_png(value: png::ColorType) -> Self {
match value {
png::ColorType::Grayscale => Self::Grayscale,
png::ColorType::Rgb => Self::Rgb,
png::ColorType::Indexed => Self::Indexed,
png::ColorType::GrayscaleAlpha => Self::GrayscaleAlpha,
png::ColorType::Rgba => Self::Rgba,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParsedThumbnailPng {
width: u32,
height: u32,
bit_depth: ThumbnailPngBitDepth,
color_type: ThumbnailPngColorType,
interlaced: bool,
metadata: ThumbnailMetadata,
}
impl ParsedThumbnailPng {
pub fn parse(bytes: &[u8]) -> Result<Self> {
let decoder = png::Decoder::new(Cursor::new(bytes));
let mut reader = decoder
.read_info()
.map_err(|err| ThumbnailError::png(err.to_string()))?;
let Some(output_buffer_size) = reader.output_buffer_size() else {
return Err(ThumbnailError::png(
"png output buffer size is unavailable".to_owned(),
));
};
let info = reader.info();
ensure_parsed_png_resource_limits(info.width, info.height, output_buffer_size)?;
let mut buffer = vec![0; output_buffer_size];
reader
.next_frame(&mut buffer)
.map_err(|err| ThumbnailError::png(err.to_string()))?;
let info = reader.info();
let mut values = BTreeMap::new();
for chunk in &info.uncompressed_latin1_text {
values.insert(chunk.keyword.clone(), chunk.text.clone());
}
for chunk in &info.compressed_latin1_text {
let text = chunk
.get_text()
.map_err(|err| ThumbnailError::png(err.to_string()))?;
values.insert(chunk.keyword.clone(), text);
}
for chunk in &info.utf8_text {
let text = chunk
.get_text()
.map_err(|err| ThumbnailError::png(err.to_string()))?;
values.insert(chunk.keyword.clone(), text);
}
Ok(Self {
width: info.width,
height: info.height,
bit_depth: ThumbnailPngBitDepth::from_png(info.bit_depth),
color_type: ThumbnailPngColorType::from_png(info.color_type),
interlaced: info.interlaced,
metadata: ThumbnailMetadata { values },
})
}
#[must_use]
pub const fn width(&self) -> u32 {
self.width
}
#[must_use]
pub const fn height(&self) -> u32 {
self.height
}
#[must_use]
pub const fn bit_depth(&self) -> ThumbnailPngBitDepth {
self.bit_depth
}
#[must_use]
pub const fn color_type(&self) -> ThumbnailPngColorType {
self.color_type
}
#[must_use]
pub const fn interlaced(&self) -> bool {
self.interlaced
}
#[must_use]
pub const fn metadata(&self) -> &ThumbnailMetadata {
&self.metadata
}
pub(crate) fn into_metadata(self) -> ThumbnailMetadata {
self.metadata
}
#[must_use]
pub fn into_parts(self) -> ParsedThumbnailPngParts {
ParsedThumbnailPngParts {
width: self.width,
height: self.height,
bit_depth: self.bit_depth,
color_type: self.color_type,
interlaced: self.interlaced,
metadata: self.metadata,
}
}
pub(crate) fn conformance_problems(&self, size: ThumbnailSize) -> Vec<CacheEntryProblem> {
let mut problems = Vec::new();
if self.bit_depth != ThumbnailPngBitDepth::Eight
|| self.interlaced
|| !matches!(
self.color_type,
ThumbnailPngColorType::Rgba | ThumbnailPngColorType::GrayscaleAlpha
)
{
push_problem(&mut problems, CacheEntryProblem::NonconformingPngFormat);
}
if self.width > size.max_dimension() || self.height > size.max_dimension() {
push_problem(&mut problems, CacheEntryProblem::DimensionsExceedNamespace);
}
problems
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct ParsedThumbnailPngParts {
pub width: u32,
pub height: u32,
pub bit_depth: ThumbnailPngBitDepth,
pub color_type: ThumbnailPngColorType,
pub interlaced: bool,
pub metadata: ThumbnailMetadata,
}
fn ensure_parsed_png_resource_limits(
width: u32,
height: u32,
output_buffer_size: usize,
) -> Result<()> {
let pixels = u64::from(width) * u64::from(height);
if pixels > MAX_RENDERED_PIXELS || output_buffer_size > MAX_RENDERED_DECODE_BYTES {
return Err(ThumbnailError::resource_limit_exceeded(
"PNG decode resource limit exceeded",
));
}
Ok(())
}
fn parse_thumbnail_for_validation(
bytes: &[u8],
) -> std::result::Result<ParsedThumbnailPng, CacheEntryProblem> {
match ParsedThumbnailPng::parse(bytes) {
Ok(parsed) => Ok(parsed),
Err(ThumbnailError::ResourceLimitExceeded { .. }) => {
Err(CacheEntryProblem::ResourceLimitExceeded)
}
Err(_) => Err(CacheEntryProblem::InvalidPngStructure),
}
}
#[must_use]
pub fn validate_personal_thumbnail(
bytes: &[u8],
original: &ReadablePersonalOriginalIdentity,
size: ThumbnailSize,
) -> PersonalValidationOutcome {
validate_personal_thumbnail_identity(bytes, original.identity(), size)
}
pub(crate) fn validate_personal_thumbnail_identity(
bytes: &[u8],
original: &PersonalOriginalIdentity,
size: ThumbnailSize,
) -> PersonalValidationOutcome {
let parsed = match parse_thumbnail_for_validation(bytes) {
Ok(parsed) => parsed,
Err(problem) => {
return PersonalValidationOutcome::Invalid(vec![problem]);
}
};
let mut problems = parsed.conformance_problems(size);
compare_personal_metadata(&mut problems, parsed.metadata(), original);
if problems.is_empty() {
PersonalValidationOutcome::FullyVerified
} else {
PersonalValidationOutcome::Invalid(problems)
}
}
#[must_use]
pub fn validate_personal_failure_entry(
bytes: &[u8],
original: &ReadablePersonalOriginalIdentity,
) -> PersonalValidationOutcome {
validate_personal_failure_entry_identity(bytes, original.identity())
}
pub(crate) fn validate_personal_failure_entry_identity(
bytes: &[u8],
original: &PersonalOriginalIdentity,
) -> PersonalValidationOutcome {
let parsed = match parse_thumbnail_for_validation(bytes) {
Ok(parsed) => parsed,
Err(problem) => {
return PersonalValidationOutcome::Invalid(vec![problem]);
}
};
let mut problems = Vec::new();
compare_personal_metadata(&mut problems, parsed.metadata(), original);
if problems.is_empty() {
PersonalValidationOutcome::FullyVerified
} else {
PersonalValidationOutcome::Invalid(problems)
}
}
#[must_use]
pub fn validate_shared_thumbnail(
bytes: &[u8],
context: &SharedRepositoryContext,
original: SharedOriginalMetadata,
size: ThumbnailSize,
) -> SharedValidationOutcome {
let parsed = match parse_thumbnail_for_validation(bytes) {
Ok(parsed) => parsed,
Err(problem) => {
return SharedValidationOutcome::Invalid(vec![problem]);
}
};
let mut problems = parsed.conformance_problems(size);
let mut incomplete = false;
let metadata = parsed.metadata();
match metadata.thumb_uri() {
Some(uri) if uri == context.shared_uri().as_str() => {}
Some(uri) if SharedRelativeOriginalUri::parse(uri).is_err() => {
push_problem(
&mut problems,
metadata_problem(
ThumbnailMetadataKey::Uri,
ThumbnailMetadataProblemKind::InvalidSyntax,
),
);
}
Some(_) => push_problem(
&mut problems,
metadata_problem(
ThumbnailMetadataKey::Uri,
ThumbnailMetadataProblemKind::ValueMismatch,
),
),
None => incomplete = true,
}
match (metadata.thumb_mtime_result(), original.mtime()) {
(Ok(Some(stored)), Some(expected)) if stored == expected => {}
(Ok(Some(_)), Some(_)) => push_problem(
&mut problems,
metadata_problem(
ThumbnailMetadataKey::Mtime,
ThumbnailMetadataProblemKind::ValueMismatch,
),
),
(Ok(Some(_)), None) => push_problem(&mut problems, CacheEntryProblem::UnverifiableOriginal),
(Ok(None), _) => incomplete = true,
(Err(_), _) => push_problem(
&mut problems,
metadata_problem(
ThumbnailMetadataKey::Mtime,
ThumbnailMetadataProblemKind::InvalidSyntax,
),
),
}
compare_optional_size(
&mut problems,
metadata,
original.original_byte_size(),
false,
);
if !problems.is_empty() {
SharedValidationOutcome::Invalid(problems)
} else if incomplete {
SharedValidationOutcome::MetadataIncomplete
} else {
SharedValidationOutcome::FullyVerified
}
}
fn compare_personal_metadata(
problems: &mut Vec<CacheEntryProblem>,
metadata: &ThumbnailMetadata,
original: &PersonalOriginalIdentity,
) {
match metadata.thumb_uri() {
Some(uri) if uri == original.uri().as_str() => {}
Some(uri) if validate_absolute_uri_identity(uri).is_err() => {
push_problem(
problems,
metadata_problem(
ThumbnailMetadataKey::Uri,
ThumbnailMetadataProblemKind::InvalidSyntax,
),
);
}
Some(_) => push_problem(
problems,
metadata_problem(
ThumbnailMetadataKey::Uri,
ThumbnailMetadataProblemKind::ValueMismatch,
),
),
None => push_problem(
problems,
metadata_problem(
ThumbnailMetadataKey::Uri,
ThumbnailMetadataProblemKind::MissingRequired,
),
),
}
match metadata.thumb_mtime_result() {
Ok(Some(mtime)) if mtime == original.mtime() => {}
Ok(Some(_)) => push_problem(
problems,
metadata_problem(
ThumbnailMetadataKey::Mtime,
ThumbnailMetadataProblemKind::ValueMismatch,
),
),
Ok(None) => push_problem(
problems,
metadata_problem(
ThumbnailMetadataKey::Mtime,
ThumbnailMetadataProblemKind::MissingRequired,
),
),
Err(_) => push_problem(
problems,
metadata_problem(
ThumbnailMetadataKey::Mtime,
ThumbnailMetadataProblemKind::InvalidSyntax,
),
),
}
compare_optional_size(problems, metadata, original.original_byte_size(), false);
}
fn compare_optional_size(
problems: &mut Vec<CacheEntryProblem>,
metadata: &ThumbnailMetadata,
expected: Option<u64>,
missing_is_problem: bool,
) {
match (metadata.thumb_size_result(), expected) {
(Ok(Some(stored)), Some(expected)) if stored == expected => {}
(Ok(Some(_)), Some(_)) => push_problem(
problems,
metadata_problem(
ThumbnailMetadataKey::Size,
ThumbnailMetadataProblemKind::ValueMismatch,
),
),
(Ok(None), Some(_)) if missing_is_problem => push_problem(
problems,
metadata_problem(
ThumbnailMetadataKey::Size,
ThumbnailMetadataProblemKind::MissingRequired,
),
),
(Ok(_), _) => {}
(Err(_), _) => push_problem(
problems,
metadata_problem(
ThumbnailMetadataKey::Size,
ThumbnailMetadataProblemKind::InvalidSyntax,
),
),
}
}
pub(crate) const fn metadata_problem(
key: ThumbnailMetadataKey,
kind: ThumbnailMetadataProblemKind,
) -> CacheEntryProblem {
CacheEntryProblem::Metadata(ThumbnailMetadataProblem::new(key, kind))
}
pub(crate) fn push_problem(problems: &mut Vec<CacheEntryProblem>, problem: CacheEntryProblem) {
if !problems.contains(&problem) {
problems.push(problem);
}
}
struct RgbaImage {
width: u32,
height: u32,
pixels: Vec<u8>,
}
pub(crate) struct DecodedThumbnailRgba8 {
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) stride: usize,
pub(crate) pixels: Vec<u8>,
}
pub(crate) fn normalized_personal_thumbnail_png(
rendered_png: &[u8],
original: &PersonalOriginalIdentity,
size: ThumbnailSize,
) -> Result<Vec<u8>> {
let image = decode_rendered_png_to_rgba8(rendered_png)?;
normalized_personal_thumbnail_rgba_png(image, original, size)
}
pub(crate) fn normalized_personal_thumbnail_raw_png(
image: RawThumbnailImage<'_>,
original: &PersonalOriginalIdentity,
size: ThumbnailSize,
) -> Result<Vec<u8>> {
let image = raw_thumbnail_to_rgba8(image)?;
normalized_personal_thumbnail_rgba_png(image, original, size)
}
pub(crate) fn normalized_personal_thumbnail_from_cache_png(
thumbnail_png: &[u8],
original: &PersonalOriginalIdentity,
size: ThumbnailSize,
) -> Result<Vec<u8>> {
let image = decode_validated_thumbnail_png_to_rgba_image(thumbnail_png)?;
normalized_personal_thumbnail_rgba_png(image, original, size)
}
pub(crate) fn downscaled_validated_thumbnail_png_to_rgba8(
thumbnail_png: &[u8],
size: ThumbnailSize,
) -> Result<DecodedThumbnailRgba8> {
let image = decode_validated_thumbnail_png_to_rgba_image(thumbnail_png)?;
let image = downscale_to_namespace(image, size)?;
Ok(DecodedThumbnailRgba8 {
width: image.width,
height: image.height,
stride: usize::try_from(image.width)
.ok()
.and_then(|width| width.checked_mul(4))
.ok_or_else(|| {
ThumbnailError::resource_limit_exceeded("PNG decode resource limit exceeded")
})?,
pixels: image.pixels,
})
}
fn normalized_personal_thumbnail_rgba_png(
image: RgbaImage,
original: &PersonalOriginalIdentity,
size: ThumbnailSize,
) -> Result<Vec<u8>> {
let image = downscale_to_namespace(image, size)?;
let metadata = thumbnail_metadata_pairs(original);
let png = encode_rgba_png(image.width, image.height, &image.pixels, &metadata)?;
match validate_personal_thumbnail_identity(&png, original, size) {
PersonalValidationOutcome::FullyVerified => Ok(png),
PersonalValidationOutcome::Invalid(problems) => {
Err(ThumbnailError::unsupported_rendered_thumbnail(
rendered_validation_error(problems.as_slice()),
))
}
}
}
fn rendered_validation_error(problems: &[CacheEntryProblem]) -> &'static str {
if problems.contains(&CacheEntryProblem::ResourceLimitExceeded) {
"resource limit exceeded"
} else if problems.contains(&CacheEntryProblem::DimensionsExceedNamespace) {
"dimensions exceed namespace"
} else if problems.contains(&CacheEntryProblem::NonconformingPngFormat) {
"nonconforming final PNG"
} else if problems.contains(&CacheEntryProblem::InvalidPngStructure) {
"invalid final PNG structure"
} else {
"metadata validation failed"
}
}
pub(crate) fn thumbnail_metadata_pairs(
original: &PersonalOriginalIdentity,
) -> Vec<(String, String)> {
let mut metadata = vec![
("Thumb::URI".to_owned(), original.uri().as_str().to_owned()),
("Thumb::MTime".to_owned(), original.mtime().to_string()),
];
if let Some(original_byte_size) = original.original_byte_size() {
metadata.push(("Thumb::Size".to_owned(), original_byte_size.to_string()));
}
if let Some(mime_type) = original.mime_type() {
metadata.push(("Thumb::Mimetype".to_owned(), mime_type.to_owned()));
}
metadata
}
fn ensure_rendered_resource_limits(
width: u32,
height: u32,
output_buffer_size: usize,
) -> Result<()> {
let pixels = u64::from(width) * u64::from(height);
if pixels > MAX_RENDERED_PIXELS || output_buffer_size > MAX_RENDERED_DECODE_BYTES {
return Err(ThumbnailError::unsupported_rendered_thumbnail(
"rendered PNG resource limit exceeded",
));
}
Ok(())
}
fn validate_raw_thumbnail_image(
width: u32,
height: u32,
stride: usize,
format: RawThumbnailPixelFormat,
pixels: &[u8],
) -> Result<()> {
if width == 0 || height == 0 {
return Err(ThumbnailError::unsupported_rendered_thumbnail(
"raw thumbnail dimensions must be non-zero",
));
}
let row_bytes = raw_row_bytes(width, format)?;
let decoded_len = rgba_buffer_len(width, height)?;
ensure_raw_resource_limits(width, height, decoded_len)?;
if stride < row_bytes {
return Err(ThumbnailError::unsupported_rendered_thumbnail(
"raw thumbnail stride is too small",
));
}
let required_len = raw_required_buffer_len(height, stride, row_bytes)?;
if pixels.len() < required_len {
return Err(ThumbnailError::unsupported_rendered_thumbnail(
"raw thumbnail buffer is too short",
));
}
Ok(())
}
fn ensure_raw_resource_limits(width: u32, height: u32, output_buffer_size: usize) -> Result<()> {
ensure_rendered_resource_limits(width, height, output_buffer_size).map_err(
|error| match error {
ThumbnailError::UnsupportedRenderedThumbnail { .. } => {
ThumbnailError::unsupported_rendered_thumbnail(
"raw thumbnail resource limit exceeded",
)
}
error => error,
},
)
}
fn raw_row_bytes(width: u32, format: RawThumbnailPixelFormat) -> Result<usize> {
usize::try_from(width)
.map_err(|_| {
ThumbnailError::unsupported_rendered_thumbnail("raw thumbnail width overflows usize")
})?
.checked_mul(format.channels())
.ok_or_else(|| {
ThumbnailError::unsupported_rendered_thumbnail(
"raw thumbnail row length overflows usize",
)
})
}
fn raw_required_buffer_len(height: u32, stride: usize, row_bytes: usize) -> Result<usize> {
let height = usize::try_from(height).map_err(|_| {
ThumbnailError::unsupported_rendered_thumbnail("raw thumbnail height overflows usize")
})?;
stride
.checked_mul(height.saturating_sub(1))
.and_then(|bytes_before_last_row| bytes_before_last_row.checked_add(row_bytes))
.ok_or_else(|| {
ThumbnailError::unsupported_rendered_thumbnail(
"raw thumbnail buffer length overflows usize",
)
})
}
fn pixel_count_len(width: u32, height: u32) -> Result<usize> {
let pixels = u64::from(width) * u64::from(height);
usize::try_from(pixels).map_err(|_| {
ThumbnailError::unsupported_rendered_thumbnail("rendered PNG dimensions are too large")
})
}
fn rgba_buffer_len(width: u32, height: u32) -> Result<usize> {
pixel_count_len(width, height)?
.checked_mul(4)
.ok_or_else(|| {
ThumbnailError::unsupported_rendered_thumbnail("RGBA buffer length overflows usize")
})
}
fn validated_lookup_rgba_buffer_len(width: u32, height: u32) -> Result<usize> {
let pixels = u64::from(width) * u64::from(height);
let len = usize::try_from(pixels)
.ok()
.and_then(|pixels| pixels.checked_mul(4))
.ok_or_else(|| {
ThumbnailError::resource_limit_exceeded("PNG decode resource limit exceeded")
})?;
if len > MAX_RENDERED_DECODE_BYTES {
return Err(ThumbnailError::resource_limit_exceeded(
"PNG decode resource limit exceeded",
));
}
Ok(len)
}
pub(crate) fn decode_validated_thumbnail_png_to_rgba8(
bytes: &[u8],
) -> Result<DecodedThumbnailRgba8> {
let image = decode_validated_thumbnail_png_to_rgba_image(bytes)?;
Ok(DecodedThumbnailRgba8 {
width: image.width,
height: image.height,
stride: usize::try_from(image.width)
.ok()
.and_then(|width| width.checked_mul(4))
.ok_or_else(|| {
ThumbnailError::resource_limit_exceeded("PNG decode resource limit exceeded")
})?,
pixels: image.pixels,
})
}
fn decode_validated_thumbnail_png_to_rgba_image(bytes: &[u8]) -> Result<RgbaImage> {
let decoder = png::Decoder::new(Cursor::new(bytes));
let mut reader = decoder
.read_info()
.map_err(|err| ThumbnailError::png(err.to_string()))?;
let Some(output_buffer_size) = reader.output_buffer_size() else {
return Err(ThumbnailError::png(
"png output buffer size is unavailable".to_owned(),
));
};
let info = reader.info();
ensure_parsed_png_resource_limits(info.width, info.height, output_buffer_size)?;
let mut buffer = vec![0; output_buffer_size];
let output = reader
.next_frame(&mut buffer)
.map_err(|err| ThumbnailError::png(err.to_string()))?;
if output.bit_depth != png::BitDepth::Eight {
return Err(ThumbnailError::png(
"validated thumbnail did not decode to 8-bit samples".to_owned(),
));
}
let expected_len = validated_lookup_rgba_buffer_len(output.width, output.height)?;
let frame = &buffer[..output.buffer_size()];
let pixels = match output.color_type {
png::ColorType::Rgba => {
if frame.len() != expected_len {
return Err(ThumbnailError::png(
"decoded RGBA buffer length does not match dimensions".to_owned(),
));
}
frame.to_vec()
}
png::ColorType::GrayscaleAlpha => {
let expected_gray_alpha_len = expected_len / 2;
if frame.len() != expected_gray_alpha_len {
return Err(ThumbnailError::png(
"decoded grayscale-alpha buffer length does not match dimensions".to_owned(),
));
}
let mut out = Vec::with_capacity(expected_len);
for pixel in frame.chunks_exact(2) {
out.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]]);
}
out
}
png::ColorType::Grayscale | png::ColorType::Rgb | png::ColorType::Indexed => {
return Err(ThumbnailError::png(
"validated thumbnail color type is not RGBA or grayscale-alpha".to_owned(),
));
}
};
Ok(RgbaImage {
width: output.width,
height: output.height,
pixels,
})
}
fn decode_rendered_png_to_rgba8(bytes: &[u8]) -> Result<RgbaImage> {
let mut decoder = png::Decoder::new(Cursor::new(bytes));
decoder.set_transformations(
png::Transformations::EXPAND | png::Transformations::STRIP_16 | png::Transformations::ALPHA,
);
let mut reader = decoder
.read_info()
.map_err(|err| ThumbnailError::png(err.to_string()))?;
let info = reader.info();
if info.animation_control.is_some() {
return Err(ThumbnailError::unsupported_rendered_thumbnail(
"animated PNG output is unsupported",
));
}
let Some(output_buffer_size) = reader.output_buffer_size() else {
return Err(ThumbnailError::png(
"png output buffer size is unavailable".to_owned(),
));
};
ensure_rendered_resource_limits(info.width, info.height, output_buffer_size)?;
let mut buffer = vec![0; output_buffer_size];
let output = reader
.next_frame(&mut buffer)
.map_err(|err| ThumbnailError::png(err.to_string()))?;
let frame = &buffer[..output.buffer_size()];
if output.bit_depth != png::BitDepth::Eight {
return Err(ThumbnailError::unsupported_rendered_thumbnail(
"decoded PNG did not normalize to 8-bit samples",
));
}
let pixels = match output.color_type {
png::ColorType::Rgba => frame.to_vec(),
png::ColorType::Rgb => {
let mut out = Vec::with_capacity(rgba_buffer_len(output.width, output.height)?);
for pixel in frame.chunks_exact(3) {
out.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]);
}
out
}
png::ColorType::GrayscaleAlpha => {
let mut out = Vec::with_capacity(rgba_buffer_len(output.width, output.height)?);
for pixel in frame.chunks_exact(2) {
out.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]]);
}
out
}
png::ColorType::Grayscale | png::ColorType::Indexed => {
let mut out = Vec::with_capacity(rgba_buffer_len(output.width, output.height)?);
for &gray in frame {
out.extend_from_slice(&[gray, gray, gray, 255]);
}
out
}
};
Ok(RgbaImage {
width: output.width,
height: output.height,
pixels,
})
}
fn raw_thumbnail_to_rgba8(image: RawThumbnailImage<'_>) -> Result<RgbaImage> {
let row_bytes = raw_row_bytes(image.width, image.format)?;
let mut pixels = Vec::with_capacity(rgba_buffer_len(image.width, image.height)?);
for row_index in 0..image.height as usize {
let start = row_index * image.stride;
let row = &image.pixels[start..start + row_bytes];
match image.format {
RawThumbnailPixelFormat::Rgb8 => {
for pixel in row.chunks_exact(3) {
pixels.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]);
}
}
RawThumbnailPixelFormat::Rgba8 => pixels.extend_from_slice(row),
}
}
Ok(RgbaImage {
width: image.width,
height: image.height,
pixels,
})
}
fn downscale_to_namespace(image: RgbaImage, size: ThumbnailSize) -> Result<RgbaImage> {
let max = size.max_dimension();
if image.width <= max && image.height <= max {
return Ok(image);
}
let (width, height) = constrain_dimensions(image.width, image.height, max);
let source = image
.pixels
.chunks_exact(4)
.map(|pixel| resize::px::RGBA::new(pixel[0], pixel[1], pixel[2], pixel[3]))
.collect::<Vec<_>>();
let mut dest = vec![resize::px::RGBA::new(0, 0, 0, 0); pixel_count_len(width, height)?];
let mut resizer = resize::new(
image.width as usize,
image.height as usize,
width as usize,
height as usize,
resize::Pixel::RGBA8P,
resize::Type::Lanczos3,
)
.map_err(|_| ThumbnailError::unsupported_rendered_thumbnail("resize setup failed"))?;
resizer
.resize(&source, &mut dest)
.map_err(|_| ThumbnailError::unsupported_rendered_thumbnail("resize failed"))?;
let mut pixels = Vec::with_capacity(dest.len() * 4);
for pixel in dest {
pixels.extend_from_slice(&[pixel.r, pixel.g, pixel.b, pixel.a]);
}
Ok(RgbaImage {
width,
height,
pixels,
})
}
fn constrain_dimensions(width: u32, height: u32, max: u32) -> (u32, u32) {
if width >= height {
let scaled_height = (u64::from(height) * u64::from(max) / u64::from(width)).max(1) as u32;
(max, scaled_height)
} else {
let scaled_width = (u64::from(width) * u64::from(max) / u64::from(height)).max(1) as u32;
(scaled_width, max)
}
}
pub(crate) fn encode_rgba_png(
width: u32,
height: u32,
pixels: &[u8],
metadata: &[(String, String)],
) -> Result<Vec<u8>> {
let expected_len = rgba_buffer_len(width, height)?;
if pixels.len() != expected_len {
return Err(ThumbnailError::unsupported_rendered_thumbnail(
"RGBA buffer length does not match dimensions",
));
}
let mut output = Vec::new();
{
let mut encoder = png::Encoder::new(&mut output, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
for (key, value) in metadata {
encoder
.add_text_chunk(key.clone(), value.clone())
.map_err(|err| ThumbnailError::png(err.to_string()))?;
}
let mut writer = encoder
.write_header()
.map_err(|err| ThumbnailError::png(err.to_string()))?;
writer
.write_image_data(pixels)
.map_err(|err| ThumbnailError::png(err.to_string()))?;
}
Ok(output)
}
pub(crate) fn validate_mime_type(mime_type: &str) -> Result<()> {
if mime_type.is_empty()
|| !mime_type.is_ascii()
|| mime_type.bytes().any(|byte| byte.is_ascii_control())
|| !mime_type.contains('/')
{
return Err(ThumbnailError::invalid_metadata("invalid MIME type"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rendered_resource_limit_rejects_large_dimensions() {
let error = ensure_rendered_resource_limits(4097, 4097, 4097 * 4097 * 4).unwrap_err();
assert!(matches!(
error,
ThumbnailError::UnsupportedRenderedThumbnail {
reason: "rendered PNG resource limit exceeded",
..
}
));
}
#[test]
fn rgba_buffer_len_rejects_overflow() {
let error = rgba_buffer_len(u32::MAX, u32::MAX).unwrap_err();
assert!(matches!(
error,
ThumbnailError::UnsupportedRenderedThumbnail {
reason: "RGBA buffer length overflows usize",
..
}
));
}
#[test]
fn rendered_png_rejects_apng() {
let mut output = Vec::new();
{
let mut encoder = png::Encoder::new(&mut output, 1, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_animated(1, 0).unwrap();
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&[255, 0, 0, 255]).unwrap();
}
let error = match decode_rendered_png_to_rgba8(&output) {
Ok(_) => panic!("APNG rendered output was accepted"),
Err(error) => error,
};
assert!(matches!(
error,
ThumbnailError::UnsupportedRenderedThumbnail {
reason: "animated PNG output is unsupported",
..
}
));
}
}