mod rules;
#[cfg(test)]
mod tests;
use crate::{
ImageCandidate, Policy, Resource, ResourceAnalysis,
analyze_image::Context,
filesystem::{hash, write_new},
optimizer,
timings::Phase,
};
use anyhow::{Result, bail, ensure};
use rules::IMAGES;
pub(crate) use rules::Refusal;
use std::path::PathBuf;
use svga::{Compression, ValueKind};
const CANCELLED: &str = "svga_analysis_cancelled";
struct Optimized {
bytes: Vec<u8>,
images: usize,
images_optimized: usize,
}
#[cfg(test)]
pub(crate) fn optimize(original: &[u8], png: &Policy) -> Result<Vec<u8>> {
Ok(optimize_with(original, png, &|| false)?.bytes)
}
fn optimize_with(original: &[u8], png: &Policy, cancelled: &dyn Fn() -> bool) -> Result<Optimized> {
let document = rules::open(original)?;
let mut edited = document.clone();
let (mut images, mut images_optimized) = (0, 0);
for (position, image) in document.images().enumerate() {
ensure!(!cancelled(), CANCELLED);
if image.kind() != ValueKind::Png {
continue;
}
images += 1;
let smaller = optimizer::optimize(image.value(), png)
.ok()
.filter(|candidate| candidate.len() < image.value().len());
if let Some(candidate) = smaller {
edited = edited
.replace_image_at(position, &candidate)
.map_err(Refusal::from)?;
images_optimized += 1;
}
}
ensure!(!cancelled(), CANCELLED);
let candidate = edited.to_bytes(Compression::Best).map_err(Refusal::from)?;
let bytes = if candidate.len() < original.len() {
candidate
} else {
original.to_vec()
};
Ok(Optimized {
bytes,
images,
images_optimized,
})
}
pub(crate) fn verify(original: &[u8], candidate: &[u8]) -> Result<()> {
let (before, after) = (rules::open(original)?, rules::open(candidate)?);
ensure!(
before.fields().count() == after.fields().count(),
"svga_verify_field_count_changed"
);
let (mut old_images, mut new_images) = (before.images(), after.images());
for (old, new) in before.fields().zip(after.fields()) {
match (old.number == IMAGES, new.number == IMAGES) {
(false, false) => ensure!(old.raw == new.raw, "svga_verify_field_changed"),
(true, true) => {
let (Some(old), Some(new)) = (old_images.next(), new_images.next()) else {
bail!("svga_verify_field_order_changed");
};
ensure!(
old.key_bytes() == new.key_bytes()
&& old.field_numbers() == new.field_numbers(),
"svga_verify_image_key_changed"
);
if old.value() == new.value() {
continue;
}
ensure!(
old.kind() == ValueKind::Png && new.kind() == ValueKind::Png,
"svga_verify_image_value_changed"
);
let same = optimizer::verify(old.value(), new.value(), false)
.or_else(|_| optimizer::verify(old.value(), new.value(), true));
ensure!(same.is_ok(), "svga_verify_image_pixels_changed");
}
_ => bail!("svga_verify_field_order_changed"),
}
}
Ok(())
}
pub(crate) fn analyze(
context: &Context<'_>,
resource: &Resource,
index: usize,
original: &[u8],
digest: &str,
) -> ResourceAnalysis {
let mut result = ResourceAnalysis::new(resource, "inspected");
result.sha256 = Some(digest.to_string());
if let Err(error) = analyze_into(context, resource, index, original, &mut result) {
result.candidates.clear();
result.smallest_candidate = None;
result.status = match error.downcast_ref::<Refusal>() {
Some(Refusal::Unsupported(_)) => "unsupported",
_ if error.to_string() == CANCELLED => "not_analyzed",
_ => "failed",
}
.into();
if result.status != "not_analyzed" {
result.issues.push(format!("{error:#}"));
}
}
result
}
fn analyze_into(
context: &Context<'_>,
resource: &Resource,
index: usize,
original: &[u8],
result: &mut ResourceAnalysis,
) -> Result<()> {
let Context {
out,
options,
timings,
control,
..
} = context;
timings.time(Phase::Decode, || rules::open(original).map(drop))?;
if let Some(reason) = &resource.conversion_exclusion {
result.status = "excluded".into();
result.issues.push(reason.clone());
return Ok(());
}
if options.probe_only {
return Ok(());
}
if original.len() < options.min_input_bytes as usize {
result.issues.push("below_explicit_input_threshold".into());
return Ok(());
}
let optimized = timings.time(Phase::EncodeLossless, || {
let policy = Policy {
reductions: true,
..options.png_policy()
};
optimize_with(original, &policy, &|| control.is_cancelled())
})?;
let bytes = optimized.bytes;
let savings_bytes = (original.len() as u64).saturating_sub(bytes.len() as u64);
if savings_bytes < options.min_savings_bytes.max(1) {
return Ok(());
}
timings.time(Phase::Decode, || verify(original, &bytes))?;
let artifact = PathBuf::from(format!("candidates/{index}-svga-0.svga"));
let original_artifact = PathBuf::from(format!("originals/{index}.svga"));
timings.time(Phase::Write, || -> Result<()> {
write_new(&out.join(&artifact), &bytes)?;
write_new(&out.join(&original_artifact), original)
})?;
result.candidates.push(ImageCandidate {
format: "svga".into(),
quality: None,
lossy: false,
bytes: bytes.len() as u64,
savings_bytes,
valid: true,
rejection: None,
difference: None,
artifact: Some(artifact),
preview: None,
sha256: Some(hash(&bytes)),
warnings: vec![],
notes: vec![format!(
"embedded_pngs_optimized: {}/{}",
optimized.images_optimized, optimized.images
)],
});
result.smallest_candidate = Some(0);
result.original_artifact = Some(original_artifact);
result.status = "candidates_available".into();
let current = crate::filesystem::contained_file(context.root, &resource.path)
.and_then(|path| crate::resources::bounded_read(&path))?;
ensure!(
Some(hash(¤t)) == result.sha256,
"source_changed_during_analysis"
);
Ok(())
}