DWG open: stop runaway conversions early and name the limit (#57) (#175)

* DWG open: stop runaway conversions early, name the limit (#57)

"file too large" was the DXF reader's 50,000,000 group-code cap (MAX_TAGS),
not a file-size limit. For a DWG it was only checked at the very end: after
acadrust had read the whole file, written all of its DXF text into one Vec,
the text had been copied again for decoding, and 50 million tags had been
parsed. A real 10 MB DWG is nowhere near the cap (a synthetic 10 MB AC1024
file is 3.7 million group codes and opens in about 2 s). A damaged file, or
objects the DWG reader misreads, can be: acadrust caps every count at 100,000
and reads zeros past the end of the data, so one desynchronised object becomes
a polyline with 100,000 vertices. A fuzzed 0.25 MB AC1024 file made it write
11 GB of DXF.

- cadcraft-dwg: Limits { max_dwg_bytes: 512 MB, max_dxf_bytes: 1 GB }. The DWG
  size is checked before the reader runs; the DXF is streamed into a writer
  that fails as soon as it passes the limit, so the conversion stops there
  instead of filling memory. Both errors give actual and allowed sizes. The
  input is no longer copied, and the DXF writer also runs under catch_unwind.
- cadcraft-dxf: TooLarge { tags, limit } says "drawing too large: N DXF group
  codes, more than the 50000000 CADCraft reads". For ASCII the count comes
  from the line count before any decoding or tag allocation. Valid UTF-8 text
  is borrowed instead of copied. MAX_TAGS is public and unchanged.
- cadcraft-io: errors from the DXF converted from a DWG say so.

On the fuzzed file: before, 76 s and 13.4 GB, then the process aborted copying
the 11 GB text (under a 16 GB memory limit). After, 15 s and 5.3 GB, with
"DWG: converting this 0.2 MB file produced more than 1024 MB of drawing data
(the limit); ...". What's left is acadrust's reader itself (8 s, 5.3 GB on that
file), which this bridge can't bound without patching the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move tests away from other PRs' hunks to avoid merge conflicts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 18:15:01 +02:00 committed by GitHub
parent 7497618f76
commit 61875b1f0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 194 additions and 23 deletions

View File

@ -21,17 +21,87 @@ pub fn version(bytes: &[u8]) -> Option<String> {
is_dwg(bytes).then(|| String::from_utf8_lossy(bytes.get(0..6).unwrap_or_default()).to_string())
}
/// Size limits for reading a DWG file. DWG is compressed and the DXF text it converts to is
/// several times larger (about 6x in our tests: a 10 MB DWG becomes about 60 MB of DXF), so
/// the limits are generous for real drawings. They exist because a damaged file, or one the reader
/// misreads, can make the DWG reader produce a drawing thousands of times larger than the file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Limits {
/// Largest DWG file read at all (checked before reading starts).
pub max_dwg_bytes: usize,
/// Largest DXF rendition of a DWG (checked while it is written).
pub max_dxf_bytes: usize,
}
impl Limits {
/// 512 MB of DWG, 1 GB of DXF (more than the DXF reader's group-code limit fills).
pub const DEFAULT: Limits = Limits { max_dwg_bytes: 512 << 20, max_dxf_bytes: 1 << 30 };
}
/// A byte count in megabytes for messages ("9.9 MB", "1024 MB").
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn mb(n: u64) -> String {
let m = n as f64 / f64::from(1u32 << 20);
if m >= 100.0 { format!("{m:.0} MB") } else { format!("{m:.1} MB") }
}
#[cfg(not(target_arch = "wasm32"))]
mod native {
use std::io::Cursor;
use std::io::{Cursor, Write};
use std::panic::AssertUnwindSafe;
/// Read DWG bytes and return an ASCII DXF rendition.
use super::{Limits, mb};
/// Read DWG bytes and return an ASCII DXF rendition, within [`Limits::DEFAULT`].
pub fn dwg_to_dxf(bytes: &[u8]) -> Result<Vec<u8>, String> {
let data = bytes.to_vec();
let doc = std::panic::catch_unwind(move || acadrust::DwgReader::from_stream(Cursor::new(data)).read())
dwg_to_dxf_with(bytes, Limits::DEFAULT)
}
/// [`dwg_to_dxf`] with explicit limits. Both are checked before the expensive step they
/// guard: the input size before the DWG reader runs, the DXF size while it is written, so a
/// runaway conversion stops at the limit instead of filling memory first.
pub fn dwg_to_dxf_with(bytes: &[u8], limits: Limits) -> Result<Vec<u8>, String> {
if bytes.len() > limits.max_dwg_bytes {
return Err(format!("DWG: the file is {}, larger than the {} CADCraft opens", mb(bytes.len() as u64), mb(limits.max_dwg_bytes as u64)));
}
let doc = std::panic::catch_unwind(|| acadrust::DwgReader::from_stream(Cursor::new(bytes)).read())
.map_err(|_| "the DWG reader failed on this file".to_string())?
.map_err(|e| format!("DWG: {e}"))?;
acadrust::DxfWriter::new(&doc).write_to_vec().map_err(|e| format!("DWG→DXF: {e}"))
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
let written = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)))
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
if out.exceeded {
return Err(format!(
"DWG: converting this {} file produced more than {} of drawing data (the limit); \
the file is probably damaged, or uses objects the DWG reader misreads",
mb(bytes.len() as u64),
mb(limits.max_dxf_bytes as u64)
));
}
written.map_err(|e| format!("DWG→DXF: {e}"))?;
Ok(out.buf)
}
/// Collects the DXF text and fails the write once it would grow past `limit`.
struct CappedWriter {
buf: Vec<u8>,
limit: usize,
exceeded: bool,
}
impl Write for CappedWriter {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
// Sticky: once over, every later write fails too.
if self.exceeded || self.buf.len().saturating_add(b.len()) > self.limit {
self.exceeded = true;
return Err(std::io::Error::other("DXF size limit reached"));
}
self.buf.extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// Convert DXF bytes into a DWG file.
@ -45,13 +115,17 @@ mod native {
}
#[cfg(not(target_arch = "wasm32"))]
pub use native::{dwg_to_dxf, dxf_to_dwg};
pub use native::{dwg_to_dxf, dwg_to_dxf_with, dxf_to_dwg};
#[cfg(target_arch = "wasm32")]
pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
}
#[cfg(target_arch = "wasm32")]
pub fn dwg_to_dxf_with(_bytes: &[u8], _limits: Limits) -> Result<Vec<u8>, String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
}
#[cfg(target_arch = "wasm32")]
pub fn dxf_to_dwg(_dxf: &[u8]) -> Result<Vec<u8>, String> {
Err("DWG files can't be written in the web build yet; save as DXF".into())
}

View File

@ -17,8 +17,10 @@ pub enum DxfError {
BadCode { line: usize, text: String },
#[error("unexpected end of file")]
Eof,
#[error("file too large")]
TooLarge,
/// More group codes than [`MAX_TAGS`]: `tags` is the count found (or the count read when the
/// limit was reached), `limit` the maximum.
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads")]
TooLarge { tags: usize, limit: usize },
}
pub type Result<T> = std::result::Result<T, DxfError>;
@ -157,22 +159,37 @@ fn typed(code: i32, text: &str) -> Value {
}
const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
const MAX_TAGS: usize = 50_000_000;
/// Most group codes read from one file. A `Tag` takes 40 bytes plus its text, so this bounds the
/// tag list to a few GB; real drawings stay far below (a 10 MB DWG is typically a few million).
pub const MAX_TAGS: usize = 50_000_000;
/// Decode bytes as text: UTF-8 when valid, otherwise Latin-1 (old ANSI_1252 files).
fn decode_text(b: &[u8]) -> String {
/// Decode bytes as text: UTF-8 when valid (borrowed, no copy), otherwise Latin-1 (old ANSI_1252
/// files).
fn decode_text(b: &[u8]) -> std::borrow::Cow<'_, str> {
match std::str::from_utf8(b) {
Ok(s) => s.to_string(),
Err(_) => b.iter().map(|&c| char::from(c)).collect(),
Ok(s) => std::borrow::Cow::Borrowed(s),
Err(_) => std::borrow::Cow::Owned(b.iter().map(|&c| char::from(c)).collect()),
}
}
/// Parse a DXF file (ASCII or binary).
pub fn parse(bytes: &[u8]) -> Result<Vec<Tag>> {
if bytes.starts_with(BINARY_SENTINEL) {
return parse_binary(bytes.get(BINARY_SENTINEL.len()..).unwrap_or(&[]));
parse_with_limit(bytes, MAX_TAGS)
}
/// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from
/// the line count, before the text is decoded or any tag allocated, so an oversized file fails in
/// milliseconds instead of after it has filled memory.
pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) {
return parse_binary_with_limit(body, max_tags);
}
parse_ascii(&decode_text(bytes))
// Every group code takes two lines (code, value).
let tags = bytes.iter().filter(|&&b| b == b'\n').count().div_ceil(2);
if tags > max_tags {
return Err(DxfError::TooLarge { tags, limit: max_tags });
}
parse_ascii_with_limit(&decode_text(bytes), max_tags)
}
/// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one.
@ -219,8 +236,12 @@ fn unescape_unicode(s: &str) -> String {
out
}
#[allow(clippy::while_let_loop)]
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
parse_ascii_with_limit(text, MAX_TAGS)
}
#[allow(clippy::while_let_loop)]
fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
let mut tags = Vec::new();
let mut lines = text.lines().enumerate();
loop {
@ -237,8 +258,8 @@ pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
o => o,
};
tags.push(Tag { code, value: v });
if tags.len() > MAX_TAGS {
return Err(DxfError::TooLarge);
if tags.len() > max_tags {
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
}
if code == 0 && tags.last().is_some_and(|t| t.str() == "EOF") {
break;
@ -290,7 +311,7 @@ impl Cur<'_> {
while self.p < self.b.len() && self.b.get(self.p) != Some(&0) {
self.p += 1;
}
let s = decode_text(self.b.get(start..self.p).unwrap_or(&[]));
let s = decode_text(self.b.get(start..self.p).unwrap_or(&[])).into_owned();
if self.p >= self.b.len() {
return Err(DxfError::Eof);
}
@ -301,6 +322,10 @@ impl Cur<'_> {
/// Binary DXF (R13+: 2-byte group codes).
pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
parse_binary_with_limit(b, MAX_TAGS)
}
fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
let mut c = Cur { b, p: 0 };
let mut tags = Vec::new();
while c.p < b.len() {
@ -327,8 +352,8 @@ pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
};
let eof = code == 0 && matches!(&v, Value::Str(s) if s == "EOF");
tags.push(Tag { code, value: v });
if tags.len() > MAX_TAGS {
return Err(DxfError::TooLarge);
if tags.len() > max_tags {
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
}
if eof {
break;

View File

@ -30,7 +30,7 @@ fn ext(name: &str) -> String {
pub fn read(bytes: &[u8], name: &str) -> Result<Drawing> {
if cadcraft_dwg::is_dwg(bytes) {
let dxf = cadcraft_dwg::dwg_to_dxf(bytes).map_err(IoError::Format)?;
return dxf_read::read(&dxf);
return dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")));
}
match ext(name).as_str() {
"dxf" | "" => dxf_read::read(bytes),

View File

@ -626,6 +626,78 @@ fn dimension_overrides_roundtrip_as_dstyle_xdata() {
assert_eq!(again.overrides, got.overrides);
}
/// A drawing with `n` lines, circles and polylines on a grid (issue #57's tests).
fn many_entities(n: usize) -> Drawing {
let mut d = Drawing::new_imperial();
for i in 0..n {
let (x, y) = ((i % 300) as f64 * 10.0, (i / 300) as f64 * 10.0);
let kind = match i % 3 {
0 => EntityKind::Line(Line { a: Vec3::new(x, y, 0.0), b: Vec3::new(x + 4.0, y + (i % 7) as f64, 0.0) }),
1 => EntityKind::Circle(Circle { center: Vec3::new(x, y, 0.0), radius: 1.0 + (i % 5) as f64 }),
_ => EntityKind::LwPolyline(LwPolyline {
vertices: (0..8).map(|k| PolyVertex::new(Vec2::new(x + f64::from(k), y + (k % 3) as f64))).collect(),
closed: false,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
};
d.add(&Space::Model, Common::default(), kind).unwrap();
}
d
}
/// An AutoCAD 2010 (AC1024) DWG of `n` entities, made through the DWG bridge.
#[cfg(not(target_arch = "wasm32"))]
fn ac1024_dwg(n: usize) -> Vec<u8> {
let dxf = write_dxf(&many_entities(n)).replacen("AC1015", "AC1024", 1);
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1024"));
dwg
}
/// Issue #57: an AutoCAD 2010 DWG with tens of thousands of entities opens completely, and its
/// DXF rendition stays far inside the limits (about 6 bytes of DXF and 0.4 group codes per DWG
/// byte), so a 10 MB file is nowhere near them.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn ac1024_dwg_with_many_entities_opens() {
let n = 30_000;
let dwg = ac1024_dwg(n);
let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap();
let tags = cadcraft_dxf::parse(&dxf).unwrap().len();
assert!(dxf.len() < dwg.len() * 20, "DXF {} bytes from {} DWG bytes", dxf.len(), dwg.len());
assert!(tags < dwg.len(), "{tags} group codes from {} DWG bytes", dwg.len());
let d = read(&dwg, "big.dwg").unwrap();
assert_eq!(d.entity_count(), n);
}
/// Issue #57: oversized input is rejected early, with a message naming the limit, never a panic.
/// A conversion that runs away (a damaged or misread DWG can make the reader produce a drawing
/// thousands of times larger than the file) stops at the DXF size limit instead of filling memory
/// and failing later with "file too large"; the DWG size is checked before reading; the DXF
/// group-code count before parsing.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn oversized_dwg_and_dxf_are_rejected_early() {
let dwg = ac1024_dwg(3000);
let full = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap().len();
assert!(full > 400_000, "{full}");
let limits = cadcraft_dwg::Limits { max_dxf_bytes: 300_000, ..cadcraft_dwg::Limits::DEFAULT };
let e = cadcraft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
assert!(e.starts_with("DWG: converting this 0."), "{e}");
assert!(e.contains("MB file produced more than 0.3 MB of drawing data (the limit)"), "{e}");
let limits = cadcraft_dwg::Limits { max_dwg_bytes: 100_000, ..cadcraft_dwg::Limits::DEFAULT };
let e = cadcraft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
assert!(e.starts_with("DWG: the file is 0.") && e.ends_with("MB, larger than the 0.1 MB CADCraft opens"), "{e}");
// Lines that aren't even group codes: the size is reported before anything is parsed.
let text = "not a group code\nx\n".repeat(60);
let e = cadcraft_dxf::parse_with_limit(text.as_bytes(), 50).unwrap_err();
assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads");
}
#[test]
fn dimension_associativity_roundtrips() {
let mut d = Drawing::new_imperial();