symbolic_unreal/
container.rs

1//! API to process Unreal Engine 4 crashes.
2#![warn(missing_docs)]
3
4use std::fmt;
5use std::io::Read;
6use std::iter::FusedIterator;
7use std::ops::Deref;
8
9use bytes::Bytes;
10use flate2::bufread::ZlibDecoder;
11use scroll::{ctx::TryFromCtx, Endian, Pread};
12
13use crate::context::Unreal4Context;
14use crate::error::{Unreal4Error, Unreal4ErrorKind};
15use crate::logs::Unreal4LogEntry;
16
17#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
18struct AnsiString(String);
19
20impl AnsiString {
21    pub fn as_str(&self) -> &str {
22        &self.0
23    }
24}
25
26impl AsRef<str> for AnsiString {
27    fn as_ref(&self) -> &str {
28        &self.0
29    }
30}
31
32impl Deref for AnsiString {
33    type Target = str;
34
35    fn deref(&self) -> &Self::Target {
36        &self.0
37    }
38}
39
40impl fmt::Display for AnsiString {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        self.0.fmt(f)
43    }
44}
45
46impl TryFromCtx<'_, Endian> for AnsiString {
47    type Error = scroll::Error;
48
49    fn try_from_ctx(data: &[u8], context: Endian) -> Result<(Self, usize), Self::Error> {
50        let mut offset = 0;
51
52        // Read the length and data of this string
53        let len = data.gread_with::<u32>(&mut offset, context)?;
54        let bytes = data.gread_with::<&[u8]>(&mut offset, len as usize)?;
55
56        // Convert into UTF-8 and truncate the trailing zeros
57        let mut string = String::from_utf8_lossy(bytes).into_owned();
58        let actual_len = string.trim_end_matches('\0').len();
59        string.truncate(actual_len);
60
61        Ok((Self(string), offset))
62    }
63}
64
65#[allow(dead_code)]
66#[derive(Clone, Debug, Pread)]
67struct Unreal4Header {
68    pub directory_name: AnsiString,
69    pub file_name: AnsiString,
70    pub uncompressed_size: i32,
71    pub file_count: i32,
72}
73
74/// Meta-data about a file within a UE4 crash file.
75#[derive(Clone, Debug)]
76struct Unreal4FileMeta {
77    /// The original index within the UE4 crash file.
78    index: usize,
79    /// File name.
80    file_name: AnsiString,
81    /// Start of the file within crash dump.
82    offset: usize,
83    /// Length of bytes from offset.
84    len: usize,
85}
86
87impl TryFromCtx<'_, usize> for Unreal4FileMeta {
88    type Error = scroll::Error;
89
90    fn try_from_ctx(data: &[u8], file_offset: usize) -> Result<(Self, usize), Self::Error> {
91        let mut offset = 0;
92
93        let index = data.gread_with::<i32>(&mut offset, scroll::LE)? as usize;
94        let file_name = data.gread_with(&mut offset, scroll::LE)?;
95        let len = data.gread_with::<i32>(&mut offset, scroll::LE)? as usize;
96
97        let file_meta = Unreal4FileMeta {
98            index,
99            file_name,
100            offset: file_offset + offset,
101            len,
102        };
103
104        // Ensure that the buffer contains enough data
105        if len > 0 {
106            data.gread_with::<&[u8]>(&mut offset, len)?;
107        }
108
109        Ok((file_meta, offset))
110    }
111}
112
113fn gread_files(
114    bytes: &[u8],
115    count: usize,
116    offset: &mut usize,
117) -> Result<Vec<Unreal4FileMeta>, Unreal4Error> {
118    // a `Unreal4FileMeta` is at least 3 * 4 bytes
119    if count > bytes.len() / 12 {
120        return Err(Unreal4ErrorKind::BadData.into());
121    }
122    let mut files = Vec::with_capacity(count);
123    for _ in 0..count {
124        let file_offset = *offset;
125        files.push(bytes.gread_with(offset, file_offset)?);
126    }
127    Ok(files)
128}
129
130/// Unreal Engine 4 crash file.
131#[derive(Debug)]
132pub struct Unreal4Crash {
133    bytes: Bytes,
134    header: Unreal4Header,
135    files: Vec<Unreal4FileMeta>,
136}
137
138impl Unreal4Crash {
139    fn from_bytes(bytes: Bytes) -> Result<Self, Unreal4Error> {
140        let mut offset = 0;
141
142        let (header, files) = if bytes.starts_with(b"CR1") {
143            // https://github.com/EpicGames/UnrealEngine/commit/a0471b76577a64e5c4dad89a38dfe7d9611a65ef
144            // The 'CR1' marker marks a new version of the file format. There is a single correct
145            // header at the start of the file. Start parsing after the 3 byte marker.
146            offset = 3;
147
148            let header = bytes.gread_with::<Unreal4Header>(&mut offset, scroll::LE)?;
149            let files = gread_files(&bytes, header.file_count as usize, &mut offset)?;
150
151            (header, files)
152        } else {
153            // The header is repeated at the beginning and the end of the file. The first one is
154            // merely a placeholder, the second contains actual information. However, it's not
155            // possible to parse it right away, so we only read the file count and parse the rest
156            // progressively.
157            let file_count = bytes.pread_with::<i32>(bytes.len() - 4, scroll::LE)? as usize;
158
159            // Ignore the initial header and use the one at the end of the file instead.
160            bytes.gread_with::<Unreal4Header>(&mut offset, scroll::LE)?;
161
162            let files = gread_files(&bytes, file_count, &mut offset)?;
163            let header = bytes.gread_with(&mut offset, scroll::LE)?;
164
165            (header, files)
166        };
167
168        if offset != bytes.len() {
169            return Err(Unreal4ErrorKind::TrailingData.into());
170        }
171
172        Ok(Unreal4Crash {
173            bytes,
174            header,
175            files,
176        })
177    }
178
179    /// Parses a UE4 crash dump from the original, compressed data.
180    ///
181    /// To prevent unbounded decompression, consider using
182    /// [`parse_with_limit`](Self::parse_with_limit) with an explicit limit, instead.
183    pub fn parse(slice: &[u8]) -> Result<Self, Unreal4Error> {
184        Self::parse_with_limit(slice, usize::MAX)
185    }
186
187    /// Parses a UE4 crash dump from the original, compressed data up to a maximum size limit.
188    ///
189    /// If files contained within the UE4 crash exceed the given size `limit`, this function returns
190    /// `Err` with [`Unreal4ErrorKind::TooLarge`].
191    pub fn parse_with_limit(slice: &[u8], limit: usize) -> Result<Self, Unreal4Error> {
192        if slice.is_empty() {
193            return Err(Unreal4ErrorKind::Empty.into());
194        }
195
196        let mut decompressed = Vec::new();
197        let decoder = &mut ZlibDecoder::new(slice);
198
199        decoder
200            .take(limit as u64)
201            .read_to_end(&mut decompressed)
202            .map_err(|e| Unreal4Error::new(Unreal4ErrorKind::BadCompression, e))?;
203
204        // The decoder was not exhausted if there's still a byte to read. Given that we're decoding
205        // from a slice, it should be safe to ignore `ErrorKind::Interrupted` and the likes.
206        if !matches!(decoder.read(&mut [0; 1]), Ok(0)) {
207            return Err(Unreal4ErrorKind::TooLarge.into());
208        }
209
210        Self::from_bytes(decompressed.into())
211    }
212
213    /// Returns the file name of this UE4 crash.
214    pub fn name(&self) -> &str {
215        &self.header.file_name
216    }
217
218    /// Returns the directory path of this UE4 crash.
219    pub fn directory_name(&self) -> &str {
220        &self.header.directory_name
221    }
222
223    /// Returns an iterator over all files within this UE4 crash dump.
224    pub fn files(&self) -> Unreal4FileIterator<'_> {
225        Unreal4FileIterator {
226            inner: self.files.iter(),
227            bytes: &self.bytes,
228        }
229    }
230
231    /// Count of files within the UE4 crash dump.
232    pub fn file_count(&self) -> usize {
233        self.files.len()
234    }
235
236    /// Returns the file at the given index.
237    pub fn file_by_index(&self, index: usize) -> Option<Unreal4File> {
238        self.files().nth(index)
239    }
240
241    /// Returns a file by its type.
242    ///
243    /// If there are multiple files matching the given type, the first match is returned.
244    pub fn file_by_type(&self, ty: Unreal4FileType) -> Option<Unreal4File> {
245        self.files().find(|f| f.ty() == ty)
246    }
247
248    /// Returns the native crash report contained.
249    pub fn native_crash(&self) -> Option<Unreal4File> {
250        self.files().find(|f| {
251            f.ty() == Unreal4FileType::Minidump || f.ty() == Unreal4FileType::AppleCrashReport
252        })
253    }
254
255    /// Get the `Unreal4Context` of this crash.
256    ///
257    /// This is achieved by reading the context (xml) file
258    /// If the file doesn't exist in the crash, `None` is returned.
259    pub fn context(&self) -> Result<Option<Unreal4Context>, Unreal4Error> {
260        match self.file_by_type(Unreal4FileType::Context) {
261            Some(file) => Unreal4Context::parse(file.data()).map(Some),
262            None => Ok(None),
263        }
264    }
265
266    /// Get up to `limit` log entries of this crash.
267    pub fn logs(&self, limit: usize) -> Result<Vec<Unreal4LogEntry>, Unreal4Error> {
268        match self.file_by_type(Unreal4FileType::Log) {
269            Some(file) => Unreal4LogEntry::parse(file.data(), limit),
270            None => Ok(Vec::new()),
271        }
272    }
273}
274
275/// The type of the file within the UE4 crash.
276#[non_exhaustive]
277#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
278pub enum Unreal4FileType {
279    /// Microsoft or Breakpad Minidump.
280    Minidump,
281    /// Apple crash report text file.
282    AppleCrashReport,
283    /// Log file.
284    Log,
285    /// The .ini config file.
286    Config,
287    /// The XML context file.
288    Context,
289    /// Unknown file type.
290    Unknown,
291}
292
293impl Unreal4FileType {
294    /// Returns the display name of this file type.
295    pub fn name(self) -> &'static str {
296        match self {
297            Unreal4FileType::Minidump => "minidump",
298            Unreal4FileType::AppleCrashReport => "applecrashreport",
299            Unreal4FileType::Log => "log",
300            Unreal4FileType::Config => "config",
301            Unreal4FileType::Context => "context",
302            Unreal4FileType::Unknown => "unknown",
303        }
304    }
305}
306
307impl fmt::Display for Unreal4FileType {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        write!(f, "{}", self.name())
310    }
311}
312
313/// A file withing an `Unreal4Crash`.
314///
315/// The file internally holds a reference to the entire unreal 4 crash data.
316#[derive(Debug)]
317pub struct Unreal4File {
318    /// The original index within the UE4 crash file.
319    index: usize,
320    /// The file name.
321    file_name: String,
322    /// A handle to the data of this file.
323    bytes: Bytes,
324}
325
326impl Unreal4File {
327    /// Creates an instance from the header and data.
328    fn from_meta(meta: &Unreal4FileMeta, bytes: &Bytes) -> Self {
329        Unreal4File {
330            index: meta.index,
331            file_name: meta.file_name.as_str().to_owned(),
332            bytes: bytes.slice(meta.offset..meta.offset + meta.len),
333        }
334    }
335
336    /// Returns the original index of this file in the unreal crash.
337    pub fn index(&self) -> usize {
338        self.index
339    }
340
341    /// Returns the file name of this file (without path).
342    pub fn name(&self) -> &str {
343        &self.file_name
344    }
345
346    /// Returns the raw contents of this file.
347    pub fn data(&self) -> &[u8] {
348        &self.bytes
349    }
350
351    /// Returns the file type.
352    pub fn ty(&self) -> Unreal4FileType {
353        if self.name() == "CrashReportClient.ini" {
354            Unreal4FileType::Config
355        } else if self.name() == "CrashContext.runtime-xml" {
356            Unreal4FileType::Context
357        } else if self.name().ends_with(".log") {
358            Unreal4FileType::Log
359        } else if self.data().starts_with(b"MDMP") {
360            Unreal4FileType::Minidump
361        } else if self.data().starts_with(b"Incident Identifier:") {
362            Unreal4FileType::AppleCrashReport
363        } else {
364            Unreal4FileType::Unknown
365        }
366    }
367}
368
369/// An iterator over `Unreal4File`.
370pub struct Unreal4FileIterator<'a> {
371    inner: std::slice::Iter<'a, Unreal4FileMeta>,
372    bytes: &'a Bytes,
373}
374
375impl Iterator for Unreal4FileIterator<'_> {
376    type Item = Unreal4File;
377
378    fn next(&mut self) -> Option<Self::Item> {
379        let meta = self.inner.next()?;
380        Some(Unreal4File::from_meta(meta, self.bytes))
381    }
382
383    fn size_hint(&self) -> (usize, Option<usize>) {
384        self.inner.size_hint()
385    }
386
387    fn count(self) -> usize {
388        self.inner.count()
389    }
390
391    fn nth(&mut self, n: usize) -> Option<Self::Item> {
392        let meta = self.inner.nth(n)?;
393        Some(Unreal4File::from_meta(meta, self.bytes))
394    }
395}
396
397impl DoubleEndedIterator for Unreal4FileIterator<'_> {
398    fn next_back(&mut self) -> Option<Self::Item> {
399        let meta = self.inner.next_back()?;
400        Some(Unreal4File::from_meta(meta, self.bytes))
401    }
402}
403
404impl FusedIterator for Unreal4FileIterator<'_> {}
405
406impl ExactSizeIterator for Unreal4FileIterator<'_> {}
407
408#[cfg(test)]
409mod tests {
410    use std::{error::Error, fs::File};
411    use symbolic_testutils::fixture;
412
413    use super::*;
414
415    #[test]
416    fn test_parse_empty_buffer() {
417        let crash = &[];
418
419        let result = Unreal4Crash::parse(crash);
420
421        assert!(matches!(
422            result.expect_err("empty crash").kind(),
423            Unreal4ErrorKind::Empty
424        ));
425    }
426
427    #[test]
428    fn test_parse_invalid_input() {
429        let crash = &[0u8; 1];
430
431        let result = Unreal4Crash::parse(crash);
432        let error = result.expect_err("empty crash");
433        assert_eq!(error.kind(), Unreal4ErrorKind::BadCompression);
434
435        let source = error.source().expect("error source");
436        assert_eq!(source.to_string(), "corrupt deflate stream");
437    }
438
439    // The size of the unreal_crash fixture when decompressed.
440    const DECOMPRESSED_SIZE: usize = 440752;
441
442    #[test]
443    fn test_parse_too_large() {
444        let mut file = File::open(fixture("unreal/unreal_crash")).expect("example file opens");
445        let mut file_content = Vec::new();
446        file.read_to_end(&mut file_content).expect("fixture file");
447
448        let result = Unreal4Crash::parse_with_limit(&file_content, DECOMPRESSED_SIZE - 1);
449        let error = result.expect_err("too large");
450        assert_eq!(error.kind(), Unreal4ErrorKind::TooLarge);
451    }
452
453    #[test]
454    fn test_parse_fits_exact() {
455        let mut file = File::open(fixture("unreal/unreal_crash")).expect("example file opens");
456        let mut file_content = Vec::new();
457        file.read_to_end(&mut file_content).expect("fixture file");
458
459        Unreal4Crash::parse_with_limit(&file_content, DECOMPRESSED_SIZE)
460            .expect("file fits decompression buffer");
461    }
462}