use crate::{Result, formats};
use image::{ImageFormat, ImageReader, RgbaImage};
use std::{
collections::BTreeMap,
io::{Cursor, Read},
path::Path,
};
const MAX_IMAGE: usize = 8 * 1024 * 1024;
const MAX_METADATA: usize = 64 * 1024;
const MAX_ENTRIES: usize = 4096;
const MODEL_IMAGE: &str = "meta/model_thumbnail.png";
pub fn extract(path: &Path) -> Result<Option<RgbaImage>> {
extract_bytes(&formats::read_file(path)?)
}
fn extract_bytes(data: &[u8]) -> Result<Option<RgbaImage>> {
let mut source = Reader { remaining: data };
if source.string()? != "SketchUp Model" {
return Err("input is not a SketchUp document".into());
}
let version = source.string()?;
let plain = version
.strip_prefix('{')
.and_then(|v| v.strip_suffix('}'))
.ok_or("invalid SketchUp version string")?;
let parts = plain.split('.').collect::<Vec<_>>();
if parts.len() != 3
|| parts
.iter()
.any(|p| p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()))
{
return Err("invalid SketchUp version string".into());
}
match parts[0].parse::<u32>()? {
major @ 13..=17 => legacy(&mut source, major),
26 => modern(&mut source, plain),
_ => Err("unqualified SketchUp version (supported major versions: 13–17 and 26)".into()),
}
}
fn legacy(source: &mut Reader<'_>, major: u32) -> Result<Option<RgbaImage>> {
source.take(16)?; if !source.string()?.is_empty() {
return Err("unsupported SketchUp legacy header field".into());
}
source.u32()?; source.class("CVersionMap", 0)?;
let mut schemas = BTreeMap::new();
loop {
let name = source.string()?;
let schema = source.u32()?;
if name == "End-Of-Version-Map" {
if schema != 0 {
return Err("invalid SketchUp version-map terminator".into());
}
break;
}
if schemas.len() >= 256 {
return Err("SketchUp version map exceeds the 256-class limit".into());
}
if name.is_empty() || schemas.insert(name, schema).is_some() {
return Err("ambiguous SketchUp version map".into());
}
}
if schemas.get("CDib") != Some(&3) {
return Err("unqualified SketchUp CDib schema".into());
}
for expected in [2, 1200, 6] {
if source.u32()? != expected {
return Err("unsupported SketchUp legacy document framing".into());
}
}
if major == 17 {
source.u32()?; if source.u32()? != 0 {
return Err("unsupported SketchUp legacy reserved field".into());
}
}
if source.remaining.starts_with(&[0, 0]) {
source.take(2)?;
return Ok(None);
}
source.class("CDib", 3)?;
if source.u32()? != 4 {
return Err("unsupported SketchUp model-thumbnail image subtype (expected PNG)".into());
}
let length = usize::try_from(source.u32()?)?;
if length > MAX_IMAGE {
return Err("SketchUp model thumbnail exceeds the 8 MiB limit".into());
}
decode_png(source.take(length)?).map(Some)
}
fn modern(source: &mut Reader<'_>, version: &str) -> Result<Option<RgbaImage>> {
if source.take(9)? != b"VFF\x08\x00\x01\x00\x11\x00" {
return Err("unqualified SketchUp VFF container prefix".into());
}
source.take(4)?; let bytes = source.remaining;
if !bytes.starts_with(b"PK\x03\x04") {
return Err("SketchUp VFF container has no addressed ZIP archive".into());
}
let physical = formats::zip_directory::validate(
&mut Cursor::new(bytes),
u64::try_from(bytes.len())?,
MAX_ENTRIES,
)?;
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))?;
if archive.len() != physical {
return Err("ambiguous SketchUp ZIP member names".into());
}
let mut entries = BTreeMap::new();
for index in 0..archive.len() {
let entry = archive.by_index_raw(index)?;
let name = entry.name()?;
let canonical = name.strip_suffix('/').unwrap_or(&name);
if canonical.is_empty()
|| canonical.len() > 4096
|| canonical.contains(['\\', '\0'])
|| canonical
.split('/')
.any(|p| p.is_empty() || matches!(p, "." | ".."))
|| entries.insert(canonical.to_owned(), index).is_some()
{
return Err("invalid or ambiguous SketchUp ZIP member path".into());
}
}
let model_index = *entries
.get("model.dat")
.ok_or("SketchUp archive has no model.dat member")?;
if archive.by_index_raw(model_index)?.is_dir() {
return Err("SketchUp model.dat member is a directory".into());
}
let meta_index = *entries
.get("meta/meta.dat")
.ok_or("SketchUp archive has no metadata member")?;
let metadata = member(&mut archive, meta_index, MAX_METADATA)?;
if !model_image_present(&metadata, version)? {
return Ok(None);
}
let index = *entries
.get(MODEL_IMAGE)
.ok_or("addressed SketchUp model thumbnail is missing")?;
decode_png(&member(&mut archive, index, MAX_IMAGE)?).map(Some)
}
fn model_image_present(metadata: &[u8], version: &str) -> Result<bool> {
let root = records(metadata)?;
if root.len() != 1 {
return Err("SketchUp metadata must contain one root record".into());
}
let fields = records(root.get(&0x64).ok_or("invalid SketchUp metadata root")?)?;
if fields.get(&0x75).copied() != Some(version.as_bytes())
|| fields.get(&0x76).copied() != Some(&[26, 0])
|| fields.get(&0x77).copied() != Some(&[1, 0])
|| fields.get(&0x73).copied() != Some(&[1, 0])
|| fields.get(&0x74).copied() != Some(&[17, 0])
{
return Err("unqualified or inconsistent SketchUp metadata version".into());
}
let Some(carrier) = fields.get(&0x67).filter(|b| !b.is_empty()) else {
return Ok(false);
};
let image = records(carrier)?;
if image.len() != 1 {
return Err("invalid SketchUp model-thumbnail record".into());
}
let image = records(
image
.get(&0x2328)
.ok_or("invalid SketchUp model-image container")?,
)?;
if image.len() != 2
|| image.get(&0x2329).copied() != Some(&[4, 0, 0, 0])
|| image.get(&0x232a).copied() != Some(MODEL_IMAGE.as_bytes())
{
return Err("unsupported SketchUp model-thumbnail descriptor".into());
}
Ok(true)
}
fn member(
archive: &mut zip::ZipArchive<Cursor<&[u8]>>,
index: usize,
limit: usize,
) -> Result<Vec<u8>> {
let mut entry = archive.by_index(index)?;
if entry.is_dir()
|| entry.encrypted()
|| !matches!(
entry.compression(),
zip::CompressionMethod::Stored | zip::CompressionMethod::Deflated
)
|| entry.size() > u64::try_from(limit)?
|| entry.compressed_size() > u64::try_from(limit)?
{
return Err("unsupported or oversized SketchUp selected ZIP member".into());
}
let declared = usize::try_from(entry.size())?;
let mut data = Vec::with_capacity(declared);
entry
.by_ref()
.take(u64::try_from(limit)? + 1)
.read_to_end(&mut data)?;
if data.len() != declared || data.len() > limit {
return Err("SketchUp selected ZIP member has an invalid decoded size".into());
}
Ok(data)
}
fn records(mut bytes: &[u8]) -> Result<BTreeMap<u16, &[u8]>> {
let mut result = BTreeMap::new();
while !bytes.is_empty() {
let mut source = Reader { remaining: bytes };
let tag = source.u16()?;
let length = usize::try_from(source.u32()?)?;
let payload = source.take(length)?;
if result.len() >= 128 || result.insert(tag, payload).is_some() {
return Err("SketchUp metadata has too many or duplicate records".into());
}
bytes = source.remaining;
}
Ok(result)
}
fn decode_png(bytes: &[u8]) -> Result<RgbaImage> {
if bytes.len() < 33
|| !bytes.starts_with(b"\x89PNG\r\n\x1a\n")
|| bytes[8..16] != [0, 0, 0, 13, b'I', b'H', b'D', b'R']
{
return Err("SketchUp model thumbnail is not a PNG with a valid IHDR".into());
}
let width = u32::from_be_bytes(bytes[16..20].try_into()?);
let height = u32::from_be_bytes(bytes[20..24].try_into()?);
if width == 0
|| height == 0
|| width > 4096
|| height > 4096
|| u64::from(width) * u64::from(height) > 16_000_000
{
return Err("SketchUp model thumbnail exceeds the dimension or pixel limit".into());
}
let mut reader = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
Ok(reader.decode()?.to_rgba8())
}
struct Reader<'a> {
remaining: &'a [u8],
}
impl<'a> Reader<'a> {
fn take(&mut self, length: usize) -> Result<&'a [u8]> {
let (head, tail) = self
.remaining
.split_at_checked(length)
.ok_or("truncated SketchUp carrier")?;
self.remaining = tail;
Ok(head)
}
fn u16(&mut self) -> Result<u16> {
Ok(u16::from_le_bytes(self.take(2)?.try_into()?))
}
fn u32(&mut self) -> Result<u32> {
Ok(u32::from_le_bytes(self.take(4)?.try_into()?))
}
fn string(&mut self) -> Result<String> {
if self.take(3)? != b"\xff\xfe\xff" {
return Err("unsupported SketchUp string encoding".into());
}
let short = self.take(1)?[0];
let length = if short == 255 {
let word = self.u16()?;
if word == u16::MAX {
usize::try_from(self.u32()?)?
} else {
usize::from(word)
}
} else {
usize::from(short)
};
if length > 128 {
return Err("SketchUp identity string exceeds the 128-code-unit limit".into());
}
let units = self
.take(length * 2)?
.as_chunks::<2>()
.0
.iter()
.copied()
.map(u16::from_le_bytes)
.collect::<Vec<_>>();
Ok(String::from_utf16(&units)?)
}
fn class(&mut self, expected: &str, schema: u16) -> Result<()> {
if self.u16()? != u16::MAX || self.u16()? != schema {
return Err("unsupported SketchUp class declaration or schema".into());
}
let length = usize::from(self.u16()?);
if length != expected.len() || self.take(length)? != expected.as_bytes() {
return Err("unexpected SketchUp addressed object class".into());
}
Ok(())
}
}